feat(web-platform): release V0.8.3 常规界面优化
web-platform-release / Build and publish release (push) 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:
2026-09-04 15:34:24 +08:00
parent 63d67a645b
commit fa5485049a
60 changed files with 4494 additions and 2073 deletions
+321
View File
@@ -0,0 +1,321 @@
import { Copy, LockKeyhole, Trash2 } from 'lucide-react';
import { Button, ScrubbableNumberInput, Select } from '../components/ui';
import { MAP_OBJECT_PLACEMENT_LABELS, type EditableMapObject } from './editor/types';
const PARAMETER_LABELS: Record<string, string> = {
sizeX: '尺寸 X(m)',
sizeY: '尺寸 Y(m)',
sizeZ: '尺寸 Z(m)',
radius: '半径(m)',
height: '高度(m)',
length: '长度(m)',
width: '宽度(m)',
rise: '抬升(m)',
thickness: '厚度(m)',
stepDepth: '踏步深度(m)',
stepHeight: '踏步高度(m)',
count: '踏步数量',
};
const SURFACE_PRESETS = {
standard: {
label: '标准防滑材质',
rgb: [0.55, 0.6, 0.68] as const,
friction: [1, 0.005, 0.0001] as const,
},
concrete: {
label: '混凝土',
rgb: [0.55, 0.58, 0.62] as const,
friction: [1, 0.005, 0.0001] as const,
},
rubber: {
label: '橡胶',
rgb: [0.12, 0.14, 0.16] as const,
friction: [1.5, 0.008, 0.0002] as const,
},
metal: {
label: '金属',
rgb: [0.42, 0.48, 0.54] as const,
friction: [0.45, 0.003, 0.0001] as const,
},
ice: {
label: '低摩擦冰面',
rgb: [0.58, 0.82, 0.94] as const,
friction: [0.08, 0.001, 0.00001] as const,
},
} as const;
type SurfacePreset = keyof typeof SURFACE_PRESETS;
function close(a: number, b: number): boolean {
return Math.abs(a - b) < 0.015;
}
function selectedSurfacePreset(object: EditableMapObject): SurfacePreset | 'custom' {
for (const [key, preset] of Object.entries(SURFACE_PRESETS) as [
SurfacePreset,
(typeof SURFACE_PRESETS)[SurfacePreset],
][]) {
if (
preset.rgb.every((channel, index) => close(channel, object.rgba[index])) &&
close(preset.friction[0], object.friction[0])
)
return key;
}
return 'custom';
}
function yawDegrees(object: EditableMapObject): number {
const [w, x, y, z] = object.pose.quaternion;
return (Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) * 180) / Math.PI;
}
function colorValue(object: EditableMapObject): string {
return `#${object.rgba
.slice(0, 3)
.map((channel) =>
Math.round(Math.min(1, Math.max(0, channel)) * 255)
.toString(16)
.padStart(2, '0'),
)
.join('')}`;
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="engineering-card rounded-lg border p-2.5">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-text-tertiary">
{title}
</h3>
{children}
</section>
);
}
export function MapObjectInspector({
object,
loading,
onUpdate,
onDuplicate,
onDelete,
}: {
object: EditableMapObject;
loading: boolean;
onUpdate: (patch: Partial<Omit<EditableMapObject, 'id' | 'type'>>) => void;
onDuplicate: () => void;
onDelete: () => void;
}) {
const poseLocked = object.placementMode === 'locked';
const updatePosition = (index: number, value: number) => {
if (!Number.isFinite(value)) return;
const position = [...object.pose.position] as [number, number, number];
position[index] = value;
onUpdate({ pose: { ...object.pose, position } });
};
const updateParameter = (key: string, value: number) => {
if (!Number.isFinite(value) || value <= 0) return;
onUpdate({
parameters: {
...object.parameters,
[key]: key === 'count' ? Math.round(value) : value,
},
});
};
const updateFriction = (index: number, value: number) => {
if (!Number.isFinite(value) || value < 0) return;
const friction = [...object.friction] as [number, number, number];
friction[index] = value;
onUpdate({ friction });
};
return (
<div className="space-y-2" aria-label="地图物体检查器">
<Section title="对象">
<div className="space-y-2">
<label className="block text-[11px] text-text-secondary">
名称
<input
aria-label="对象名称"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-2"
value={object.name}
disabled={loading}
onChange={(event) => {
if (event.target.value.trim()) onUpdate({ name: event.target.value });
}}
/>
</label>
<label className="flex items-center justify-between text-[11px] text-text-secondary">
<span>启用碰撞与渲染</span>
<input
aria-label="启用地图对象"
type="checkbox"
checked={object.enabled}
disabled={loading}
onChange={(event) => onUpdate({ enabled: event.target.checked })}
/>
</label>
</div>
</Section>
<Section title="位姿">
<div className="mb-2 flex items-center justify-between rounded bg-surface/55 px-2 py-1.5 text-[10px] text-text-secondary">
<span>{MAP_OBJECT_PLACEMENT_LABELS[object.placementMode]}</span>
<span className="flex items-center gap-1 text-text-tertiary">
{poseLocked && <LockKeyhole className="h-3 w-3" aria-hidden="true" />}
在视口工具条切换
</span>
</div>
<div className="grid grid-cols-3 gap-2">
{object.pose.position.map((value, index) => (
<ScrubbableNumberInput
key={index}
label={`${['X', 'Y', 'Z'][index]}(m)`}
aria-label={`对象位置${['X', 'Y', 'Z'][index]}`}
value={value}
step={0.1}
disabled={loading || poseLocked || index === 2}
onValueChange={(next) => updatePosition(index, next)}
/>
))}
</div>
<ScrubbableNumberInput
containerClassName="mt-2"
label="绕 Z 旋转(°)"
aria-label="对象绕Z旋转"
value={Number(yawDegrees(object).toFixed(4))}
step={1}
disabled={loading || poseLocked}
onValueChange={(next) => {
const half = (next * Math.PI) / 360;
onUpdate({
pose: {
...object.pose,
quaternion: [Math.cos(half), 0, 0, Math.sin(half)],
},
});
}}
/>
</Section>
<Section title="尺寸">
<div className="grid grid-cols-2 gap-2">
{Object.entries(object.parameters).map(([key, value]) => (
<ScrubbableNumberInput
key={key}
label={PARAMETER_LABELS[key] ?? key}
aria-label={`对象参数${key}`}
value={value}
min={0.001}
step={key === 'count' ? 1 : 0.1}
disabled={loading}
onValueChange={(next) => updateParameter(key, next)}
/>
))}
</div>
</Section>
<Section title="表面材质">
<div className="grid grid-cols-2 gap-2">
<label className="text-[10px] text-text-secondary">
材质预设
<Select
aria-label="对象表面材质"
className="mt-1 w-full"
value={selectedSurfacePreset(object)}
disabled={loading}
onChange={(event) => {
if (event.target.value === 'custom') return;
const preset = SURFACE_PRESETS[event.target.value as SurfacePreset];
onUpdate({
rgba: [...preset.rgb, object.rgba[3]],
friction: [...preset.friction],
});
}}
>
<option value="custom">自定义</option>
{(
Object.entries(SURFACE_PRESETS) as [
SurfacePreset,
(typeof SURFACE_PRESETS)[SurfacePreset],
][]
).map(([value, preset]) => (
<option key={value} value={value}>
{preset.label}
</option>
))}
</Select>
</label>
<label className="text-[10px] text-text-secondary">
表面颜色
<input
aria-label="对象颜色"
className="mt-1 h-7 w-full rounded border border-border-strong bg-input p-0.5"
type="color"
value={colorValue(object)}
disabled={loading}
onChange={(event) => {
const value = event.target.value;
if (!/^#[0-9a-f]{6}$/i.test(value)) return;
onUpdate({
rgba: [
Number.parseInt(value.slice(1, 3), 16) / 255,
Number.parseInt(value.slice(3, 5), 16) / 255,
Number.parseInt(value.slice(5, 7), 16) / 255,
object.rgba[3],
],
});
}}
/>
</label>
</div>
<ScrubbableNumberInput
containerClassName="mt-2"
label="不透明度"
aria-label="对象不透明度"
value={object.rgba[3]}
min={0}
max={1}
step={0.05}
disabled={loading}
onValueChange={(alpha) =>
onUpdate({
rgba: [object.rgba[0], object.rgba[1], object.rgba[2], alpha],
})
}
/>
</Section>
<Section title="摩擦力">
<div className="grid grid-cols-3 gap-2">
{object.friction.map((value, index) => (
<ScrubbableNumberInput
key={index}
label={['滑动', '扭转', '滚动'][index]}
aria-label={`对象${['滑动摩擦', '扭转摩擦', '滚动摩擦'][index]}`}
value={value}
min={0}
max={5}
step={index === 0 ? 0.05 : 0.0001}
disabled={loading}
onValueChange={(next) => updateFriction(index, next)}
/>
))}
</div>
</Section>
<div className="grid grid-cols-2 gap-2">
<Button disabled={loading} icon={<Copy className="h-3 w-3" />} onClick={onDuplicate}>
复制对象
</Button>
<Button
variant="danger"
disabled={loading}
icon={<Trash2 className="h-3 w-3" />}
onClick={onDelete}
>
删除对象
</Button>
</div>
</div>
);
}