feat: localize Pascal editor in Chinese

This commit is contained in:
134 2026-07-12 00:23:29 +08:00
parent 8bd0308164
commit 312064e6e8
27 changed files with 316 additions and 279 deletions

View File

@ -29,7 +29,7 @@ export default function RootLayout({
return (
<html
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable} ${barlow.variable}`}
lang="en"
lang="zh-CN"
>
<head>
{process.env.NODE_ENV === 'development' && (

View File

@ -21,7 +21,7 @@ function EditorItemsPanel() {
const SIDEBAR_TABS = [
{
id: 'site',
label: 'Scene',
label: '场景',
component: () => null,
mobileDefaultSnap: 0.5,
mobileIcon: <Layers className="h-5 w-5" />,
@ -37,7 +37,7 @@ const SIDEBAR_TABS = [
},
{
id: 'build',
label: 'Build',
label: '搭建',
component: BuildTab,
mobileDefaultSnap: 0.5,
mobileIcon: <Hammer className="h-5 w-5" />,
@ -53,7 +53,7 @@ const SIDEBAR_TABS = [
},
{
id: 'items',
label: 'Items',
label: '物品',
component: EditorItemsPanel,
mobileDefaultSnap: 0.5,
mobileIcon: <Package className="h-5 w-5" />,
@ -69,7 +69,7 @@ const SIDEBAR_TABS = [
},
{
id: 'settings',
label: 'Settings',
label: '设置',
component: () => null,
mobileDefaultSnap: 0.5,
mobileIcon: <Settings className="h-5 w-5" />,
@ -93,15 +93,15 @@ export default function Home() {
{PROJECT_ID === 'local-editor' && (
<div className="pointer-events-none absolute top-3 left-1/2 z-40 -translate-x-1/2">
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs shadow-sm backdrop-blur">
<span className="text-muted-foreground">Local editor — scenes are not saved.</span>
<span className="text-muted-foreground">本地编辑器中的场景不会自动保存。</span>
<Link className="font-medium text-foreground hover:underline" href="/scenes">
Open recent scenes
打开最近场景
</Link>
<span aria-hidden className="text-muted-foreground">
·
</span>
<Link className="font-medium text-foreground hover:underline" href="/scenes">
Create new
新建场景
</Link>
</div>
</div>

View File

@ -29,22 +29,22 @@ export default async function ScenePage({ params }: { params: Promise<{ id: stri
<div className="flex min-h-screen items-center justify-center bg-background p-6">
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 text-center shadow-xl">
<p className="font-mono text-muted-foreground text-xs uppercase tracking-wide">404</p>
<h1 className="mt-2 font-semibold text-lg">Scene not found</h1>
<h1 className="mt-2 font-semibold text-lg">未找到场景</h1>
<p className="mt-2 text-muted-foreground text-sm">
We couldn&apos;t find a scene with id <code className="font-mono">{id}</code>.
无法找到 ID 为 <code className="font-mono">{id}</code> 的场景。
</p>
<div className="mt-4 flex items-center justify-center gap-2">
<Link
className="rounded-md border border-border bg-accent px-3 py-2 font-medium text-sm hover:bg-accent/80"
href="/scenes"
>
Browse scenes
查看场景
</Link>
<Link
className="rounded-md border border-border bg-background px-3 py-2 font-medium text-sm hover:bg-accent/40"
href="/"
>
Back to editor
返回编辑器
</Link>
</div>
</div>

View File

@ -16,7 +16,7 @@ async function fetchScenes(): Promise<SceneMeta[]> {
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString()
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
} catch {
return iso
}
@ -34,26 +34,24 @@ export default async function ScenesPage() {
className="text-muted-foreground transition-colors hover:text-foreground"
href="/"
>
Home
首页
</Link>
<span className="text-muted-foreground">/</span>
<span className="font-medium text-foreground">Scenes</span>
<span className="font-medium text-foreground">场景</span>
</nav>
<CreateSceneButton />
</div>
</header>
<main className="container mx-auto max-w-5xl px-6 py-12">
<h1 className="mb-2 font-bold text-3xl">Your scenes</h1>
<h1 className="mb-2 font-bold text-3xl">我的场景</h1>
<p className="mb-8 text-muted-foreground text-sm">
{scenes.length === 0
? 'No scenes yet. Create one to get started.'
: `${scenes.length} scene${scenes.length === 1 ? '' : 's'}.`}
{scenes.length === 0 ? '还没有场景,创建一个开始设计吧。' : `共 ${scenes.length} 个场景`}
</p>
{scenes.length === 0 ? (
<div className="rounded-xl border border-border/60 border-dashed bg-background p-12 text-center">
<p className="text-muted-foreground text-sm">You haven&apos;t saved any scenes yet.</p>
<p className="text-muted-foreground text-sm">尚未保存任何场景。</p>
<div className="mt-4 flex justify-center">
<CreateSceneButton />
</div>
@ -75,7 +73,7 @@ export default async function ScenesPage() {
src={scene.thumbnailUrl}
/>
) : (
<span className="text-muted-foreground text-xs">No thumbnail</span>
<span className="text-muted-foreground text-xs">暂无缩略图</span>
)}
</div>
<div className="mt-3">
@ -83,7 +81,7 @@ export default async function ScenesPage() {
{scene.name}
</h2>
<div className="mt-1 flex items-center justify-between text-muted-foreground text-xs">
<span>{scene.nodeCount} nodes</span>
<span>{scene.nodeCount} 个元素</span>
<time dateTime={scene.updatedAt}>{formatDate(scene.updatedAt)}</time>
</div>
</div>

View File

@ -69,53 +69,53 @@ type MepItem = {
// Same icons + ordering as the community Build sidebar, minus presets.
const BUILD_TYPES: BuildType[] = [
{ id: 'wall', label: 'Wall', iconSrc: withPascalBasePath('/icons/wall.webp'), kind: 'wall' },
{ id: 'fence', label: 'Fence', iconSrc: withPascalBasePath('/icons/fence.webp'), kind: 'fence' },
{ id: 'slab', label: 'Slab', iconSrc: withPascalBasePath('/icons/floor.webp'), kind: 'slab' },
{ id: 'wall', label: '墙体', iconSrc: withPascalBasePath('/icons/wall.webp'), kind: 'wall' },
{ id: 'fence', label: '围栏', iconSrc: withPascalBasePath('/icons/fence.webp'), kind: 'fence' },
{ id: 'slab', label: '楼板', iconSrc: withPascalBasePath('/icons/floor.webp'), kind: 'slab' },
{
id: 'ceiling',
label: 'Ceiling',
label: '天花板',
iconSrc: withPascalBasePath('/icons/ceiling.webp'),
kind: 'ceiling',
},
{ id: 'roof', label: 'Roof', iconSrc: withPascalBasePath('/icons/roof.webp'), kind: 'roof' },
{ id: 'roof', label: '屋顶', iconSrc: withPascalBasePath('/icons/roof.webp'), kind: 'roof' },
{
id: 'stair',
label: 'Stairs',
label: '楼梯',
iconSrc: withPascalBasePath('/icons/stairs.webp'),
kind: 'stair',
},
{
id: 'elevator',
label: 'Elevator',
label: '电梯',
iconSrc: withPascalBasePath('/icons/elevator.webp'),
kind: 'elevator',
},
{ id: 'door', label: 'Door', iconSrc: withPascalBasePath('/icons/door.webp'), kind: 'door' },
{ id: 'door', label: '门', iconSrc: withPascalBasePath('/icons/door.webp'), kind: 'door' },
{
id: 'window',
label: 'Window',
label: '窗户',
iconSrc: withPascalBasePath('/icons/window.webp'),
kind: 'window',
},
{
id: 'column',
label: 'Column',
label: '立柱',
iconSrc: withPascalBasePath('/icons/column.webp'),
kind: 'column',
},
{ id: 'shelf', label: 'Shelf', iconSrc: withPascalBasePath('/icons/shelf.webp'), kind: 'shelf' },
{ id: 'shelf', label: '置物架', iconSrc: withPascalBasePath('/icons/shelf.webp'), kind: 'shelf' },
{
id: 'spawn',
label: 'Spawn Point',
label: '出生点',
iconSrc: withPascalBasePath('/icons/spawn-point.webp'),
kind: 'spawn',
},
// Group tile — no tool of its own; opens the MEP sub-grid below (like Roof).
{ id: 'mep', label: 'MEP', iconSrc: withPascalBasePath('/icons/HVAC.webp') },
{ id: 'mep', label: '机电管线', iconSrc: withPascalBasePath('/icons/HVAC.webp') },
{
id: 'painting',
label: 'Painting',
label: '材质涂装',
iconSrc: withPascalBasePath('/icons/paint.webp'),
mode: 'material-paint',
},
@ -126,37 +126,37 @@ const BUILD_TYPES: BuildType[] = [
const MEP_ITEMS: MepItem[] = [
{
id: 'duct-segment',
label: 'Duct',
label: '风管',
iconSrc: withPascalBasePath('/icons/duct.webp'),
kind: 'duct-segment',
},
{
id: 'duct-terminal',
label: 'Register',
label: '风口',
iconSrc: withPascalBasePath('/icons/registers.webp'),
kind: 'duct-terminal',
},
{
id: 'hvac-equipment',
label: 'HVAC Unit',
label: '暖通设备',
iconSrc: withPascalBasePath('/icons/HVAC.webp'),
kind: 'hvac-equipment',
},
{
id: 'lineset',
label: 'Lineset',
label: '冷媒管组',
iconSrc: withPascalBasePath('/icons/lineset.webp'),
kind: 'lineset',
},
{
id: 'liquid-line',
label: 'Liquid Line',
label: '液管',
iconSrc: withPascalBasePath('/icons/lineset.webp'),
kind: 'liquid-line',
},
{
id: 'pipe-segment',
label: 'DWV Pipe',
label: '排水通气管',
iconSrc: withPascalBasePath('/icons/dwv-pipes.webp'),
kind: 'pipe-segment',
},
@ -186,6 +186,20 @@ function activatePaintMode(): void {
type RoofFeature = { kind: string; label: string; iconSrc: string }
const ROOF_FEATURE_LABELS: Record<string, string> = {
'box-vent': '箱式通风口',
chimney: '烟囱',
cupola: '屋顶钟楼',
dormer: '老虎窗',
downspout: '落水管',
'eyebrow-vent': '眉形通风口',
gutter: '天沟',
'ridge-vent': '屋脊通风口',
skylight: '天窗',
'solar-panel': '太阳能板',
'turbine-vent': '涡轮通风器',
}
const ROOF_FEATURE_FALLBACK_ICON = withPascalBasePath('/icons/roof.webp')
/**
@ -259,7 +273,7 @@ export function BuildTab() {
const icon = def.presentation?.icon
features.push({
kind,
label: def.presentation?.label ?? kind,
label: ROOF_FEATURE_LABELS[kind] ?? def.presentation?.label ?? kind,
iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON,
})
}
@ -362,7 +376,7 @@ export function BuildTab() {
(activeTool === 'roof' || isRoofFeatureActive) &&
roofFeatures.length > 0 ? (
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">Features</div>
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">屋顶构件</div>
<TooltipProvider delayDuration={0} disableHoverableContent>
<div
className="grid gap-1.5"
@ -407,7 +421,7 @@ export function BuildTab() {
</div>
) : isMepActive ? (
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">MEP</div>
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">机电管线</div>
<TooltipProvider delayDuration={0} disableHoverableContent>
<div
className="grid gap-1.5 px-0.5"
@ -452,7 +466,7 @@ export function BuildTab() {
{ductContext ? (
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">Duct</span>
<span className="text-muted-foreground text-xs">风管</span>
<button
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-200',
@ -475,14 +489,14 @@ export function BuildTab() {
src={withPascalBasePath('/icons/duct-fitting.webp')}
width={16}
/>
Add Fitting
添加管件
</button>
</div>
) : null}
{pipeContext ? (
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">DWV Pipe</span>
<span className="text-muted-foreground text-xs">排水通气管</span>
<button
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-200',
@ -505,7 +519,7 @@ export function BuildTab() {
src={withPascalBasePath('/icons/duct-fitting.webp')}
width={16}
/>
Add Fitting
添加管件
</button>
<button
className={cn(
@ -529,14 +543,14 @@ export function BuildTab() {
src={withPascalBasePath('/icons/dwv-pipes.webp')}
width={16}
/>
Add Trap
添加存水弯
</button>
</div>
) : null}
{liquidLineContext ? (
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">Liquid Line</span>
<span className="text-muted-foreground text-xs">液管</span>
<button
className={cn(
'flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-200',
@ -549,13 +563,11 @@ export function BuildTab() {
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
<span>Follow lineset</span>
<span className="text-muted-foreground text-xs">{follow ? 'On' : 'Off'}</span>
<span>跟随冷媒管组</span>
<span className="text-muted-foreground text-xs">{follow ? '开' : '关'}</span>
</button>
<span className="px-1 text-[11px] text-muted-foreground">
{follow
? 'Click a lineset to lay the line beside it.'
: 'Trace a line alongside an existing lineset (F).'}
{follow ? '单击冷媒管组,在其旁边铺设液管。' : '沿现有冷媒管组绘制液管(F)。'}
</span>
</div>
) : null}

View File

@ -20,7 +20,7 @@ interface SaveButtonProps {
/**
* Creates a new empty scene and navigates the user to it.
*/
export function CreateSceneButton({ label = 'Create new scene' }: { label?: string } = {}) {
export function CreateSceneButton({ label = '新建场景' }: { label?: string } = {}) {
const router = useRouter()
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -32,16 +32,16 @@ export function CreateSceneButton({ label = 'Create new scene' }: { label?: stri
const response = await fetch(withPascalBasePath('/api/scenes'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Untitled scene', graph: EMPTY_GRAPH }),
body: JSON.stringify({ name: '未命名场景', graph: EMPTY_GRAPH }),
})
if (!response.ok) {
setError(`Failed to create scene (${response.status})`)
setError(`创建场景失败 (${response.status})`)
return
}
const meta = (await response.json()) as { id: string }
router.push(`/scene/${meta.id}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create scene')
setError(err instanceof Error ? err.message : '创建场景失败')
} finally {
setIsCreating(false)
}
@ -56,7 +56,7 @@ export function CreateSceneButton({ label = 'Create new scene' }: { label?: stri
onClick={handleCreate}
type="button"
>
{isCreating ? 'Creating…' : label}
{isCreating ? '正在创建…' : label}
</button>
</div>
)
@ -75,7 +75,7 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps
const handleSave = useCallback(async () => {
const graph = getGraph()
if (!graph) {
setStatus('No scene to save')
setStatus('没有可保存的场景')
return
}
setIsSaving(true)
@ -90,16 +90,16 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps
body: JSON.stringify({ name, graph }),
})
if (response.status === 409) {
setStatus('Conflict — reload to continue')
setStatus('保存冲突,请刷新后继续')
return
}
if (!response.ok) {
setStatus(`Save failed (${response.status})`)
setStatus(`保存失败 (${response.status})`)
return
}
setStatus('Saved')
setStatus('已保存')
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Save failed')
setStatus(error instanceof Error ? error.message : '保存失败')
} finally {
setIsSaving(false)
}
@ -108,10 +108,10 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps
const handleSaveAs = useCallback(async () => {
const graph = getGraph()
if (!graph) {
setStatus('No scene to save')
setStatus('没有可保存的场景')
return
}
const newName = typeof window !== 'undefined' ? window.prompt('New scene name', name) : null
const newName = typeof window !== 'undefined' ? window.prompt('请输入新场景名称', name) : null
if (!newName) return
setIsSaving(true)
setStatus(null)
@ -122,13 +122,13 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps
body: JSON.stringify({ name: newName, graph }),
})
if (!response.ok) {
setStatus(`Save-as failed (${response.status})`)
setStatus(`另存为失败 (${response.status})`)
return
}
const meta = (await response.json()) as { id: string }
router.push(`/scene/${meta.id}`)
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Save-as failed')
setStatus(error instanceof Error ? error.message : '另存为失败')
} finally {
setIsSaving(false)
}
@ -142,7 +142,7 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps
onClick={handleSave}
type="button"
>
{isSaving ? 'Saving…' : 'Save'}
{isSaving ? '正在保存…' : '保存'}
</button>
<button
className="rounded-md border border-border bg-background px-3 py-1.5 font-medium text-xs hover:bg-accent/40 disabled:opacity-50"
@ -150,7 +150,7 @@ export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps
onClick={handleSaveAs}
type="button"
>
Save as…
另存为…
</button>
{status && <span className="text-muted-foreground text-xs">{status}</span>}
</div>

