feat: simplify HiCAD project management

This commit is contained in:
134 2026-07-27 22:57:40 +08:00 committed by root
parent 2c81486862
commit 43fd3d258a
4 changed files with 465 additions and 2 deletions

View File

@ -110,7 +110,11 @@ function replaceSource(relativePath, before, after) {
if (!after) {
return;
}
throw new Error(`Official source changed; patch failed for ${relativePath}`);
throw new Error(
`Official source changed; patch failed for ${relativePath}; expected ${JSON.stringify(
before.slice(0, 160)
)}`
);
}
writeFileSync(filePath, source.replace(before, after));
}
@ -162,6 +166,10 @@ function patchOfficialSource() {
path.join(customizationDir, "FitAllButton.tsx.template"),
path.join(sourceDir, "src/components/FitAllButton.tsx")
);
copyFileSync(
path.join(customizationDir, "HicadProjectExperience.tsx.template"),
path.join(sourceDir, "src/components/HicadProjectExperience.tsx")
);
replaceSource(
"index.html",
' <meta name="description" content="An open-source CAD modeling tool from the future by Zoo." />',
@ -307,6 +315,284 @@ function patchOfficialSource() {
" {displayedName !== user.email && (",
" {!isHiCadEmbedded && displayedName !== user.email && ("
);
replaceSource(
"src/components/AppHeader.tsx",
"import ProjectSidebarMenu from '@src/components/ProjectSidebarMenu'\n",
"import { HicadAutosaveStatus } from '@src/components/HicadProjectExperience'\nimport ProjectSidebarMenu from '@src/components/ProjectSidebarMenu'\n"
);
replaceSource(
"src/components/AppHeader.tsx",
` <div className="flex items-center gap-2 py-1.5 ml-auto">
{children ||`,
` <div className="flex items-center gap-2 py-1.5 ml-auto">
{project && <HicadAutosaveStatus />}
{children ||`
);
replaceSource(
"src/routes/Home.tsx",
"import AppProjectCard from '@src/components/AppProjectCard/AppProjectCard'\n",
"import AppProjectCard from '@src/components/AppProjectCard/AppProjectCard'\nimport { HicadProjectDashboardHeading } from '@src/components/HicadProjectExperience'\n"
);
replaceSource(
"src/routes/Home.tsx",
` projectSearchKeybinding={projectSearchKeybinding}
className="col-start-2 -col-end-1"`,
` projectSearchKeybinding={projectSearchKeybinding}
projectCount={scopedHomeProjectEntries.length}
className="col-start-2 -col-end-1"`
);
replaceSource(
"src/routes/Home.tsx",
` projectSearchKeybinding?: string
}`,
` projectSearchKeybinding?: string
projectCount: number
}`
);
replaceSource(
"src/routes/Home.tsx",
` projectSearchKeybinding,
...rest`,
` projectSearchKeybinding,
projectCount,
...rest`
);
replaceSource(
"src/routes/Home.tsx",
` <div className="flex gap-8 items-center">
<div className="flex flex-col gap-1">
{library && showLibraryBackLink && (
<Link
to={PATHS.HOME}
className="text-sm text-chalkboard-70 underline underline-offset-2 dark:text-chalkboard-30"
>
All libraries
</Link>
)}
<h1 className="text-3xl font-bold">{title}</h1>
</div>
</div>`,
` <div className="flex min-w-0 flex-1 gap-8 items-center">
{library && showLibraryBackLink && (
<Link
to={PATHS.HOME}
className="text-sm text-chalkboard-70 underline underline-offset-2 dark:text-chalkboard-30"
>
返回全部项目
</Link>
)}
<HicadProjectDashboardHeading projectCount={projectCount} />
</div>`
);
replaceSource(
"src/routes/Home.tsx",
`<div className="flex flex-col md:flex-row gap-4 justify-between md:items-center select-none">`,
`<div className="flex flex-col gap-5 justify-between select-none">`
);
replaceSource(
"src/routes/Home.tsx",
`<div className="flex flex-col sm:flex-row gap-2 sm:items-center">`,
`<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
"import { ContextMenu, ContextMenuItem } from '@src/components/ContextMenu'\n",
"import { ContextMenu, ContextMenuItem } from '@src/components/ContextMenu'\nimport { CustomIcon } from '@src/components/CustomIcon'\n"
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
` {
local: 'Local',
'cloud-only': 'Cloud-only',
syncing: 'Syncing',
synced: 'Synced',
conflicted: 'Conflicted',
}`,
` {
local: '已保存',
'cloud-only': '云端项目',
syncing: '保存中',
synced: '已保存',
conflicted: '需要处理',
}`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
`function getDisplayedTime(dateTimeMs: number) {
const date = new Date(dateTimeMs)
const startOfToday = new Date()
startOfToday.setHours(0, 0, 0, 0)
return date.getTime() < startOfToday.getTime()
? date.toLocaleDateString()
: date.toLocaleTimeString()
}`,
`function getDisplayedTime(dateTimeMs: number) {
const date = new Date(dateTimeMs)
const now = new Date()
const isToday =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
if (isToday) {
return \`今天 \${date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})}\`
}
return date.toLocaleDateString('zh-CN', {
year: date.getFullYear() === now.getFullYear() ? undefined : 'numeric',
month: 'short',
day: 'numeric',
})
}`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
` {project.kclFileCount !== undefined && (
<span className="px-2 text-chalkboard-60 text-xs">
<span data-testid="project-file-count">{project.kclFileCount}</span>{' '}
file{project.kclFileCount === 1 ? '' : 's'}{' '}
{(project.directoryCount ?? 0) > 0 && (
<>
{'/ '}
<span data-testid="project-folder-count">
{project.directoryCount}
</span>{' '}
folder{project.directoryCount === 1 ? '' : 's'}
</>
)}
</span>
)}
<span className="px-2 text-chalkboard-60 text-xs">
Edited{' '}
<span data-testid="project-edit-date">
{displayedProject.modified
? getDisplayedTime(displayedProject.modified)
: 'never'}
</span>
</span>`,
` <div className="flex items-center justify-between gap-2 px-2 text-xs text-chalkboard-60">
<span>
{project.kclFileCount !== undefined ? (
<>
<span data-testid="project-file-count">{project.kclFileCount}</span>{' '}
个模型文件
</>
) : (
'CAD 项目'
)}
</span>
<span className="truncate">
最近编辑{' '}
<span data-testid="project-edit-date">
{displayedProject.modified
? getDisplayedTime(displayedProject.modified)
: '暂无记录'}
</span>
</span>
</div>`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
` title="Delete Project"`,
` title="删除项目"`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
` <p className="my-4 text-wrap break-words">
This will permanently delete "{projectName || 'this file'}
".
</p>
<p className="my-4 text-wrap break-words">
Are you sure you want to delete "{projectName || 'this file'}
"? This action cannot be undone.
</p>`,
` <p className="my-4 text-wrap break-words">
确定删除“{projectName || '此项目'}”吗?
</p>
<p className="my-4 text-wrap break-words text-sm text-chalkboard-70 dark:text-chalkboard-30">
项目及其全部模型文件将被永久删除,此操作无法撤销。
</p>`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
` title={projectName}
titleText={projectName}`,
` title={
<span className="flex min-w-0 items-center justify-between gap-2">
<span className="truncate font-semibold">{projectName}</span>
{canOpen && (
<span className="flex-none text-xs font-medium text-primary opacity-0 transition-opacity group-hover:opacity-100">
继续设计
</span>
)}
</span>
}
titleText={projectName}`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
` thumbnailUrl={imageUrl}
badges={badges}`,
` thumbnailUrl={imageUrl}
thumbnailAlt={projectName ? \`\${projectName} 的模型预览\` : 'CAD 项目预览'}
thumbnailFallback={
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-chalkboard-50 dark:text-chalkboard-50">
<CustomIcon name="model" className="h-8 w-8" />
<span className="text-xs">暂无模型预览</span>
</div>
}
badges={badges}`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
` Rename project`,
` 重命名`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
` Delete project`,
` 删除项目`
);
replaceSource(
"src/lib/commandBarConfigs/projectsCommandConfig.ts",
` displayName: \`Create project\`,
description: 'Create a project',`,
` displayName: \`新建 CAD 项目\`,
description: '输入项目名称即可开始,设计内容会自动保存。',`
);
replaceSource(
"src/lib/commandBarConfigs/projectsCommandConfig.ts",
` displayName: 'Title',
required: true,`,
` displayName: '项目名称',
description: '使用容易识别的名称,例如“五金连接安装板”。',
required: true,`
);
replaceSource(
"src/lib/commandBarConfigs/projectsCommandConfig.ts",
` displayName: 'Library',
required: () => createProjectLibraryOptions().length > 1,`,
` displayName: '保存位置',
required: () => createProjectLibraryOptions().length > 1,`
);
replaceSource(
"src/lib/commandBarConfigs/projectsCommandConfig.ts",
` defaultValue: defaultProjectFolderNameSnapshot,`,
` defaultValue: () => '',`
);
replaceSource(
"src/components/CommandBar/CommandBarBasicInput.tsx",
` placeholder="Enter a value"`,
` placeholder={
commandBarState.context.selectedCommand?.name === 'Create project'
? '例如:五金连接安装板'
: '请输入'
}`
);
replaceSource(
"src/components/environment/Environment.tsx",
" new URL(env().VITE_KITTYCAD_WEBSOCKET_URL ?? ''),",

View File

@ -0,0 +1,53 @@
import fs from 'node:fs'
import path from 'node:path'
import { expect, test } from '@playwright/test'
const projectRoot = process.cwd()
test.describe('HiCAD project management experience', () => {
test('vendors the project dashboard and autosave status', () => {
const script = fs.readFileSync(
path.join(projectRoot, 'scripts/vendor-zoo-modeling-app.mjs'),
'utf8'
)
const experience = fs.readFileSync(
path.join(
projectRoot,
'vendor/zoo-modeling-app/HicadProjectExperience.tsx.template'
),
'utf8'
)
expect(script).toContain('HicadProjectExperience.tsx.template')
expect(script).toContain('projectCount={scopedHomeProjectEntries.length}')
expect(script).toContain('thumbnailFallback')
expect(script).toContain('最近编辑')
expect(experience).toContain('我的 CAD 项目')
expect(experience).toContain('编辑内容会自动保存')
expect(experience).toContain('自动保存已开启')
expect(experience).toContain('data-testid="hicad-create-project"')
expect(experience).toContain('data-testid="hicad-create-from-sample"')
})
test('keeps new project creation simple and understandable', () => {
const script = fs.readFileSync(
path.join(projectRoot, 'scripts/vendor-zoo-modeling-app.mjs'),
'utf8'
)
const localization = fs.readFileSync(
path.join(projectRoot, 'vendor/zoo-modeling-app/localization.ts'),
'utf8'
)
expect(script).toContain('新建 CAD 项目')
expect(script).toContain("displayName: '项目名称'")
expect(script).toContain('例如:五金连接安装板')
expect(script).toContain("defaultValue: () => ''")
expect(localization).toContain("['Submit', '确认']")
expect(localization).toContain("['Continue', '下一步']")
expect(localization).toContain("['Step back', '返回上一步']")
expect(localization).toContain('还没有项目,点击“新建项目”开始设计。')
expect(localization).toContain("[data-testid='home-sidebar']")
})
})

View File

@ -0,0 +1,68 @@
import { CustomIcon } from '@src/components/CustomIcon'
export function HicadProjectDashboardHeading({
projectCount,
}: {
projectCount: number
}) {
const startCreatingProject = () => {
document
.querySelector<HTMLButtonElement>('[data-testid="home-new-file"]')
?.click()
}
const startFromSample = () => {
document
.querySelector<HTMLButtonElement>(
'[data-testid="home-create-from-sample"]'
)
?.click()
}
return (
<div
className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
data-testid="hicad-project-dashboard-heading"
>
<div className="min-w-0">
<p className="mb-1 text-xs font-medium text-primary">HiCAD 项目空间</p>
<h1 className="text-3xl font-bold">我的 CAD 项目</h1>
<p className="mt-1 text-sm text-chalkboard-70 dark:text-chalkboard-30">
共 {projectCount} 个项目,编辑内容会自动保存
</p>
</div>
<div className="flex flex-none items-center gap-2">
<button
type="button"
onClick={startFromSample}
className="inline-flex h-10 items-center justify-center gap-2 rounded-sm border border-chalkboard-30 bg-chalkboard-10 px-3 text-sm font-medium text-chalkboard-90 transition-colors hover:border-primary/50 hover:bg-primary/5 dark:border-chalkboard-70 dark:bg-chalkboard-90 dark:text-chalkboard-20"
data-testid="hicad-create-from-sample"
>
<CustomIcon name="file" className="h-4 w-4" />
从示例开始
</button>
<button
type="button"
onClick={startCreatingProject}
className="inline-flex h-10 items-center justify-center gap-2 rounded-sm border border-primary bg-primary px-4 text-sm font-semibold text-white transition-colors hover:brightness-110 focus-visible:outline-appForeground"
data-testid="hicad-create-project"
>
<CustomIcon name="plus" className="h-5 w-5" />
新建项目
</button>
</div>
</div>
)
}
export function HicadAutosaveStatus() {
return (
<span
className="mr-1 inline-flex h-7 items-center gap-1.5 rounded-md border border-chalkboard-30 bg-chalkboard-10/80 px-2 text-xs text-chalkboard-70 dark:border-chalkboard-70 dark:bg-chalkboard-100/50 dark:text-chalkboard-30"
data-testid="hicad-autosave-status"
title="项目内容会自动保存到当前浏览器"
>
<span className="h-1.5 w-1.5 rounded-full bg-succeed-60" />
自动保存已开启
</span>
)
}

View File

@ -23,6 +23,8 @@ const translations = new Map<string, string>([
['Connected', '已连接'],
['Connecting and setting up scene...', '正在连接并初始化场景...'],
['Create a new project', '新建项目'],
['Create project', '新建项目'],
['Create from a sample', '从示例开始'],
[
'Create a gear with 10 teeth and use sensible defaults for everything else...',
'创建一个 10 齿齿轮,其余参数使用合理默认值...',
@ -31,6 +33,7 @@ const translations = new Map<string, string>([
['Design using traditional point-and-click', '使用传统点击方式设计'],
['Dismiss', '关闭'],
['Edit', '编辑'],
['Edited', '最近编辑'],
['Export', '导出'],
['Export part', '导出零件'],
['Extrude', '拉伸'],
@ -67,9 +70,28 @@ const translations = new Map<string, string>([
['No selection.', '未选择。'],
['Offset Plane', '偏移基准面'],
['Open project', '打开项目'],
['Project Libraries', '我的 CAD 项目'],
['Projects', '项目'],
['Project Files', '项目文件'],
['Publish', '发布'],
['Redo', '重做'],
['Rename project', '重命名'],
['Delete project', '删除项目'],
['Search projects', '搜索项目'],
['Sort by', '排序'],
['Name', '名称'],
['Last Modified', '最近编辑'],
['No projects found', '还没有项目'],
[', ready to make your first one?', ',点击“新建项目”开始设计。'],
[
'No projects found, ready to make your first one?',
'还没有项目,点击“新建项目”开始设计。',
],
['Submit', '确认'],
['Continue', '下一步'],
['Step back', '返回上一步'],
['Enter a value', '请输入'],
['Cancel', '取消'],
['Revolve', '旋转'],
['Settings', '设置'],
['Shell', '抽壳'],
@ -158,7 +180,11 @@ function translateElement(element: Element) {
for (const attribute of translatableAttributes) {
const value = normalizeLookupKey(element.getAttribute(attribute) || '')
if (!value) continue
const translated = translations.get(value)
const translated =
translations.get(value) ||
(attribute === 'placeholder' && value.startsWith('Search projects')
? '搜索项目'
: undefined)
if (translated) element.setAttribute(attribute, translated)
}
}
@ -203,6 +229,36 @@ export function installHiCadLocalization() {
html[data-hicad-embedded='true'] [data-hicad-official-account-link] {
display: none !important;
}
html[data-hicad-embedded='true'] [data-testid='home-sidebar'],
html[data-hicad-embedded='true'] [data-testid='home-new-file'],
html[data-hicad-embedded='true'] [data-testid='environment-subdomain-bar'],
html[data-hicad-embedded='true'] [data-testid='cloud-sync-status'] {
display: none !important;
}
html[data-hicad-embedded='true'] .home-layout {
display: block !important;
column-gap: 2rem;
margin-top: clamp(1.5rem, 5vh, 4rem);
}
html[data-hicad-embedded='true'] [data-testid='home-header'] > p:has(
[data-testid='project-directory-settings-link']
) {
display: none !important;
}
html[data-hicad-embedded='true'] [data-testid='home-section'] {
margin-top: 1.5rem;
overflow-y: auto;
padding-bottom: 3rem;
}
html[data-hicad-embedded='true'] [data-testid='home-section'] > ul {
align-content: start;
}
html[data-hicad-embedded='true'] [data-testid='project-status-badge'] {
box-shadow: 0 1px 2px rgb(0 0 0 / 8%);
}
html[data-hicad-embedded='true'] [data-testid='project-directory-settings-link'] {
overflow-wrap: anywhere;
}
`
document.head.appendChild(style)