View File

@ -35,7 +35,7 @@ export interface SceneMeta {
const BASE_SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
{
id: 'site',
label: 'Scene',
label: '场景',
component: () => null, // Built-in SitePanel handles this
mobileDefaultSnap: 0.5,
mobileIcon: <Layers className="h-5 w-5" />,
@ -51,7 +51,7 @@ const BASE_SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
},
{
id: 'build',
label: 'Build',
label: '搭建',
component: BuildTab,
mobileDefaultSnap: 0.5,
mobileIcon: <Hammer className="h-5 w-5" />,
@ -109,7 +109,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
...BASE_SIDEBAR_TABS,
{
id: 'ai',
label: 'AI Designer',
label: 'AI 设计助手',
component: SceneAgentPanel,
mobileDefaultSnap: 0.72,
mobileIcon: <Bot className="h-5 w-5" />,
@ -152,7 +152,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
}
if (!response.ok) {
setSaveError(`Save failed (${response.status})`)
setSaveError(`保存失败 (${response.status})`)
return
}
@ -160,7 +160,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
versionRef.current = next.version
setSaveError(null)
} catch (error) {
setSaveError(error instanceof Error ? error.message : 'Save failed')
setSaveError(error instanceof Error ? error.message : '保存失败')
}
},
[meta.id, meta.name],
@ -189,7 +189,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
source.addEventListener('error', () => {
if (source.readyState === EventSource.CLOSED) {
setSaveError('Live scene connection closed')
setSaveError('场景实时连接已断开')
}
})
@ -214,9 +214,9 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
<div className="relative h-screen w-screen">
{conflict && (
<div className="pointer-events-auto absolute top-4 left-1/2 z-50 w-full max-w-md -translate-x-1/2 rounded-lg border border-border bg-background p-4 shadow-xl">
<h2 className="font-semibold text-sm">Another session saved first — refresh?</h2>
<h2 className="font-semibold text-sm">其他会话已先保存,是否刷新?</h2>
<p className="mt-1 text-muted-foreground text-xs">
Your changes haven&apos;t been saved. Reload to pick up the latest version.
当前更改尚未保存,请重新加载最新版本。
</p>
<div className="mt-3 flex items-center gap-2">
<button
@ -224,14 +224,14 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
onClick={() => router.refresh()}
type="button"
>
Reload
重新加载
</button>
<button
className="rounded-md border border-border bg-background px-3 py-1.5 font-medium text-xs hover:bg-accent/40"
onClick={() => setConflict(false)}
type="button"
>
Dismiss
暂不处理
</button>
</div>
</div>
@ -246,7 +246,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
className="pointer-events-auto rounded-md border border-border bg-background/90 px-3 py-1.5 font-medium text-xs shadow-sm backdrop-blur hover:bg-accent/40"
href="/scenes"
>
All scenes
全部场景
</Link>
</div>
<Editor

View File

@ -107,30 +107,30 @@ const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [
},
{
id: 'split',
label: 'Split',
label: '分屏',
icon: <Columns2 className="h-3 w-3" />,
},
]
const levelModeOrder = ['stacked', 'exploded', 'solo'] as const
const levelModeLabels: Record<string, string> = {
manual: 'Stack',
stacked: 'Stack',
exploded: 'Exploded',
solo: 'Solo',
manual: '堆叠',
stacked: '堆叠',
exploded: '分层',
solo: '单层',
}
const wallModeOrder = ['cutaway', 'up', 'down', 'translucent'] as const
const wallModeConfig: Record<string, { icon: string; label: string }> = {
up: { icon: withPascalBasePath('/icons/room.webp'), label: 'Full height' },
cutaway: { icon: withPascalBasePath('/icons/wallcut.webp'), label: 'Cutaway' },
down: { icon: withPascalBasePath('/icons/walllow.webp'), label: 'Low' },
translucent: { icon: withPascalBasePath('/icons/wall.webp'), label: 'Translucent' },
up: { icon: withPascalBasePath('/icons/room.webp'), label: '全高' },
cutaway: { icon: withPascalBasePath('/icons/wallcut.webp'), label: '剖切' },
down: { icon: withPascalBasePath('/icons/walllow.webp'), label: '低墙' },
translucent: { icon: withPascalBasePath('/icons/wall.webp'), label: '半透明' },
}
const SHADING_OPTIONS = [
{ id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box },
{ id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
{ id: 'solid', name: '实体', detail: '快速平面显示,不含环境光遮蔽', icon: Box },
{ id: 'rendered', name: '渲染', detail: '完整环境光遮蔽效果', icon: Sparkles },
] as const
function ViewModeControl() {
@ -175,9 +175,9 @@ function CollapseSidebarButton() {
return (
<div className={TOOLBAR_CONTAINER}>
<ToolbarTooltip label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}>
<ToolbarTooltip label={isCollapsed ? '展开侧边栏' : '收起侧边栏'}>
<button
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-label={isCollapsed ? '展开侧边栏' : '收起侧边栏'}
className={TOOLBAR_BTN}
onClick={toggle}
type="button"
@ -209,7 +209,7 @@ function LevelModeToggle() {
if (next) setLevelMode(next)
}
const label = `Levels: ${levelMode === 'manual' ? 'Manual' : (levelModeLabels[levelMode] ?? 'Stack')}`
const label = `楼层:${levelMode === 'manual' ? '手动' : (levelModeLabels[levelMode] ?? '堆叠')}`
return (
<ToolbarTooltip label={label}>
@ -229,7 +229,7 @@ function LevelModeToggle() {
) : (
<IconifyIcon height={14} icon="charm:stack-push" width={14} />
)}
<span className="font-medium text-xs">{levelModeLabels[levelMode] ?? 'Stack'}</span>
<span className="font-medium text-xs">{levelModeLabels[levelMode] ?? '堆叠'}</span>
</button>
</ToolbarTooltip>
)
@ -247,7 +247,7 @@ function WallModeToggle() {
}
return (
<ToolbarTooltip label={`Walls: ${config.label}`}>
<ToolbarTooltip label={`墙体:${config.label}`}>
<button
className={cn(
TOOLBAR_BTN,
@ -270,11 +270,27 @@ function WallModeToggle() {
// camera projection, units, render mode, edges and scene theme.
const EDGE_OPTIONS = [
{ id: 'off', name: 'Off', detail: 'No edge lines' },
{ id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' },
{ id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' },
{ id: 'off', name: '关闭', detail: '不显示边线' },
{ id: 'soft', name: '柔和', detail: '淡化显示主要折线' },
{ id: 'strong', name: '清晰', detail: '显示清晰不透明边线' },
] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
const SCENE_THEME_LABELS: Record<string, string> = {
studio: '工作室',
paper: '纸张',
sunset: '日落',
overcast: '阴天',
blueprint: '蓝图',
mediterranean: '地中海',
twilight: '黄昏',
night: '夜景',
verdant: '绿意',
}
function getSceneThemeLabel(id: string, fallback: string) {
return SCENE_THEME_LABELS[id] ?? fallback
}
const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl'
function DisplayMenu() {
@ -308,15 +324,15 @@ function DisplayMenu() {
return (
<DropdownMenu>
<ToolbarTooltip label="Display settings">
<ToolbarTooltip label="显示设置">
<DropdownMenuTrigger asChild>
<button
aria-label="Display settings"
aria-label="显示设置"
className={cn(TOOLBAR_BTN, 'w-auto gap-1.5 px-2.5 text-foreground/90')}
type="button"
>
<SlidersHorizontal className="h-3.5 w-3.5 shrink-0" />
<span className="font-medium text-xs">Display</span>
<span className="font-medium text-xs">显示</span>
</button>
</DropdownMenuTrigger>
</ToolbarTooltip>
@ -328,7 +344,7 @@ function DisplayMenu() {
>
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setShowGrid(!showGrid))}>
<Grid2X2 className="h-4 w-4" />
<span>Grid</span>
<span>网格</span>
{showGrid ? (
<Eye className="ml-auto h-4 w-4 text-foreground" />
) : (
@ -337,15 +353,15 @@ function DisplayMenu() {
</DropdownMenuItem>
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}>
<Magnet className="h-4 w-4" />
<span>Magnetic snap</span>
<span>磁吸捕捉</span>
<span className="ml-auto text-muted-foreground text-xs">
{magneticSnap ? 'On' : 'Off'}
{magneticSnap ? '开' : '关'}
</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setShadows(!shadows))}>
<Contrast className="h-4 w-4" />
<span>Shadows</span>
<span className="ml-auto text-muted-foreground text-xs">{shadows ? 'On' : 'Off'}</span>
<span>阴影</span>
<span className="ml-auto text-muted-foreground text-xs">{shadows ? '开' : '关'}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) =>
@ -359,9 +375,9 @@ function DisplayMenu() {
icon={cameraMode === 'perspective' ? 'icon-park-outline:perspective' : 'vaadin:grid'}
width={16}
/>
<span>Camera</span>
<span>相机</span>
<span className="ml-auto text-muted-foreground text-xs">
{cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
{cameraMode === 'perspective' ? '透视' : '正交'}
</span>
</DropdownMenuItem>
<DropdownMenuItem
@ -370,9 +386,9 @@ function DisplayMenu() {
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
{unit === 'metric' ? 'm' : 'ft'}
</span>
<span>Units</span>
<span>单位</span>
<span className="ml-auto text-muted-foreground text-xs">
{unit === 'metric' ? 'Metric' : 'Imperial'}
{unit === 'metric' ? '公制' : '英制'}
</span>
</DropdownMenuItem>
@ -381,7 +397,7 @@ function DisplayMenu() {
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<activeShading.icon className="h-4 w-4" />
<span>Render</span>
<span>渲染</span>
<span className="ml-auto text-muted-foreground text-xs">{activeShading.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
@ -406,7 +422,7 @@ function DisplayMenu() {
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PenLine className="h-4 w-4" />
<span>Edges</span>
<span>边线</span>
<span className="ml-auto text-muted-foreground text-xs">{activeEdges.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
@ -425,9 +441,9 @@ function DisplayMenu() {
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<SwatchBook className="h-4 w-4" />
<span>Theme</span>
<span>主题</span>
<span className="ml-auto truncate text-muted-foreground text-xs">
{activeTheme.name}
{getSceneThemeLabel(activeTheme.id, activeTheme.name)}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-48 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl">
@ -445,7 +461,9 @@ function DisplayMenu() {
<span key={`${theme.id}-${index}`} style={{ backgroundColor: color }} />
))}
</span>
<span className="text-foreground">{theme.name}</span>
<span className="text-foreground">
{getSceneThemeLabel(theme.id, theme.name)}
</span>
{sceneTheme === theme.id ? (
<Check className="ml-auto h-4 w-4 text-foreground" />
) : null}
@ -473,7 +491,7 @@ function WalkthroughButton() {
}, [isFirstPersonMode, setFirstPersonMode])
return (
<ToolbarTooltip label="Walkthrough">
<ToolbarTooltip label="漫游模式">
<button
className={cn(
TOOLBAR_BTN,
@ -490,14 +508,14 @@ function WalkthroughButton() {
function PreviewButton() {
return (
<ToolbarTooltip label="Preview mode">
<ToolbarTooltip label="预览模式">
<button
className="flex items-center gap-1.5 px-2.5 font-medium text-muted-foreground/80 text-xs transition-colors hover:bg-white/8 hover:text-foreground/90"
onClick={() => useEditor.getState().setPreviewMode(true)}
type="button"
>
<Eye className="h-3.5 w-3.5 shrink-0" />
<span>Preview</span>
<span>预览</span>
</button>
</ToolbarTooltip>
)

View File

@ -1,11 +1,20 @@
import type { LevelNode } from '../schema'
export function getDefaultLevelName(level: number): string {
if (level === 0) return 'Ground Floor'
if (level > 0) return `Floor ${level}`
return `Basement ${-level}`
if (level === 0) return '首层'
if (level > 0) return `${level + 1} 层`
return `地下 ${-level} 层`
}
export function getLevelDisplayName(level: Pick<LevelNode, 'name' | 'level'>): string {
return level.name || getDefaultLevelName(level.level)
const name = level.name?.trim()
if (!name || name === 'Ground Floor') return getDefaultLevelName(level.level)
const floorMatch = name.match(/^Floor\s+(\d+)$/i)
if (floorMatch) return `${Number(floorMatch[1]) + 1} 层`
const basementMatch = name.match(/^Basement\s+(\d+)$/i)
if (basementMatch) return `地下 ${Number(basementMatch[1])} 层`
return name
}

View File

@ -473,7 +473,7 @@ function FloorplanCompassButton({
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="Align view to north"
aria-label="将视图对齐到正北"
className="group absolute bottom-3 left-3 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-black/10 bg-white/85 shadow-sm backdrop-blur-md transition hover:bg-white hover:shadow-md dark:border-white/10 dark:bg-neutral-900/85 dark:hover:bg-neutral-900"
onClick={(event) => {
event.preventDefault()

View File

@ -358,40 +358,41 @@ type CameraControlHint = {
const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
{
action: 'Pan',
action: '平移',
keys: [{ value: 'Space' }, { value: 'Left click' }],
alternativeKeys: [{ value: 'Middle click' }],
},
{ action: 'Rotate', keys: [{ value: 'Right click' }] },
{ action: 'Zoom', keys: [{ value: 'Scroll' }] },
{ action: '旋转', keys: [{ value: 'Right click' }] },
{ action: '缩放', keys: [{ value: 'Scroll' }] },
]
const PREVIEW_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
{ action: 'Pan', keys: [{ value: 'Left click' }] },
{ action: 'Rotate', keys: [{ value: 'Right click' }] },
{ action: 'Zoom', keys: [{ value: 'Scroll' }] },
{ action: '平移', keys: [{ value: 'Left click' }] },
{ action: '旋转', keys: [{ value: 'Right click' }] },
{ action: '缩放', keys: [{ value: 'Scroll' }] },
]
const CAMERA_SHORTCUT_KEY_META: Record<string, { icon?: string; label: string; text?: string }> = {
'Left click': {
icon: 'ph:mouse-left-click-fill',
label: 'Left click',
label: '鼠标左键',
},
'Middle click': {
icon: 'qlementine-icons:mouse-middle-button-16',
label: 'Middle click',
label: '鼠标中键',
},
'Right click': {
icon: 'ph:mouse-right-click-fill',
label: 'Right click',
label: '鼠标右键',
},
Scroll: {
icon: 'qlementine-icons:mouse-middle-button-16',
label: 'Scroll wheel',
label: '鼠标滚轮',
},
Space: {
icon: 'lucide:space',
label: 'Space',
label: '空格键',
text: '空格',
},
}
@ -490,7 +491,7 @@ function ViewerCanvasControlsHint({
return (
<div className="pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2">
<section
aria-label="Camera controls hint"
aria-label="相机操作提示"
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-elevation-4 backdrop-blur-xl"
>
<div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18">
@ -501,7 +502,7 @@ function ViewerCanvasControlsHint({
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="Dismiss camera controls hint"
aria-label="关闭相机操作提示"
className="flex h-5 shrink-0 items-center justify-center self-center border-border/18 border-l pl-3 text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={onDismiss}
type="button"
@ -516,7 +517,7 @@ function ViewerCanvasControlsHint({
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
Dismiss
关闭
</TooltipContent>
</Tooltip>
</section>

View File

@ -39,10 +39,10 @@ export function NodeActionMenu({
>
{onFind && (
<button
aria-label="Find in catalog"
aria-label="在素材库中查找"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onFind}
title="Find in catalog"
title="在素材库中查找"
type="button"
>
<Search className="h-4 w-4" />
@ -50,10 +50,10 @@ export function NodeActionMenu({
)}
{onMove && (
<button
aria-label="Move"
aria-label="移动"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onMove}
title="Move"
title="移动"
type="button"
>
<Move className="h-4 w-4" />
@ -61,10 +61,10 @@ export function NodeActionMenu({
)}
{onCurve && (
<button
aria-label="Curve"
aria-label="曲线"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onCurve}
title="Curve"
title="曲线"
type="button"
>
<Spline className="h-4 w-4" />
@ -72,10 +72,10 @@ export function NodeActionMenu({
)}
{onDuplicate && (
<button
aria-label="Duplicate"
aria-label="复制"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onDuplicate}
title="Duplicate"
title="复制"
type="button"
>
<Copy className="h-4 w-4" />
@ -83,10 +83,10 @@ export function NodeActionMenu({
)}
{onAddHole && (
<button
aria-label="Cut Out"
aria-label="开孔"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onAddHole}
title="Cut Out"
title="开孔"
type="button"
>
<Icon height={16} icon="carbon:cut-out" width={16} />
@ -94,10 +94,10 @@ export function NodeActionMenu({
)}
{onDelete && (
<button
aria-label="Delete"
aria-label="删除"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
title="Delete"
title="删除"
type="button"
>
<Trash2 className="h-4 w-4" />

View File

@ -25,7 +25,7 @@ const controls: ControlConfig[] = [
{
id: 'select',
imageSrc: '/icons/select.webp',
label: 'Select',
label: '选择',
shortcut: 'V',
color: 'hover:bg-blue-500/20 hover:text-blue-400',
activeColor: 'bg-blue-500/20 text-blue-400',
@ -33,7 +33,7 @@ const controls: ControlConfig[] = [
{
id: 'zone',
imageSrc: '/icons/zone.webp',
label: 'Zone',
label: '区域',
shortcut: 'Z',
color: 'hover:bg-green-500/20 hover:text-green-400',
activeColor: 'bg-green-500/20 text-green-400',
@ -41,7 +41,7 @@ const controls: ControlConfig[] = [
{
id: 'delete',
icon: Trash2,
label: 'Delete',
label: '删除',
shortcut: 'X',
color: 'hover:bg-red-500/20 hover:text-red-400',
activeColor: 'bg-red-500/20 text-red-400',

View File

@ -8,9 +8,9 @@ export type FurnishToolConfig = {
}
export const furnishTools: FurnishToolConfig[] = [
{ id: 'item', iconSrc: '/icons/couch.webp', label: 'Furniture', catalogCategory: 'furniture' },
{ id: 'item', iconSrc: '/icons/appliance.webp', label: 'Appliance', catalogCategory: 'appliance' },
{ id: 'item', iconSrc: '/icons/kitchen.webp', label: 'Kitchen', catalogCategory: 'kitchen' },
{ id: 'item', iconSrc: '/icons/bathroom.webp', label: 'Bathroom', catalogCategory: 'bathroom' },
{ id: 'item', iconSrc: '/icons/tree.webp', label: 'Outdoor', catalogCategory: 'outdoor' },
{ id: 'item', iconSrc: '/icons/couch.webp', label: '家具', catalogCategory: 'furniture' },
{ id: 'item', iconSrc: '/icons/appliance.webp', label: '家电', catalogCategory: 'appliance' },
{ id: 'item', iconSrc: '/icons/kitchen.webp', label: '厨房', catalogCategory: 'kitchen' },
{ id: 'item', iconSrc: '/icons/bathroom.webp', label: '卫浴', catalogCategory: 'bathroom' },
{ id: 'item', iconSrc: '/icons/tree.webp', label: '户外', catalogCategory: 'outdoor' },
]

View File

@ -24,7 +24,7 @@ import { ActionButton } from './action-button'
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
const REFERENCES_EMPTY_TEXT =
'Upload GLB meshes as scan references or blueprint images as guide references.'
'可上传 GLB 网格作为扫描参考,或上传蓝图图片作为导引参考。'
// ── Helper: get guide images for the current level ──────────────────────────
@ -100,7 +100,7 @@ function UploadButton({ onError }: { onError: (message: string | null) => void }
onError(null)
if (file.size > MAX_FILE_SIZE) {
onError('File is too large. Maximum size is 200 MB.')
onError('文件过大,最大支持 200 MB。')
return
}
@ -108,7 +108,7 @@ function UploadButton({ onError }: { onError: (message: string | null) => void }
file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
const isImage = file.type.startsWith('image/')
if (!(isScan || isImage)) {
onError('Upload a .glb/.gltf scan or an image.')
onError('请上传 .glb/.gltf 扫描文件或图片。')
return
}
@ -120,7 +120,7 @@ function UploadButton({ onError }: { onError: (message: string | null) => void }
setSelectedReferenceId(guide.id)
setSelection({ selectedIds: [], zoneId: null })
} catch {
onError('Could not add that guide image.')
onError('无法添加该导引图片。')
} finally {
setIsAddingGuide(false)
}
@ -129,13 +129,13 @@ function UploadButton({ onError }: { onError: (message: string | null) => void }
const { uploadHandler } = useUploadStore.getState()
if (!uploadHandler) {
onError('Scan upload is unavailable.')
onError('扫描文件上传暂不可用。')
return
}
const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0]
if (!projectId) {
onError('Open a project before uploading a scan.')
onError('请先打开项目,再上传扫描文件。')
return
}
@ -148,7 +148,7 @@ function UploadButton({ onError }: { onError: (message: string | null) => void }
return (
<>
<button
aria-label="Upload scan or guide image"
aria-label="上传扫描或导引图片"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
disabled={isAddingGuide}
onClick={() => fileInputRef.current?.click()}
@ -571,13 +571,12 @@ function ReferenceListSection({
<p className="font-medium text-foreground text-sm">{title}</p>
{hasItems && (
<p className="text-muted-foreground text-xs">
{nodes.length} {noun}
{nodes.length !== 1 ? 's' : ''} on this level
本层共 {nodes.length} 个{noun}
</p>
)}
</div>
<button
aria-label={show ? `Hide ${title.toLowerCase()}` : `Show ${title.toLowerCase()}`}
aria-label={show ? `隐藏${title}` : `显示${title}`}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
onClick={() => setShow(!show)}
type="button"
@ -611,14 +610,14 @@ function ReferenceListSection({
src={iconSrc}
/>
<p className="truncate font-medium text-foreground text-sm">
{node.name || `${noun.charAt(0).toUpperCase()}${noun.slice(1)} ${index + 1}`}
{node.name || `${noun} ${index + 1}`}
</p>
{selectedReferenceId === node.id && (
<Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
)}
</button>
<button
aria-label={`Delete ${noun}`}
aria-label={`删除${noun}`}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
onClick={(event) => {
event.stopPropagation()
@ -633,7 +632,7 @@ function ReferenceListSection({
</button>
</div>
<SliderControl
label="Opacity"
label="不透明度"
max={100}
min={0}
onChange={(value) =>
@ -685,14 +684,14 @@ function ReferencesControl() {
? 'bg-white/15'
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
)}
label={`References: ${anyVisible ? 'Visible' : 'Hidden'}`}
label={`参考资源:${anyVisible ? '可见' : '隐藏'}`}
onClick={toggleAll}
size="icon"
variant="ghost"
>
<div className="relative">
<img
alt="References"
alt="参考资源"
className="h-[28px] w-[28px] object-contain"
src="/icons/floorplan.webp"
/>
@ -705,7 +704,7 @@ function ReferencesControl() {
<PopoverTrigger asChild>
<button
aria-expanded={isOpen}
aria-label="Reference settings"
aria-label="参考资源设置"
className={cn(
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
anyVisible
@ -739,22 +738,22 @@ function ReferencesControl() {
emptyText={REFERENCES_EMPTY_TEXT}
iconSrc="/icons/mesh.webp"
nodes={scans}
noun="scan"
noun="扫描"
onError={setUploadError}
setShow={setShowScans}
show={showScans}
title="Scans"
title="扫描"
/>
<div className="h-px bg-border/45" />
<ReferenceListSection
emptyText={REFERENCES_EMPTY_TEXT}
iconSrc="/icons/floorplan.webp"
nodes={guides}
noun="guide image"
noun="导引图片"
onError={setUploadError}
setShow={setShowGuides}
show={showGuides}
title="Guide images"
title="导引图片"
/>
</div>
</PopoverContent>
@ -790,8 +789,8 @@ function ReferenceFloorControl() {
disabled={!hasLowerLevels}
label={
selectedLevelName && showReferenceFloor
? `Reference floor: ${selectedLevelName}`
: 'Reference floor'
? `参考楼层:${selectedLevelName}`
: '参考楼层'
}
onClick={() => {
if (hasLowerLevels) toggleReferenceFloor()
@ -810,7 +809,7 @@ function ReferenceFloorControl() {
<PopoverTrigger asChild>
<button
aria-expanded={isOpen}
aria-label="Reference floor settings"
aria-label="参考楼层设置"
className={cn(
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
showReferenceFloor && selectedLevel
@ -841,13 +840,13 @@ function ReferenceFloorControl() {
<Layers2 className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground text-sm">Reference floor</p>
<p className="font-medium text-foreground text-sm">参考楼层</p>
{selectedLevelName && (
<p className="truncate text-muted-foreground text-xs">{selectedLevelName}</p>
)}
</div>
<button
aria-label={showReferenceFloor ? 'Hide reference floor' : 'Show reference floor'}
aria-label={showReferenceFloor ? '隐藏参考楼层' : '显示参考楼层'}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
disabled={!hasLowerLevels}
onClick={toggleReferenceFloor}
@ -891,14 +890,14 @@ function ReferenceFloorControl() {
)}
/>
<span className="min-w-0 flex-1 truncate">{levelName}</span>
<span className="text-[10px] text-muted-foreground">{index + 1} below</span>
<span className="text-[10px] text-muted-foreground">下方第 {index + 1} 层</span>
</button>
)
})}
</div>
<SliderControl
label="Opacity"
label="不透明度"
max={0.8}
min={0.1}
onChange={setReferenceFloorOpacity}
@ -909,7 +908,7 @@ function ReferenceFloorControl() {
</>
) : (
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
No lower floor available.
暂无可用的下方楼层。
</div>
)}
</div>

View File

@ -166,7 +166,7 @@ function LevelRow({
>
<button
{...dragHandleProps}
aria-label={`Reorder ${getLevelDisplayName(level)}`}
aria-label={`重新排序 ${getLevelDisplayName(level)}`}
className={cn(
'ml-0.5 flex h-6 w-4 shrink-0 cursor-grab touch-none items-center justify-center rounded-md text-muted-foreground/35 opacity-0 transition-colors hover:bg-white/5 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 group-hover/level:opacity-100',
isDragging && 'cursor-grabbing opacity-100',
@ -517,7 +517,7 @@ export function FloatingLevelSelector() {
<button
className={cn(addButtonClass, 'top-0 -translate-y-1/2')}
onClick={handleAddAbove}
title="Add level above"
title="在上方添加楼层"
type="button"
>
<Plus className="h-2.5 w-2.5" />
@ -529,7 +529,7 @@ export function FloatingLevelSelector() {
<button
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
onClick={handleAddBelow}
title="Add level below"
title="在下方添加楼层"
type="button"
>
<Plus className="h-2.5 w-2.5" />
@ -574,7 +574,7 @@ export function FloatingLevelSelector() {
<button
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
onClick={() => handleInsertBetween(sortedIndex - 1)}
title="Insert level here"
title="在此插入楼层"
type="button"
>
<Plus className="h-2.5 w-2.5" />
@ -593,11 +593,11 @@ export function FloatingLevelSelector() {
<Dialog onOpenChange={(open) => !open && setDeletingLevel(null)} open={!!deletingLevel}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Delete level</DialogTitle>
<DialogTitle>删除楼层</DialogTitle>
<DialogDescription>
Are you sure you want to delete{' '}
<strong>{deletingLevel ? getLevelDisplayName(deletingLevel) : ''}</strong>? All
walls, floors, and objects on this level will be permanently removed.
确定要删除{' '}
<strong>{deletingLevel ? getLevelDisplayName(deletingLevel) : ''}</strong>
?该楼层的墙体、地面和物件将被永久删除。
</DialogDescription>
</DialogHeader>
<DialogFooter>
@ -606,14 +606,14 @@ export function FloatingLevelSelector() {
onClick={() => setDeletingLevel(null)}
type="button"
>
Cancel
取消
</button>
<button
className="rounded-full bg-red-600 px-4 py-2 text-sm text-white transition-colors hover:bg-red-700"
onClick={handleConfirmDelete}
type="button"
>
Delete
删除
</button>
</DialogFooter>
</DialogContent>

View File

@ -174,7 +174,7 @@ export function ParametricInspector({
</Suspense>
)}
{(canMove || canDelete || (parametrics.actions && parametrics.actions.length > 0)) && (
<PanelSection title="Actions">
<PanelSection title="操作">
<ActionGroup className={isZone ? 'flex-col' : undefined}>
{canMove && (
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />

View File

@ -23,13 +23,13 @@ interface IconRailProps {
const sitePanel: { id: PanelId; iconSrc: string; label: string } = {
id: 'site',
iconSrc: '/icons/level.webp',
label: 'Site',
label: '场景',
}
const settingsPanel: { id: PanelId; iconSrc: string; label: string } = {
id: 'settings',
iconSrc: '/icons/settings.webp',
label: 'Settings',
label: '设置',
}
const panels: { id: PanelId; iconSrc: string; label: string }[] = [sitePanel, settingsPanel]

View File

@ -22,9 +22,9 @@ export type FunctionTreeNode = {
}
const SOURCE_CHIPS: Array<{ id: NonNullable<AssetInput['source']>; label: string }> = [
{ id: 'library', label: 'Library' },
{ id: 'community', label: 'Community' },
{ id: 'mine', label: 'Mine' },
{ id: 'library', label: '素材库' },
{ id: 'community', label: '社区' },
{ id: 'mine', label: '我的' },
]
/** Every slug at or below `node`, so a non-leaf selection matches descendants. */
@ -173,7 +173,7 @@ export function FunctionTreePanel({
setSearch(e.target.value)
onSearchChange?.(e.target.value)
}}
placeholder="Search..."
placeholder="搜索物品…"
type="text"
value={search}
/>
@ -212,7 +212,7 @@ export function FunctionTreePanel({
onClick={() => setActiveChildSlug(null)}
type="button"
>
All
全部
</button>
{activeRoot.children.map((child) => {
const isActive = activeChildSlug === child.slug

View File

@ -167,9 +167,9 @@ function LegacyItemsPanel({
// filter even before they own any items. Selecting "Mine" with no
// matching items falls through to the empty/no-results state.
const sourceChips: Array<{ id: AssetInput['source']; label: string }> = [
{ id: 'library', label: 'Library' },
{ id: 'community', label: 'Community' },
{ id: 'mine', label: 'Mine' },
{ id: 'library', label: '素材库' },
{ id: 'community', label: '社区' },
{ id: 'mine', label: '我的' },
]
const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))
const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))
@ -242,7 +242,7 @@ function LegacyItemsPanel({
setSearch(e.target.value)
onSearchChange?.(e.target.value)
}}
placeholder="Search..."
placeholder="搜索物品…"
type="text"
value={search}
/>

View File

@ -28,19 +28,19 @@ export function AudioSettingsDialog() {
<DialogTrigger asChild>
<Button className="w-full justify-start gap-2" variant="outline">
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
Audio Settings
音频设置
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Audio Settings</DialogTitle>
<DialogDescription>Adjust volume levels and mute settings</DialogDescription>
<DialogTitle>音频设置</DialogTitle>
<DialogDescription>调整音量和静音设置</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Master Volume */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-sm">Master Volume</label>
<label className="font-medium text-sm">主音量</label>
<span className="text-muted-foreground text-sm">{masterVolume}%</span>
</div>
<Slider
@ -55,7 +55,7 @@ export function AudioSettingsDialog() {
{/* Radio Volume */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-sm">Radio Volume</label>
<label className="font-medium text-sm">背景音乐</label>
<span className="text-muted-foreground text-sm">{radioVolume}%</span>
</div>
<Slider
@ -70,7 +70,7 @@ export function AudioSettingsDialog() {
{/* SFX Volume */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-sm">Sound Effects</label>
<label className="font-medium text-sm">操作音效</label>
<span className="text-muted-foreground text-sm">{sfxVolume}%</span>
</div>
<Slider
@ -90,7 +90,7 @@ export function AudioSettingsDialog() {
variant={muted ? 'default' : 'outline'}
>
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
{muted ? 'Unmute All Sounds' : 'Mute All Sounds'}
{muted ? '恢复全部声音' : '静音全部声音'}
</Button>
</div>
</div>

View File

@ -298,12 +298,12 @@ export function SettingsPanel({
{/* Visibility Section (only for cloud projects) */}
{projectId && !isLocalProject && (
<div className="space-y-3">
<label className="font-medium text-muted-foreground text-xs uppercase">Visibility</label>
<label className="font-medium text-muted-foreground text-xs uppercase">可见性</label>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">Public</div>
<div className="font-medium text-sm">公开访问</div>
<div className="text-muted-foreground text-xs">
{projectVisibility?.isPrivate ? 'Only you' : 'Anyone'} can view
{projectVisibility?.isPrivate ? '仅自己可见' : '所有人可见'}
</div>
</div>
<Switch
@ -313,8 +313,8 @@ export function SettingsPanel({
</div>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">Show 3D Scans</div>
<div className="text-muted-foreground text-xs">Visible to public viewers</div>
<div className="font-medium text-sm">显示 3D 扫描</div>
<div className="text-muted-foreground text-xs">对公开访问者可见</div>
</div>
<Switch
checked={projectVisibility?.showScansPublic ?? true}
@ -323,8 +323,8 @@ export function SettingsPanel({
</div>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">Show Floorplans</div>
<div className="text-muted-foreground text-xs">Visible to public viewers</div>
<div className="font-medium text-sm">显示平面图</div>
<div className="text-muted-foreground text-xs">对公开访问者可见</div>
</div>
<Switch
checked={projectVisibility?.showGuidesPublic ?? true}
@ -333,8 +333,8 @@ export function SettingsPanel({
</div>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">Shadows</div>
<div className="text-muted-foreground text-xs">Cast shadows from lights</div>
<div className="font-medium text-sm">阴影</div>
<div className="text-muted-foreground text-xs">显示灯光投射阴影</div>
</div>
<Switch
checked={shadows}
@ -346,17 +346,17 @@ export function SettingsPanel({
{/* Export Section */}
<div className="space-y-4">
<label className="font-medium text-muted-foreground text-xs uppercase">Export</label>
<label className="font-medium text-muted-foreground text-xs uppercase">导出</label>
<div className="space-y-2">
<div className="font-medium text-muted-foreground text-xs">3D model</div>
<div className="font-medium text-muted-foreground text-xs">3D 模型</div>
<Button
className="w-full justify-start gap-2"
onClick={() => exportScene?.('glb')}
variant="outline"
>
<Download className="size-4" />
Export GLB
导出 GLB
</Button>
<Button
className="w-full justify-start gap-2"
@ -364,7 +364,7 @@ export function SettingsPanel({
variant="outline"
>
<Download className="size-4" />
Export STL
导出 STL
</Button>
<Button
className="w-full justify-start gap-2"
@ -372,19 +372,19 @@ export function SettingsPanel({
variant="outline"
>
<Download className="size-4" />
Export OBJ
导出 OBJ
</Button>
</div>
<div className="space-y-2">
<div className="font-medium text-muted-foreground text-xs">Floorplan</div>
<div className="font-medium text-muted-foreground text-xs">平面图</div>
<Button
className="w-full justify-start gap-2"
onClick={() => exportFloorplanPdf('full')}
variant="outline"
>
<MapIcon className="size-4" />
Full floorplan
完整平面图
</Button>
<Button
className="w-full justify-start gap-2"
@ -392,7 +392,7 @@ export function SettingsPanel({
variant="outline"
>
<MapIcon className="size-4" />
Structure only
仅结构
</Button>
</div>
</div>
@ -400,7 +400,7 @@ export function SettingsPanel({
{/* Thumbnail Section (only for cloud projects) */}
{projectId && !isLocalProject && (
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase">Thumbnail</label>
<label className="font-medium text-muted-foreground text-xs uppercase">缩略图</label>
<Button
className="w-full justify-start gap-2"
disabled={isGeneratingThumbnail}
@ -408,18 +408,18 @@ export function SettingsPanel({
variant="outline"
>
<Camera className="size-4" />
{isGeneratingThumbnail ? 'Generating...' : 'Generate Thumbnail'}
{isGeneratingThumbnail ? '正在生成…' : '生成缩略图'}
</Button>
</div>
)}
{/* Save/Load Section */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase">Save & Load</label>
<label className="font-medium text-muted-foreground text-xs uppercase">保存与加载</label>
<Button className="w-full justify-start gap-2" onClick={handleSaveBuild} variant="outline">
<Save className="size-4" />
Save Build
保存建筑
</Button>
<Button
@ -428,7 +428,7 @@ export function SettingsPanel({
variant="outline"
>
<Upload className="size-4" />
Load Build
加载建筑
</Button>
<input
@ -448,27 +448,27 @@ export function SettingsPanel({
{/* Audio Section */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase">Audio</label>
<label className="font-medium text-muted-foreground text-xs uppercase">音频</label>
<AudioSettingsDialog />
</div>
{/* Keyboard Section */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase">Keyboard</label>
<label className="font-medium text-muted-foreground text-xs uppercase">键盘</label>
<KeyboardShortcutsDialog />
</div>
{/* Scene Graph */}
<div className="space-y-1">
<label className="font-medium text-muted-foreground text-xs uppercase">Scene Graph</label>
<label className="font-medium text-muted-foreground text-xs uppercase">场景树</label>
<Dialog>
<DialogTrigger asChild>
<Button className="h-auto justify-start p-0 text-sm" variant="link">
Explore scene graph
查看场景树
</Button>
</DialogTrigger>
<DialogContent className="h-[80vh] max-w-[95vw] gap-0 overflow-hidden border-0 bg-[#1e1e1e] p-0 shadow-none sm:max-w-5xl">
<DialogTitle className="sr-only">Scene Graph</DialogTitle>
<DialogTitle className="sr-only">场景树</DialogTitle>
<div
className="flex h-full min-h-0 w-full min-w-0 *:h-full *:w-full *:overflow-y-auto"
onContextMenuCapture={blockSceneGraphMutations}
@ -486,7 +486,7 @@ export function SettingsPanel({
{/* Danger Zone */}
<div className="space-y-2">
<label className="font-medium text-destructive text-xs uppercase">Danger Zone</label>
<label className="font-medium text-destructive text-xs uppercase">危险操作</label>
<Button
className="w-full justify-start gap-2"
@ -494,7 +494,7 @@ export function SettingsPanel({
variant="destructive"
>
<Trash2 className="size-4" />
Clear & Start New
清空并重新开始
</Button>
</div>
</div>

View File

@ -195,15 +195,15 @@ export function KeyboardShortcutsDialog() {
<DialogTrigger asChild>
<Button className="w-full justify-start gap-2" variant="outline">
<Keyboard className="size-4" />
Keyboard Shortcuts
键盘快捷键
</Button>
</DialogTrigger>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden p-0 sm:max-w-3xl">
<DialogHeader className="shrink-0 border-b px-6 py-4">
<DialogTitle>Keyboard Shortcuts</DialogTitle>
<DialogTitle>键盘快捷键</DialogTitle>
<DialogDescription>
Shortcuts are context-aware. Guided constraints are enabled by default; hold Shift
during an active gesture to build freely.
快捷键会根据当前操作场景生效。默认启用辅助约束,操作时按住 Shift
可自由搭建。
</DialogDescription>
</DialogHeader>

View File

@ -165,7 +165,7 @@ const PropertyLineSection = memo(function PropertyLineSection() {
<div className="flex items-center gap-2">
<Pentagon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm">Property Line</span>
<span className="font-medium text-sm">地块边界</span>
</div>
<button
className={cn(
@ -278,7 +278,7 @@ const CameraPopover = memo(function CameraPopover({
buttonClassName,
)}
onClick={(e) => e.stopPropagation()}
title="Camera snapshot"
title="相机快照"
>
<Camera className="h-3.5 w-3.5" />
{hasCamera && (
@ -567,7 +567,7 @@ const LevelReferences = memo(function LevelReferences({
) : (
<Plus className="h-3.5 w-3.5" />
)}
{uploading ? `Uploading ${uploadingType}... ${progress}%` : 'Upload scan/floorplan'}
{uploading ? `正在上传 ${uploadingType}… ${progress}%` : '上传扫描/平面图'}
</button>
<input
@ -739,7 +739,7 @@ const LevelItem = memo(function LevelItem({
<div className="flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 py-0 pl-0.5 text-sm">
<img
alt="Level"
alt="楼层"
className={cn(
'h-4 w-4 shrink-0 object-contain transition-all duration-200',
!isSelected && 'opacity-60 grayscale',
@ -765,7 +765,7 @@ const LevelItem = memo(function LevelItem({
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
onClick={(e) => e.stopPropagation()}
title="Camera snapshot"
title="相机快照"
>
<Camera className="h-3.5 w-3.5" />
{level.camera && (
@ -949,7 +949,7 @@ const LevelsSection = memo(function LevelsSection({
<div className="relative z-10 flex items-center pr-1 pl-[38px]">
<Plus className="h-3.5 w-3.5" />
</div>
<span className="truncate">Add level</span>
<span className="truncate">添加楼层</span>
</button>
{levels.length === 0 && (
<div className="relative flex h-8 select-none items-center border-border/50 border-b py-0 pr-2 pl-[38px] text-muted-foreground text-xs">
@ -957,7 +957,7 @@ const LevelsSection = memo(function LevelsSection({
<div className="pointer-events-none absolute top-0 bottom-1/2 left-[21px] w-px bg-border/50" />
{/* Horizontal branch line */}
<div className="pointer-events-none absolute top-1/2 left-[21px] h-px w-[11px] bg-border/50" />
No levels yet
暂无楼层
</div>
)}
{[...levels].reverse().map((level, index) => (
@ -1017,14 +1017,14 @@ const LayerToggle = memo(function LayerToggle() {
)}
<div className="relative z-10 flex flex-col items-center">
<img
alt="Structure"
alt="结构"
className={cn(
'mb-1 h-6 w-6 transition-all',
activeTab !== 'structure' && 'opacity-50 grayscale',
)}
src="/icons/room.webp"
/>
Structure
结构
</div>
<div className="absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
<span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none">
@ -1053,14 +1053,14 @@ const LayerToggle = memo(function LayerToggle() {
)}
<div className="relative z-10 flex flex-col items-center">
<img
alt="Furnish"
alt="陈设"
className={cn(
'mb-1 h-6 w-6 transition-all',
activeTab !== 'furnish' && 'opacity-50 grayscale',
)}
src="/icons/couch.webp"
/>
Furnish
陈设
</div>
<div className="absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
<span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none">
@ -1090,14 +1090,14 @@ const LayerToggle = memo(function LayerToggle() {
)}
<div className="relative z-10 flex flex-col items-center">
<img
alt="Zones"
alt="区域"
className={cn(
'mb-1 h-6 w-6 transition-all',
activeTab !== 'zones' && 'opacity-50 grayscale',
)}
src="/icons/kitchen.webp"
/>
Zones
区域
</div>
<div className="absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
<span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none">
@ -1206,7 +1206,7 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa
<button
className="relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/row:opacity-100 dark:hover:bg-white/10"
onClick={(e) => e.stopPropagation()}
title="Camera snapshot"
title="相机快照"
>
<Camera className="h-3 w-3" />
{zone.camera && (
@ -1281,11 +1281,11 @@ const MultiSelectionBadge = memo(function MultiSelectionBadge() {
return (
<div className="pointer-events-none sticky top-4 z-50 flex h-0 w-full justify-center overflow-visible">
<div className="pointer-events-auto flex items-center gap-2.5 rounded-full border border-primary/20 bg-primary px-0.5 py-4 pl-2 font-medium text-primary-foreground text-xs shadow-black/10 shadow-lg backdrop-blur-md">
<span>{selectedIds.length} objects selected</span>
<span>已选择 {selectedIds.length} 个对象</span>
<button
className="cursor-pointer rounded-full p-1.5 transition-colors hover:bg-primary-foreground/20"
onClick={() => setSelection({ selectedIds: [] })}
title="Clear selection"
title="清除选择"
>
<X className="h-4 w-4" />
</button>
@ -1324,7 +1324,7 @@ const ContentSection = memo(function ContentSection() {
if (!level) {
return (
<div className="px-3 py-4 text-muted-foreground text-sm">Select a level to view content</div>
<div className="px-3 py-4 text-muted-foreground text-sm">请选择楼层以查看内容</div>
)
}
@ -1338,9 +1338,9 @@ const ContentSection = memo(function ContentSection() {
if (levelZones.length === 0) {
return (
<div className="px-3 py-4 text-muted-foreground text-sm">
No zones on this level.{' '}
该楼层暂无区域。{' '}
<button className="cursor-pointer text-primary hover:underline" onClick={handleAddZone}>
Add one
添加区域
</button>
</div>
)
@ -1356,7 +1356,7 @@ const ContentSection = memo(function ContentSection() {
}
if (elementChildren.length === 0) {
return <div className="px-3 py-4 text-muted-foreground text-sm">No elements on this level</div>
return <div className="px-3 py-4 text-muted-foreground text-sm">该楼层暂无元素</div>
}
return (
<TreeNodeDragProvider>
@ -1431,14 +1431,14 @@ const BuildingItem = memo(function BuildingItem({
>
<div className="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-2 py-2 pl-3">
<img
alt="Building"
alt="建筑"
className={cn(
'h-5 w-5 object-contain transition-all',
!isBuildingActive && 'opacity-60 grayscale',
)}
src="/icons/building.webp"
/>
<span className="truncate font-medium text-sm">{building.name || 'Building'}</span>
<span className="truncate font-medium text-sm">{building.name || '建筑'}</span>
</div>
<Popover
onOpenChange={(open) => setBuildingCameraOpen(open ? building.id : null)}
@ -1453,7 +1453,7 @@ const BuildingItem = memo(function BuildingItem({
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
onClick={(e) => e.stopPropagation()}
title="Camera snapshot"
title="相机快照"
>
<Camera className="h-4 w-4" />
{building.camera && (
@ -1587,14 +1587,14 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
>
<div className="flex items-center gap-2">
<img
alt="Site"
alt="场地"
className={cn(
'h-5 w-5 object-contain transition-all',
phase !== 'site' && 'opacity-60 grayscale',
)}
src="/icons/site-flag.webp"
/>
<span className="font-medium text-sm">{siteNode.name || 'Site'}</span>
<span className="font-medium text-sm">{siteNode.name || '场地'}</span>
</div>
<CameraPopover
buttonClassName={cn(

View File

@ -67,7 +67,7 @@ export const TreeNodeActions = memo(function TreeNodeActions({ nodeId }: TreeNod
<button
className="relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10"
onClick={(e) => e.stopPropagation()}
title="Camera snapshot"
title="相机快照"
>
<Camera className="h-3 w-3" />
{hasCamera && (

View File

@ -25,7 +25,7 @@ export const treesPlugin: Plugin = {
panels: [
{
id: 'trees',
label: 'Nature',
label: '自然资产',
icon: { kind: 'url', src: NATURE_ICON },
component: () => import('./presets-panel'),
},

View File

@ -41,7 +41,7 @@ export default function TreesPanel() {
<div className="flex flex-col gap-4 p-4 text-sidebar-foreground">
<header className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-base">Nature</h2>
<h2 className="font-semibold text-base">自然资产</h2>
<span className="rounded-full bg-sidebar-accent px-2 py-0.5 text-sidebar-foreground/70 text-xs">
{count} planted
</span>