feat: sync full workspace including web modules, docs, and configurations to Gitea

Optimized the root .gitignore to exclude virtual environments, node modules,
and temp folders to ensure clean and lightweight version tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-06-09 18:12:25 +08:00
parent 351688006e
commit a27e3b8e43
1510 changed files with 162044 additions and 1517 deletions

View File

@@ -0,0 +1,4 @@
{
"schemaVersion": 1,
"projectType": "axhub-make"
}

View File

@@ -0,0 +1,98 @@
# .axhub/make
这个目录用于存放 Axhub Make 项目的本地运行数据、项目配置索引,以及技能清单。
## 目录职责
- `axhub.config.json`
- 项目运行配置。
- `make.json`
- 项目级元信息。
- `entries.json`
- 入口扫描结果,通常由程序生成或刷新。
- `sidebar-tree.json`
- 侧边栏树数据,通常由程序生成或刷新。
- `.dev-server-info.json`
- 当前开发服务的本地运行信息。
- `skills/`
- 技能清单目录,包含官方默认清单和当前项目自定义清单。
- `backups/`
- 建议的维护备份目录,技能相关备份统一放到 `backups/skills/<timestamp>/`
## skills 目录规则
技能清单统一放在:
`apps/axhub-make/.axhub/make/skills/`
当前采用两层规则:
- 官方默认清单:`*.default.json`
- 用户自定义清单:同名但不带 `.default`
涉及 4 份 manifest
- `skills-manifest.default.json`
- `doc-skills-manifest.default.json`
- `theme-skills-manifest.default.json`
- `install-skills-manifest.default.json`
对应的自定义文件分别为:
- `skills-manifest.json`
- `doc-skills-manifest.json`
- `theme-skills-manifest.json`
- `install-skills-manifest.json`
程序读取顺序固定为:
1. 先读自定义清单
2. 若自定义不存在,再回退到 `*.default.json`
注意事项:
- 不做 merge不做覆盖合并不做深合并。
- 自定义清单一旦存在,就完整替代对应的默认清单。
- 官方仓库只维护 `*.default.json`
- 用户自定义清单不发布、不随项目更新覆盖。
## 技能正文位置
技能正文不在这里,官方技能目录位于:
`apps/axhub-make/skills/<skill-id>/`
常见文件包括:
- `apps/axhub-make/skills/<skill-id>/SKILL.md`
- `apps/axhub-make/skills/<skill-id>/references/...`
如果需要恢复某个官方默认技能:
1. 先到 `skills/*.default.json` 找回条目
2. 再到 `apps/axhub-make/skills/<skill-id>/` 找回技能正文
3. 最后按需要补回到当前项目自定义清单中
## 维护建议
- 修改任何技能清单或技能文档前,先备份。
- 推荐备份目录:`.axhub/make/backups/skills/<timestamp>/`
- 默认备份对象:
- 当前要修改的 manifest 文件
- 当前要修改的 skill 目录
## 不建议直接手改的文件
以下文件通常属于运行时或扫描产物,除非明确知道影响,否则不要手动编辑:
- `entries.json`
- `sidebar-tree.json`
- `.dev-server-info.json`
## 前端读取入口
前端技能清单的读取入口在:
`apps/prototype-admin/src/index/config/skillManifests.ts`
如果后续再调整 manifest 路径,必须同步修改这里的 glob 路径。

View File

@@ -0,0 +1,104 @@
{
"version": 1,
"generatedAt": "2026-03-28",
"skills": [
{
"id": "brainstorming",
"title": "Brainstorming",
"titleZh": "头脑风暴",
"description": "用于快速发散想法、明确文档主题与结构方向。",
"defaultSelected": true,
"localPaths": [
"/skills/third-party/brainstorming/SKILL.md"
],
"references": [
"obra/superpowers@brainstorming"
],
"category": "研究与探索"
},
{
"id": "deep-research",
"title": "Deep Research",
"titleZh": "深度研究",
"description": "用于系统化收集证据、形成深入研究材料。",
"defaultSelected": false,
"localPaths": [
"/skills/third-party/deep-research/SKILL.md"
],
"references": [
"199-biotechnologies/claude-deep-research-skill@deep-research"
],
"category": "研究与探索"
},
{
"id": "anything-to-notebooklm",
"title": "Anything to NotebookLM",
"titleZh": "资料转 NotebookLM",
"description": "将原始素材整理为适合 NotebookLM 使用的输入结构。",
"defaultSelected": false,
"localPaths": [
"/skills/third-party/anything-to-notebooklm/SKILL.md"
],
"references": [
"joeseesun/anything-to-notebooklm@anything-to-notebooklm"
],
"category": "研究与探索"
},
{
"id": "prd",
"title": "PRD",
"titleZh": "产品需求文档",
"description": "用于生成或完善 PRD 结构与内容。",
"defaultSelected": false,
"localPaths": [
"/skills/third-party/prd/SKILL.md"
],
"references": [
"https://skills.sh/github/awesome-copilot/prd"
],
"category": "产品与需求"
},
{
"id": "product-requirements",
"title": "Product Requirements",
"titleZh": "产品需求",
"description": "用于整理产品目标、范围与需求细节。",
"defaultSelected": false,
"localPaths": [
"/skills/third-party/product-requirements/SKILL.md"
],
"references": [
"https://skills.sh/cexll/myclaude/product-requirements"
],
"category": "产品与需求"
},
{
"id": "research",
"title": "Research",
"titleZh": "研究分析",
"description": "用于进行主题研究、资料筛选与结果归纳。",
"defaultSelected": false,
"localPaths": [
"/skills/third-party/research/SKILL.md"
],
"references": [
"https://skills.sh/tavily-ai/skills/research"
],
"category": "研究与探索"
},
{
"id": "user-story-writing",
"title": "User Story Writing",
"titleZh": "用户故事编写",
"description": "用于编写清晰可执行的用户故事。",
"defaultSelected": false,
"localPaths": [
"/skills/third-party/user-story-writing/SKILL.md"
],
"references": [
"https://skills.sh/aj-geddes/useful-ai-prompts/user-story-writing"
],
"category": "产品与需求"
}
]
}

View File

@@ -0,0 +1,66 @@
{
"version": 3,
"generatedAt": "2026-03-28",
"skills": [
{
"id": "project-guide",
"titleZh": "项目引导",
"titleEn": "Project Guide",
"description": "不知道从何开始或需要获得开发建议时执行该技能。AI 会为你评估当前项目状态,并推荐最佳的下一步操作与平台使用技巧。",
"localPath": "/skills/project-guide",
"skillMdPath": "/skills/project-guide/SKILL.md",
"slashCommand": "/project-guide",
"category": "项目管理"
},
{
"id": "create-workflow",
"titleZh": "创建内容",
"titleEn": "Unified Create",
"description": "想创建任何新内容如原型、组件、主题或文档时执行此技能。AI 会引导你完成规范的创建流程,无需手动记忆繁杂的规则。",
"localPath": "/skills/create-workflow",
"skillMdPath": "/skills/create-workflow/SKILL.md",
"slashCommand": "/create-workflow",
"category": "日常工作流"
},
{
"id": "design-review",
"titleZh": "设计 Review",
"titleEn": "Design Review",
"description": "界面开发完成后,想检查是否符合项目 UI 规范时执行此技能。AI 会帮你找出违规样式并提供修改建议,或帮你将新设计沉淀为主题。",
"localPath": "/skills/design-review",
"skillMdPath": "/skills/design-review/SKILL.md",
"slashCommand": "/design-review",
"category": "日常工作流"
},
{
"id": "project-memory",
"titleZh": "项目整理",
"titleEn": "Project Memory",
"description": "想整理项目积累的经验时执行此技能。AI 会把近期的代码变更、零散信息和新增逻辑沉淀至说明文档中,以保持项目资产井井有条。",
"localPath": "/skills/project-memory",
"skillMdPath": "/skills/project-memory/SKILL.md",
"slashCommand": "/project-memory",
"category": "日常工作流"
},
{
"id": "work-summary",
"titleZh": "工作总结",
"titleEn": "Work Summary",
"description": "日常报告编写利器。随时执行该技能来回顾近期的成果AI 会自动追踪你的代码变更,直接为你生成一份清晰的日报或工作总结。",
"localPath": "/skills/work-summary",
"skillMdPath": "/skills/work-summary/SKILL.md",
"slashCommand": "/work-summary",
"category": "日常工作流"
},
{
"id": "skills-management",
"titleZh": "技能管理",
"titleEn": "Skills Management",
"description": "管理和维护当前项目的技能库。当你想为团队添加新的自定义技能、修改现有描述或恢复官方默认技能时,执行此技能。",
"localPath": "/skills/skills-management",
"skillMdPath": "/skills/skills-management/SKILL.md",
"slashCommand": "/skills-management",
"category": "项目管理"
}
]
}

View File

@@ -0,0 +1,186 @@
{
"version": 1,
"generatedAt": "2026-03-28",
"skills": [
{
"id": "frontend-design",
"title": "Landing & Marketing",
"titleEn": "Landing & Marketing",
"titleZh": "C端展示与落地页",
"description": "使用场景:需要打造高辨识度视觉风格的页面/落地页/展示型界面时强调创意审美、版式与动效表达避免模板化“AI 味”。",
"localPaths": [
"/skills/third-party/frontend-design/SKILL.md"
],
"sourcePageUrl": "https://skills.sh/anthropics/skills/frontend-design",
"sourceRepoUrl": "https://github.com/anthropics/skills",
"sourcePath": "skills/frontend-design",
"license": null,
"category": "设计与体验"
},
{
"id": "interface-design",
"title": "Admin & SaaS Interface",
"titleEn": "Admin & SaaS Interface",
"titleZh": "B端后台与SaaS",
"description": "使用场景后台、SaaS、工具类产品的交互界面设计仪表盘/设置页/数据界面);强调信息架构与可用性,不用于营销落地页。",
"localPaths": [
"/skills/third-party/interface-design/SKILL.md"
],
"sourcePageUrl": "https://skills.sh/dammyjay93/interface-design/interface-design",
"sourceRepoUrl": "https://github.com/Dammyjay93/interface-design",
"sourcePath": ".claude/skills/interface-design",
"license": null,
"category": "设计与体验"
},
{
"id": "ui-ux-pro-max",
"title": "UI/UX Pro Max",
"titleEn": "UI/UX Pro Max",
"titleZh": "UI/UX 专家指南",
"description": "使用场景:需要快速选型和评审 UI/UX 方案时(色板、字体、图表、无障碍、响应式、性能);更偏规则库与优化清单,而非单一风格生成。",
"localPaths": [
"/skills/ui-ux-pro-max/SKILL.md"
],
"sourcePageUrl": "https://skills.sh/nextlevelbuilder/ui-ux-pro-max-skill/ui-ux-pro-max",
"sourceRepoUrl": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill",
"sourcePath": ".claude/skills/ui-ux-pro-max",
"license": null,
"category": "设计与体验"
},
{
"id": "implement-design",
"title": "figma-implement-design",
"titleEn": "figma-implement-design",
"titleZh": "Figma 设计实现还原",
"description": "使用场景:拿到 Figma 链接或选中节点后,基于 Figma MCP 进行 1:1 设计还原并输出可落地代码。",
"localPaths": [
"/skills/third-party/implement-design/SKILL.md"
],
"sourcePageUrl": "https://skills.sh/figma/mcp-server-guide/implement-design",
"sourceRepoUrl": "https://github.com/figma/mcp-server-guide",
"sourcePath": "skills/implement-design",
"license": null,
"category": "开发与实现"
},
{
"id": "baoyu-image-gen",
"title": "Baoyu Image Gen",
"titleEn": "Baoyu Image Gen",
"titleZh": "宝玉图像生成",
"description": "使用场景:批量生成视觉素材(插画、封面、背景图等)而非页面结构;支持 OpenAI/Google/DashScope、多比例与参考图生成。",
"localPaths": [
"/skills/third-party/baoyu-image-gen/SKILL.md"
],
"sourcePageUrl": "https://skills.sh/jimliu/baoyu-skills/baoyu-image-gen",
"sourceRepoUrl": "https://github.com/jimliu/baoyu-skills",
"sourcePath": "skills/baoyu-image-gen",
"license": null,
"category": "设计与体验"
},
{
"id": "shadcn-ui",
"title": "shadcn/ui",
"titleEn": "shadcn/ui",
"titleZh": "shadcn 组件体系",
"description": "使用场景React + Tailwind 项目搭建/扩展 shadcn/ui 组件体系;侧重可访问性、表单模式、主题变量与组件工程化复用。",
"localPaths": [
"/skills/third-party/shadcn-ui/SKILL.md"
],
"sourcePageUrl": "https://skills.sh/giuseppe-trisciuoglio/developer-kit/shadcn-ui",
"sourceRepoUrl": "https://github.com/giuseppe-trisciuoglio/developer-kit",
"sourcePath": "plugins/developer-kit-typescript/skills/shadcn-ui",
"license": null,
"category": "开发与实现"
},
{
"id": "ant-design",
"title": "Ant Design",
"titleEn": "Ant Design",
"titleZh": "Ant Design 企业后台",
"description": "使用场景:企业后台/管理系统采用 antd 6 + Pro + X 时的组件选型与架构决策;强调 token 主题、SSR、CRUD 与 AI 聊天 UI 模式。",
"localPaths": [
"/skills/third-party/ant-design/SKILL.md"
],
"sourcePageUrl": "https://skills.sh/ant-design/antd-skill/ant-design",
"sourceRepoUrl": "https://github.com/ant-design/antd-skill",
"sourcePath": "skills/ant-design",
"license": null,
"category": "开发与实现"
},
{
"id": "stitch-skills",
"title": "Stitch Skills",
"titleEn": "Stitch Skills",
"titleZh": "Stitch 主流程编排",
"description": "使用场景:以 spec.md 驱动 Stitch 设计并确认后再生成代码;统一编排 design-md、stitch-loop、react-components避免跳过设计 gate。",
"localPaths": [
"/skills/third-party/stitch-skills/stitch-main-workflow/SKILL.md"
],
"sourcePageUrl": "https://github.com/google-labs-code/stitch-skills",
"sourceRepoUrl": "https://github.com/google-labs-code/stitch-skills",
"sourcePaths": [
"skills/design-md",
"skills/react-components",
"skills/stitch-loop"
],
"license": "Apache-2.0",
"category": "开发与实现"
},
{
"id": "gemini-cli-uiux",
"title": "Gemini CLI UI/UX",
"titleEn": "Gemini CLI UI/UX",
"titleZh": "Gemini 设计",
"description": "AI 直接调用 Gemini CLI 进行 UI/UX 设计与落地",
"localPaths": [
"/skills/gemini-cli-uiux/SKILL.md"
],
"activeTabs": [
"prototypes"
],
"license": null,
"category": "开发与实现"
},
{
"id": "design-bid-proposals",
"title": "Design Bid Proposals",
"titleEn": "Design Bid Proposals",
"titleZh": "设计比稿三案",
"description": "使用场景:在原型或组件新建时默认生成 A 稳健型、B 平衡型、C 突破型三案;严格在 36 个未锁死维度上拉开差异,并输出差异对比表与推荐方向。",
"localPaths": [
"/skills/design-bid-proposals/SKILL.md"
],
"license": null,
"category": "设计与体验"
},
{
"id": "pencil-sync-after-prototype-workflow",
"title": "Pencil Sync After Prototype Workflow",
"titleEn": "Pencil Sync After Prototype Workflow",
"titleZh": "原型后置 Pencil 同步",
"description": "使用场景:先完成 spec.md 与 index.tsx 原型实现,再 1:1 回建 Pencil并要求后续代码、规格与 .pen 强同步。",
"localPaths": [
"/skills/pencil-sync-after-prototype-workflow/SKILL.md"
],
"activeTabs": [
"prototypes"
],
"license": null,
"category": "开发与实现"
},
{
"id": "taste-skill",
"title": "Design Taste",
"titleEn": "Design Taste",
"titleZh": "高品质前端设计",
"description": "使用场景:打造高品质、去 AI 味的前端界面;通过设计方差、动效强度、视觉密度三个维度精确控制输出风格,内置反 AI 模板化规则与高端设计灵感库。",
"localPaths": [
"/skills/third-party/taste-skill/SKILL.md"
],
"sourceRepoUrl": "https://github.com/Leonxlnx/taste-skill",
"sourcePath": "taste-skill",
"license": null,
"category": "设计与体验"
}
]
}

View File

@@ -0,0 +1,64 @@
{
"version": 1,
"generatedAt": "2026-03-28",
"skills": [
{
"id": "tailwind-design-system",
"title": "Tailwind Design System",
"titleZh": "Tailwind 设计系统",
"description": "用于基于 Tailwind CSS v4 设计和落地可扩展主题系统,覆盖 token、变量、组件模式与响应式规范。",
"defaultSelected": true,
"localPaths": [
"/skills/third-party/tailwind-design-system/SKILL.md"
],
"category": "开发与实现"
},
{
"id": "theme-factory",
"title": "Theme Factory",
"titleZh": "主题工厂",
"description": "用于快速构建和应用视觉主题方案,包含颜色体系与字体搭配参考,适合主题风格探索与定稿。",
"defaultSelected": false,
"localPaths": [
"/skills/third-party/theme-factory/SKILL.md"
],
"category": "设计与体验"
},
{
"id": "ui-ux-pro-max",
"title": "UI/UX Pro Max",
"titleZh": "UI/UX 专家指南",
"description": "用于补充 UI/UX 规则库与评审清单,覆盖可访问性、色板、排版、交互、响应式与性能优化建议。",
"defaultSelected": false,
"localPaths": [
"/skills/ui-ux-pro-max/SKILL.md"
],
"category": "设计与体验"
},
{
"id": "ui-design-brain",
"title": "UI Design Brain",
"titleZh": "UI 设计大脑",
"description": "用于基于 60+ 真实组件模式与界面最佳实践来辅助主题和界面风格设计,减少通用 AI 模板感。",
"defaultSelected": false,
"localPaths": [
"/skills/third-party/ui-design-brain/SKILL.md"
],
"sourceRepoUrl": "https://github.com/carmahhawwari/ui-design-brain",
"sourcePath": ".",
"license": "MIT",
"category": "设计与体验"
},
{
"id": "gemini-cli-uiux",
"title": "Gemini CLI UI/UX",
"titleZh": "Gemini 设计",
"description": "AI 直接调用 Gemini CLI 进行 UI/UX 设计与落地",
"defaultSelected": false,
"localPaths": [
"/skills/gemini-cli-uiux/SKILL.md"
],
"category": "设计与体验"
}
]
}

123
axhub-make/.gitignore vendored Normal file
View File

@@ -0,0 +1,123 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Build outputs
dist/
tests/
vite-plugins/**/*.test.ts
*.tsbuildinfo
# Vite
.vite/
vite.config.*.timestamp-*
# Environment variables
.env
.env.local
.env.*.local
.env.production
.env.development
# Local configuration (runtime-generated)
.axhub/make/*
!.axhub/make/skills/
!.axhub/make/README.md
.axhub/make/skills/*.json
!.axhub/make/skills/*.default.json
components.json
.dev-server-info.json
entries.json
# IDE and editors
.idea/
.obsidian/
.claude/
.trae/
/.drafts/
*.swp
*.swo
*~
.DS_Store
*.sublime-project
*.sublime-workspace
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Desktop.ini
# Logs
logs/
*.log
# Temporary files
*.tmp
*.temp
.cache/
# Coverage reports
coverage/
*.lcov
.nyc_output/
# TypeScript
*.tsbuildinfo
# Optional npm cache directory
.npm
.npm-cache/
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache
.parcel-cache
# Next.js
.next/
out/
# Nuxt.js
.nuxt/
# Storybook build outputs
storybook-static/
# Temporary folders
tmp/
temp/
# Git version management temporary files
.git-versions/
# Database files (keep directory structure and initial data)
# assets/database/*.json
# !assets/database/.gitkeep

21
axhub-make/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,21 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Axhub Make (Vite)",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev",
"cwd": "${workspaceFolder}"
},
],
"compounds": [
{
"name": "Dev + Ref Center",
"configurations": [
"Axhub Make (Vite)",
"Chrome: Ref Center"
]
}
]
}

2
axhub-make/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,2 @@
{
}

64
axhub-make/AGENTS.md Normal file
View File

@@ -0,0 +1,64 @@
# Agents 工作流程说明
本项目为目标用户开发原型,需要 Agents 扮演产品经理、 UI/UX设计师和前端开发的复合角色。
## 🧭 工作流程
### 场景 1新建或更新原型主场景
| 步骤 | 说明 | 参考文档 |
|------|------|----------|
| ① 阅读规范与资料 | 系统规则 + 用户提供的资料与参考 | — |
| ② 需求对齐(可选) | 用户要求时启动,澄清需求,确认后才继续 | — |
| ③ 原型/组件设计 | 确定布局与视觉方向,产出 `spec.md` | `rules/design-guide.md` |
| ④ 开发与验收 | 根据 spec 实现原型/组件并完成验收 | `rules/development-guide.md` |
> **复杂原型管理**:优先通过普通原型目录或原型内部模块组织复杂页面,避免依赖特殊命名约定。
### 场景 2资源管理
| 步骤 | 说明 | 参考文档 |
|------|------|----------|
| ① 阅读资源规则 | 查看资产目录与已有资源 | `rules/resource-management-guide.md` |
| ② 需求对齐(可选) | 澄清输出目标、范围与位置 | — |
| ③ 新增/更新资源 | 按规范放置、命名与维护 | 按类型分别参考下表 |
**资源类型与对应文档:**
| 资源类型 | 目录 | 参考文档 |
|----------|------|----------|
| 项目文档 | `src/docs/` | `rules/documentation-guide.md` |
| 主题 | `src/themes/` | `rules/theme-guide.md` |
| 数据表 | `src/database/` | `src/database/README.md` |
| 文档配图等附属资源 | `src/docs/assets/` | `rules/resource-management-guide.md` |
## ⚠️ 重要原则
1. **文档与代码同步**
- 修改代码时,必须同步更新对应的 `spec.md` 规格文档
- 修改 `spec.md` 时,必须同步更新对应的代码实现
2. **完整阅读文档资料**
- 必须仔细阅读用户提供的所有文档和资料
- 必须查看上下文中提供的相关规则和参考文件
3. **自主完成所有操作**
- 用户不懂开发技术,无法执行任何 CLI 命令
- 不得省略验收流程
4. **积极使用子代理Sub-agent**
- 对于批量任务(如同时修改多个文件、批量生成资源)或复杂任务(如涉及多步骤流水线),应主动拆分为独立子任务并分派给子代理并行执行
## 项目结构
```
├── src/
│ ├── common/ # 公共类型和工具
│ ├── components/ # 组件目录
│ ├── prototypes/ # 原型目录
│ ├── docs/ # 项目文档
│ ├── themes/ # 主题配置
│ └── database/ # 页面可直接消费的数据表
└── skills/ # 项目技能与工作流
```

View File

@@ -0,0 +1,64 @@
{{PROJECT_INFO_SECTION}}
# Agents 工作流程说明
本项目为目标用户开发原型,需要 Agents 扮演产品经理、 UI/UX设计师和前端开发的复合角色。
## 🧭 工作流程
### 场景 1新建或更新原型主场景
| 步骤 | 说明 | 参考文档 |
|------|------|----------|
| ① 阅读规范与资料 | 系统规则 + 用户提供的资料与参考 | — |
| ② 需求对齐(可选) | 用户要求时启动,澄清需求,确认后才继续 | — |
| ③ 原型/组件设计 | 确定布局与视觉方向,产出 `spec.md` | `rules/design-guide.md` |
| ④ 开发与验收 | 根据 spec 实现原型/组件并完成验收 | `rules/development-guide.md` |
> **复杂原型管理**:优先通过普通原型目录或原型内部模块组织复杂页面,避免依赖特殊命名约定。
### 场景 2资源管理
| 步骤 | 说明 | 参考文档 |
|------|------|----------|
| ① 阅读资源规则 | 查看资产目录与已有资源 | `rules/resource-management-guide.md` |
| ② 需求对齐(可选) | 澄清输出目标、范围与位置 | — |
| ③ 新增/更新资源 | 按规范放置、命名与维护 | 按类型分别参考下表 |
**资源类型与对应文档:**
| 资源类型 | 目录 | 参考文档 |
|----------|------|----------|
| 项目文档 | `src/docs/` | `rules/documentation-guide.md` |
| 主题 | `src/themes/` | `rules/theme-guide.md` |
| 数据表 | `src/database/` | `src/database/README.md` |
| 文档配图等附属资源 | `src/docs/assets/` | `rules/resource-management-guide.md` |
## ⚠️ 重要原则
1. **文档与代码同步**
- 修改代码时,必须同步更新对应的 `spec.md` 规格文档
- 修改 `spec.md` 时,必须同步更新对应的代码实现
2. **完整阅读文档资料**
- 必须仔细阅读用户提供的所有文档和资料
- 必须查看上下文中提供的相关规则和参考文件
3. **自主完成所有操作**
- 用户不懂开发技术,无法执行任何 CLI 命令
- 不得省略验收流程
4. **积极使用子代理Sub-agent**
- 对于批量任务(如同时修改多个文件、批量生成资源)或复杂任务(如涉及多步骤流水线),应主动拆分为独立子任务并分派给子代理并行执行
## 项目结构
```
├── src/
│ ├── common/ # 公共类型和工具
│ ├── components/ # 组件目录
│ ├── prototypes/ # 原型目录
│ ├── docs/ # 项目文档
│ ├── themes/ # 主题配置
│ └── database/ # 页面可直接消费的数据表
└── skills/ # 项目技能与工作流
```

64
axhub-make/CLAUDE.md Normal file
View File

@@ -0,0 +1,64 @@
# Agents 工作流程说明
本项目为目标用户开发原型,需要 Agents 扮演产品经理、 UI/UX设计师和前端开发的复合角色。
## 🧭 工作流程
### 场景 1新建或更新原型主场景
| 步骤 | 说明 | 参考文档 |
|------|------|----------|
| ① 阅读规范与资料 | 系统规则 + 用户提供的资料与参考 | — |
| ② 需求对齐(可选) | 用户要求时启动,澄清需求,确认后才继续 | — |
| ③ 原型/组件设计 | 确定布局与视觉方向,产出 `spec.md` | `rules/design-guide.md` |
| ④ 开发与验收 | 根据 spec 实现原型/组件并完成验收 | `rules/development-guide.md` |
> **复杂原型管理**:优先通过普通原型目录或原型内部模块组织复杂页面,避免依赖特殊命名约定。
### 场景 2资源管理
| 步骤 | 说明 | 参考文档 |
|------|------|----------|
| ① 阅读资源规则 | 查看资产目录与已有资源 | `rules/resource-management-guide.md` |
| ② 需求对齐(可选) | 澄清输出目标、范围与位置 | — |
| ③ 新增/更新资源 | 按规范放置、命名与维护 | 按类型分别参考下表 |
**资源类型与对应文档:**
| 资源类型 | 目录 | 参考文档 |
|----------|------|----------|
| 项目文档 | `src/docs/` | `rules/documentation-guide.md` |
| 主题 | `src/themes/` | `rules/theme-guide.md` |
| 数据表 | `src/database/` | `src/database/README.md` |
| 文档配图等附属资源 | `src/docs/assets/` | `rules/resource-management-guide.md` |
## ⚠️ 重要原则
1. **文档与代码同步**
- 修改代码时,必须同步更新对应的 `spec.md` 规格文档
- 修改 `spec.md` 时,必须同步更新对应的代码实现
2. **完整阅读文档资料**
- 必须仔细阅读用户提供的所有文档和资料
- 必须查看上下文中提供的相关规则和参考文件
3. **自主完成所有操作**
- 用户不懂开发技术,无法执行任何 CLI 命令
- 不得省略验收流程
4. **积极使用子代理Sub-agent**
- 对于批量任务(如同时修改多个文件、批量生成资源)或复杂任务(如涉及多步骤流水线),应主动拆分为独立子任务并分派给子代理并行执行
## 项目结构
```
├── src/
│ ├── common/ # 公共类型和工具
│ ├── components/ # 组件目录
│ ├── prototypes/ # 原型目录
│ ├── docs/ # 项目文档
│ ├── themes/ # 主题配置
│ └── database/ # 页面可直接消费的数据表
└── skills/ # 项目技能与工作流
```

201
axhub-make/LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

98
axhub-make/README.md Normal file
View File

@@ -0,0 +1,98 @@
> [!NOTE]
> Axhub Make 不是“又一个 AI 生成原型工具”。它是一条从 **需求** 到 **文档** 到 **原型** 再到 **交付Axure / Figma / Html** 的工作流。
# 跳过这个 README 吧
读文档的时代已经过去了。直接把下面这行发给你的 Agent
```
阅读这个 README并告诉我它为什么不只是又一个 AI 生成原型工具:
https://raw.githubusercontent.com/lintendo/Axhub-Make/refs/heads/main/README.md
```
# Axhub Make
一个给 **产品**、**设计师** 和 **AI Agent** 用的原型与文档协作工作流。
官网:[https://axhub.im/make/](https://axhub.im/make/)
你说清楚要什么Make 会把它变成:
- 可以跑的交互原型(不是截图,不是 PPT
- 完整的多类型文档(需求文档、用户故事、规格文档等)
- 可持续复用的资源资产(主题、组件、数据表等)
- 可导出的交付物Axure / Figma / Html
---
## 安装
统一交给 AI Agent 安装。把下面这段直接发给你的 AgentClaude Code、TRAE、Cursor 等):
```
请根据这里的说明安装并配置 Axhub Make
https://raw.githubusercontent.com/lintendo/Axhub-Make/refs/heads/main/rules/installation-guide.md
```
---
## 核心亮点
Axhub Make 把「原型生成」变成「可执行工作流」,核心能力如下:
- 可视化管理原型和文档,不懂开发的产品和设计师也能直接使用
- 内置 30+ 专业的原型生成与文档协作技能(`skills`
- 内置项目与资源管理和生成能力,让 AI 持续产出视觉风格一致、逻辑统一的原型和文档
- 内置记忆系统,通过文档持续沉淀项目记忆,让 AI 越来越懂你和项目
- 内置 `spec` 驱动的原型生成机制,减少 AI 生成过程中的幻觉和偏题
- 支持从 Axure、V0、Stitch、AIStudio 以及任意网页导入原型或资源
- 支持导出到 Axure 或 Figma完美融入原有工作流
### 三大产物
<table>
<thead>
<tr>
<th style="white-space: nowrap;">内容</th>
<th>你会在仓库里看到什么</th>
<th>为什么重要</th>
</tr>
</thead>
<tbody>
<tr>
<td style="white-space: nowrap;">原型</td>
<td><code>src/prototypes/</code></td>
<td>用于评审真实交互和业务流程,不再只看静态稿。可通过普通原型目录和原型内部模块组织页面结构</td>
</tr>
<tr>
<td style="white-space: nowrap;">文档</td>
<td><code>src/docs/</code></td>
<td>按专门文档编写流程沉淀信息,支撑协作、评审与复盘</td>
</tr>
<tr>
<td style="white-space: nowrap;">资源</td>
<td><code>src/themes/</code>、<code>src/components/</code>、<code>src/database/</code></td>
<td>统一管理主题、组件、数据表,保证持续生成的一致性</td>
</tr>
</tbody>
</table>
---
## 给 Agent 的入职材料
你可以把下面这段直接贴给 Agent当作“入职说明”
```
你正在 Axhub Make 仓库中工作。
请阅读并遵循:
- AGENTS.md工作流与原则
- rules/README.md规则索引与命名体系
你必须:
- 以产品经理 + UI/UX 设计师 + 前端工程师的复合角色开展工作
- 遵循项目的文档编写流程,并维护必要文档
- 持续实现并维护可运行的原型、文档和可复用资源
```

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
:root{color-scheme:light;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}html,body,#canvas-root,.canvas-template-shell,.canvas-template-canvas{width:100%;height:100%;min-height:100%;min-width:0}body{margin:0;overflow:hidden;background:#f1f5f9;color:#475569}body.dark{background:#0f172a;color:#94a3b8}.canvas-template-shell{background:inherit}.canvas-template-placeholder,.canvas-template-status,.canvas-template-error{display:flex;align-items:center;justify-content:center;width:100%;height:100%;padding:24px;box-sizing:border-box;font-size:12px;line-height:1.5}.canvas-template-status,.canvas-template-placeholder{color:inherit}.canvas-template-error{color:#dc2626}body.dark .canvas-template-error{color:#fca5a5}

View File

@@ -0,0 +1 @@
import{r as n,j as c,d as R}from"./chunks/vendor-react.js?v=1775123024591";import{I as T}from"./chunks/vendor-excalidraw.js?v=1775123024591";import"./chunks/_commonjsHelpers.js?v=1775123024591";import"./chunks/preload-helper.js?v=1775123024591";import"./chunks/vendor-common.js?v=1775123024591";import"./chunks/_commonjs-dynamic-modules.js?v=1775123024591";const N=1500;function S({canvasName:t,isDarkMode:e}){const[s,i]=n.useState(null),[l,v]=n.useState(null),[I,h]=n.useState(!0),[g,w]=n.useState(""),u=n.useRef(null),d=n.useRef(!1),C=n.useRef(t),f=n.useRef(!1);n.useEffect(()=>{C.current=t,f.current=!1,h(!0),w(""),v(null);let r=!1;return(async()=>{try{const a=await fetch(`/api/canvas/${encodeURIComponent(t)}`);if(r)return;if(!a.ok)throw new Error(`加载画布失败 (${a.status})`);const m=await a.json();if(r)return;v(m),f.current=!0}catch(a){if(r)return;w((a==null?void 0:a.message)||"加载画布失败")}finally{r||h(!1)}})(),()=>{r=!0}},[t]);const E=n.useCallback(async(r,o)=>{if(!d.current){d.current=!0;try{const a=(s==null?void 0:s.getFiles())||{},m={type:"excalidraw",version:2,source:"axhub-make",elements:r,appState:{gridSize:(o==null?void 0:o.gridSize)??null,viewBackgroundColor:(o==null?void 0:o.viewBackgroundColor)??"#ffffff"},files:a};await fetch(`/api/canvas/${encodeURIComponent(C.current)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({content:JSON.stringify(m,null,2)})})}catch(a){console.warn("Failed to save canvas:",a)}finally{d.current=!1}}},[s]),j=n.useCallback((r,o)=>{f.current&&(u.current&&clearTimeout(u.current),u.current=setTimeout(()=>{E(r,o)},N))},[E]);return n.useEffect(()=>()=>{u.current&&clearTimeout(u.current)},[]),I?c.jsx("div",{className:"canvas-template-status",children:"加载中..."}):g?c.jsx("div",{className:"canvas-template-error",children:g}):c.jsx("div",{className:"canvas-template-canvas",children:c.jsx(T,{excalidrawAPI:r=>i(r),initialData:l,onChange:j,theme:e?"dark":"light",UIOptions:{canvasActions:{saveAsImage:!0,export:!1}}},t)})}const p="axhub-make-dark-mode";function x(t){var e;return typeof document>"u"?"":((e=document.querySelector(`meta[name="${t}"]`))==null?void 0:e.getAttribute("content"))||""}function y(t){const e=String(t||"").trim();return!e||e.startsWith("{{")?"":e}function b(t){const e=t.match(/^\/canvas\/(.+?)\/?$/);if(!(e!=null&&e[1]))return"";try{return decodeURIComponent(e[1])}catch{return e[1]}}function A(){const t=y(x("axhub-canvas-name"));return t||(typeof window>"u"?"":b(window.location.pathname))}function D(t){const e=y(x("axhub-canvas-title"));if(e)return e;const s=t.replace(/\.excalidraw$/i,"").trim();return s?`${s} - Canvas`:"Canvas"}function M(){try{return localStorage.getItem(p)==="true"}catch{return!1}}function $(){const[t]=n.useState(()=>A()),[e,s]=n.useState(()=>M());return n.useEffect(()=>{const i=l=>{l.key===p&&s(l.newValue==="true")};return window.addEventListener("storage",i),()=>{window.removeEventListener("storage",i)}},[]),n.useEffect(()=>{const i=D(t);document.title=i,document.documentElement.classList.toggle("dark",e),document.body.classList.toggle("dark",e)},[t,e]),c.jsx("div",{className:e?"canvas-template-shell dark":"canvas-template-shell",children:t?c.jsx(S,{canvasName:t,isDarkMode:e}):c.jsx("div",{className:"canvas-template-placeholder",children:"未指定画布"})})}const k=document.getElementById("canvas-root");if(!k)throw new Error("[Canvas Template] 找不到 #canvas-root 元素");const L=R.createRoot(k);L.render(c.jsx($,{}));

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
function r(o){throw new Error('Could not dynamically require "'+o+'". Please configure the dynamicRequireTargets or/and ignoreDynamicRequires option of @rollup/plugin-commonjs appropriately for this require call to work.')}export{r as c};

View File

@@ -0,0 +1 @@
var u=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function f(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}function l(e){if(e.__esModule)return e;var r=e.default;if(typeof r=="function"){var t=function o(){return this instanceof o?Reflect.construct(r,arguments,this.constructor):r.apply(this,arguments)};t.prototype=r.prototype}else t={};return Object.defineProperty(t,"__esModule",{value:!0}),Object.keys(e).forEach(function(o){var n=Object.getOwnPropertyDescriptor(e,o);Object.defineProperty(t,o,n.get?n:{enumerable:!0,get:function(){return e[o]}})}),t}export{f as a,u as c,l as g};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
const h="modulepreload",E=function(i){return"/"+i},a={},y=function(u,s,v){let c=Promise.resolve();if(s&&s.length>0){document.getElementsByTagName("link");const e=document.querySelector("meta[property=csp-nonce]"),t=(e==null?void 0:e.nonce)||(e==null?void 0:e.getAttribute("nonce"));c=Promise.allSettled(s.map(r=>{if(r=E(r),r in a)return;a[r]=!0;const o=r.endsWith(".css"),d=o?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${r}"]${d}`))return;const n=document.createElement("link");if(n.rel=o?"stylesheet":h,o||(n.as="script"),n.crossOrigin="",n.href=r,t&&n.setAttribute("nonce",t),document.head.appendChild(n),o)return new Promise((f,m)=>{n.addEventListener("load",f),n.addEventListener("error",()=>m(new Error(`Unable to preload CSS for ${r}`)))})}))}function l(e){const t=new Event("vite:preloadError",{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return c.then(e=>{for(const t of e||[])t.status==="rejected"&&l(t.reason);return u().catch(l)})};export{y as _};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 B

View File

@@ -0,0 +1 @@
import{e as i,f as c,R as s,c as l}from"./chunks/vendor-react.js?v=1775123024591";import{A as m}from"./chunks/vendor-antd.js?v=1775123024591";import"./chunks/_commonjsHelpers.js?v=1775123024591";import"./chunks/vendor-common.js?v=1775123024591";import"./chunks/preload-helper.js?v=1775123024591";import"./chunks/_commonjs-dynamic-modules.js?v=1775123024591";function f(r,n){const e=document.getElementById("root");if(!e){console.error("[Html Template] 找不到 #root 元素");return}const o=n||{container:e,config:{},data:{},events:{}};try{l(e).render(s.createElement(m,null,s.createElement(r,o)))}catch(t){console.error("[Html Template] 渲染失败:",t)}}const p={...c,...i};if(typeof window<"u"){const r=new URLSearchParams(window.location.search),n=r.get("scale"),e=r.get("width"),a=r.get("height"),o=document.getElementById("root");if(o){if(n){const t=parseFloat(n);!isNaN(t)&&t>0&&(o.style.transform=`scale(${t})`,o.style.transformOrigin="top left")}if(e||a){if(e){const t=parseInt(e);!isNaN(t)&&t>0&&(o.style.width=`${t}px`)}if(a){const t=parseInt(a);!isNaN(t)&&t>0&&(o.style.height=`${t}px`)}}}window.HtmlTemplateBootstrap={renderComponent:f,React:s,ReactDOM:p}}

View File

@@ -0,0 +1 @@
.resize-handle-right{cursor:ew-resize!important;z-index:100!important}.resize-handle-right:before{content:"";position:absolute;right:50%;top:50%;transform:translate(50%,-50%);width:2px;height:40px;background:hsl(var(--border));border-radius:1px;transition:all .2s;z-index:100}.resize-handle-right:hover:before,.resize-handle-right:active:before{background:hsl(var(--muted-foreground));width:3px;height:50px}.resize-handle-bottom{cursor:ns-resize!important;z-index:100!important}.resize-handle-bottom:before{content:"";position:absolute;left:50%;bottom:50%;transform:translate(-50%,50%);width:40px;height:2px;background:hsl(var(--border));border-radius:1px;transition:all .2s;z-index:100}.resize-handle-bottom:hover:before,.resize-handle-bottom:active:before{background:hsl(var(--muted-foreground));width:50px;height:3px}.resize-handle-bottomRight{cursor:nwse-resize!important;z-index:100!important}.resize-handle-bottomRight:before{content:"";position:absolute;right:0;bottom:0;width:12px;height:12px;background:hsl(var(--border));border-radius:0 0 6px;transition:all .2s;z-index:100}.resize-handle-bottomRight:hover:before,.resize-handle-bottomRight:active:before{background:hsl(var(--border-strong));width:16px;height:16px}.ant-card-hoverable:hover .resize-handle-right:before,.ant-card-hoverable:hover .resize-handle-bottom:before,.ant-card-hoverable:hover .resize-handle-bottomRight:before{background:hsl(var(--border-strong))}.menu-item-wrapper .more-btn{opacity:0;transition:opacity .2s,color .2s}.menu-item-wrapper:hover .more-btn,.menu-item-wrapper.is-selected .more-btn,.menu-item-wrapper .more-btn.ant-dropdown-open,.menu-item-wrapper .more-btn[data-state=open],.menu-item-wrapper .more-btn:focus-visible{opacity:1}.menu-item-wrapper .prototype-row-action{opacity:0;transition:opacity .2s,color .2s}.menu-item-wrapper.is-selected .prototype-row-action{opacity:0}.menu-item-wrapper:hover .prototype-row-action,.menu-item-wrapper .prototype-row-action.ant-dropdown-open,.menu-item-wrapper .prototype-row-action[data-state=open],.menu-item-wrapper .prototype-row-action:focus-visible{opacity:1}@media (max-width: 768px){.pc-layout{display:none!important}.mobile-layout{display:block!important}}@media (min-width: 769px){.pc-layout{display:block!important}.mobile-layout{display:none!important}}.mobile-item-card{background:var(--mobile-item-bg, hsl(var(--card)));border:1px solid var(--mobile-item-border, hsl(var(--border)));border-radius:8px;padding:16px;margin-bottom:12px;cursor:pointer;transition:all .2s}.mobile-item-card:hover{border-color:var(--mobile-item-hover-border, hsl(var(--ring)));box-shadow:var(--mobile-item-hover-shadow, var(--shadow-sm))}.mobile-item-card:active{transform:scale(.98)}.mobile-item-title{font-size:16px;font-weight:500;color:var(--mobile-item-title-color, hsl(var(--foreground)));margin-bottom:4px}.mobile-item-name{font-size:12px;color:var(--mobile-item-name-color, hsl(var(--muted-foreground)))}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,246 @@
/**
* 客户端自动调试工具
* 在页面加载时自动检测错误并上报到服务器
*
* 功能:
* 1. 白屏检测
* 2. 运行时错误捕获
* 3. 自动上报到服务器
* 4. 提供调试建议
*/
(function() {
'use strict';
const AUTO_DEBUG_CONFIG = {
enabled: true,
whiteScreenTimeout: 3000, // 3秒后检测白屏
reportUrl: '/api/report-error',
autoReport: true
};
// 获取当前页面路径
function getCurrentPath() {
const pathname = window.location.pathname;
// 从 /prototypes/xxx.html 或 /components/xxx.html 提取路径
const match = pathname.match(/\/(prototypes|components)\/([^.]+)/);
return match ? `${match[1]}/${match[2]}` : null;
}
// 上报错误到服务器
function reportError(error) {
if (!AUTO_DEBUG_CONFIG.autoReport) return;
const currentPath = getCurrentPath();
if (!currentPath) return;
fetch(AUTO_DEBUG_CONFIG.reportUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: currentPath,
error: {
message: error.message,
stack: error.stack,
type: error.type || 'error',
timestamp: Date.now()
}
})
})
.then(res => res.json())
.then(data => {
if (data.suggestion) {
console.log('%c[Auto Debug] 修复建议:', 'color: #1890ff; font-weight: bold;');
console.log('%c' + data.suggestion, 'color: #52c41a;');
}
})
.catch(err => {
console.error('[Auto Debug] 上报错误失败:', err);
});
}
// 白屏检测
function checkWhiteScreen() {
const root = document.getElementById('root');
const hasContent = root && root.children.length > 0;
const hasError = document.getElementById('__fallback_error_overlay__');
if (!hasContent && !hasError) {
console.error('%c[Auto Debug] 检测到白屏问题', 'color: #ff4d4f; font-weight: bold;');
// 诊断白屏原因
const diagnostics = {
rootExists: !!root,
reactExists: !!window.React,
reactDOMExists: !!window.ReactDOM,
componentExists: !!window.AxhubDevComponent,
bootstrapExists: !!window.DevTemplateBootstrap,
errorQueueLength: window.__ERROR_SYSTEM__?.getErrorQueue()?.length || 0
};
console.log('%c[Auto Debug] 诊断信息:', 'color: #1890ff;', diagnostics);
// 提供修复建议
const suggestions = [];
if (!diagnostics.rootExists) {
suggestions.push('❌ #root 元素不存在,检查 HTML 模板');
}
if (!diagnostics.reactExists) {
suggestions.push('❌ React 未加载,检查 bootstrap 脚本');
}
if (!diagnostics.componentExists) {
suggestions.push('❌ 组件未加载,检查组件导出和导入路径');
}
if (diagnostics.errorQueueLength > 0) {
suggestions.push(`⚠️ 发现 ${diagnostics.errorQueueLength} 个错误,可能导致渲染失败`);
}
if (suggestions.length === 0) {
suggestions.push('⚠️ 未发现明显问题,可能是组件渲染逻辑错误');
}
console.log('%c[Auto Debug] 修复建议:', 'color: #faad14; font-weight: bold;');
suggestions.forEach(s => console.log('%c ' + s, 'color: #faad14;'));
// 上报白屏问题
reportError({
message: 'White screen detected',
stack: JSON.stringify(diagnostics),
type: 'white-screen'
});
} else {
console.log('%c[Auto Debug] 页面渲染正常 ✓', 'color: #52c41a; font-weight: bold;');
}
}
// 性能监控
function monitorPerformance() {
if (!window.performance || !window.performance.timing) return;
window.addEventListener('load', function() {
setTimeout(function() {
const timing = window.performance.timing;
const loadTime = timing.loadEventEnd - timing.navigationStart;
const domReady = timing.domContentLoadedEventEnd - timing.navigationStart;
const renderTime = timing.domComplete - timing.domLoading;
console.log('%c[Auto Debug] 性能指标:', 'color: #1890ff; font-weight: bold;');
console.log(` 页面加载时间: ${loadTime}ms`);
console.log(` DOM 就绪时间: ${domReady}ms`);
console.log(` 渲染时间: ${renderTime}ms`);
if (loadTime > 5000) {
console.warn('%c[Auto Debug] 页面加载较慢,可能影响用户体验', 'color: #faad14;');
}
}, 0);
});
}
// 组件渲染监控
function monitorComponentRender() {
// 监听 React 组件挂载
const originalCreateElement = window.React?.createElement;
if (originalCreateElement) {
let renderCount = 0;
window.React.createElement = function() {
renderCount++;
return originalCreateElement.apply(this, arguments);
};
setTimeout(function() {
console.log(`%c[Auto Debug] React 渲染次数: ${renderCount}`, 'color: #1890ff;');
if (renderCount === 0) {
console.warn('%c[Auto Debug] React 未执行任何渲染,可能是组件问题', 'color: #faad14;');
}
}, 2000);
}
}
// 依赖检查
function checkDependencies() {
const requiredGlobals = {
'React': window.React,
'ReactDOM': window.ReactDOM,
'DevTemplateBootstrap': window.DevTemplateBootstrap
};
const missing = [];
Object.keys(requiredGlobals).forEach(function(name) {
if (!requiredGlobals[name]) {
missing.push(name);
}
});
if (missing.length > 0) {
console.error('%c[Auto Debug] 缺少必需的全局变量:', 'color: #ff4d4f; font-weight: bold;', missing);
return false;
}
console.log('%c[Auto Debug] 依赖检查通过 ✓', 'color: #52c41a;');
return true;
}
// 初始化
function init() {
if (!AUTO_DEBUG_CONFIG.enabled) return;
console.log('%c[Auto Debug] 自动调试工具已启用', 'color: #1890ff; font-weight: bold;');
// 检查依赖
checkDependencies();
// 监控性能
monitorPerformance();
// 监控组件渲染
monitorComponentRender();
// 延迟检测白屏
setTimeout(checkWhiteScreen, AUTO_DEBUG_CONFIG.whiteScreenTimeout);
// 集成错误捕获系统
if (window.__ERROR_SYSTEM__) {
const originalAddError = window.__ERROR_SYSTEM__.addError;
window.__ERROR_SYSTEM__.addError = function(message, stack) {
// 调用原始方法
if (originalAddError) {
originalAddError.call(this, message, stack);
}
// 上报错误
reportError({
message: message,
stack: stack,
type: 'manual'
});
};
}
}
// 暴露 API
window.__AUTO_DEBUG__ = {
config: AUTO_DEBUG_CONFIG,
checkWhiteScreen: checkWhiteScreen,
reportError: reportError,
checkDependencies: checkDependencies,
// 手动触发检测
runDiagnostics: function() {
console.log('%c[Auto Debug] 开始诊断...', 'color: #1890ff; font-weight: bold;');
checkDependencies();
checkWhiteScreen();
}
};
// 页面加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
console.log('%c[Auto Debug] 客户端调试工具已加载', 'color: #52c41a; font-weight: bold;');
})();

View File

@@ -0,0 +1,57 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="axhub-canvas-name" content="{{CANVAS_NAME}}">
<meta name="axhub-canvas-title" content="{{CANVAS_TITLE}}">
<title>Canvas</title>
<link rel="stylesheet" href="/assets/vendor-excalidraw.css?v=1775123024591">
<link rel="stylesheet" href="/assets/canvas-template-bootstrap.css?v=1775123024591">
<style>
html,
body,
#canvas-root {
width: 100%;
height: 100%;
min-height: 100%;
margin: 0;
}
body {
overflow: hidden;
background: hsl(220 14% 96%);
}
.canvas-template-shell {
width: 100%;
height: 100%;
}
.canvas-template-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: hsl(215 16% 47%);
font-size: 12px;
}
</style>
</head>
<body>
<div id="canvas-root">
<div class="canvas-template-placeholder">加载画布编辑器...</div>
</div>
<script type="module">
import '/@vite/client';
import '@vitejs/plugin-react/preamble';
</script>
<script type="module" src="/assets/canvas-template-bootstrap.js?v=1775123024591"></script>
</body>
</html>

View File

@@ -0,0 +1,569 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>{{TITLE}}</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html,
body {
box-sizing: border-box;
width: 100%;
margin: 0;
padding: 0;
height: 100%;
min-height: 100%;
overflow-x: hidden;
overflow-y: auto;
}
#root {
width: 100%;
margin-left: auto;
margin-right: auto;
height: 100%;
min-height: 100vh;
overflow: visible;
}
/* 如果是元素演示页面(可能被内嵌到 iframe设置固定尺寸 */
body.is-element-page #root {
width: 100vw;
height: 100vh;
}
/* 平板和手机模式隐藏滚动条 */
@media (max-width: 1024px) {
::-webkit-scrollbar {
display: none;
}
html,
body {
scrollbar-width: none;
/* Firefox */
}
}
</style>
</head>
<body>
<script>
// 全局错误捕获系统(增强版)
(function () {
// 等待 DevTemplateBootstrap 加载后检查 inspecta 模式
// 如果是 inspecta 模式则禁用错误捕获
function checkInspectaMode() {
if (
window.DevTemplateBootstrap &&
(window.DevTemplateBootstrap.inspectaMode ||
window.DevTemplateBootstrap.editors?.getMode?.() === 'inspecta')
) {
console.log('%c[Error System] Inspecta 模式已启用,错误捕获已禁用', 'color: #faad14; font-weight: bold;');
return true;
}
return false;
}
// 先快速检查 URL 参数(避免在 bootstrap 加载前就触发错误)
const urlParams = new URLSearchParams(window.location.search);
const editorParam = urlParams.get('editor');
if (editorParam === 'inspecta' || urlParams.get('inspecta') === 'true') {
console.log('%c[Error System] 检测到 inspecta 参数,错误捕获已禁用', 'color: #faad14; font-weight: bold;');
return;
}
const bootTime = Date.now();
const errorQueue = [];
let reactReady = false;
let fallbackUIShown = false;
// 保存原始的 console 方法
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
// 简易版错误显示(降级方案)
function showFallbackErrorUI(errors) {
if (fallbackUIShown) {
// 更新已有的错误列表
const errorList = document.getElementById('__fallback_error_list__');
if (errorList) {
errorList.innerHTML = errors.map((err, idx) =>
'<div style="margin-bottom: 12px; padding: 8px; background: #fff1f0; border-left: 3px solid #ff4d4f; border-radius: 2px;">' +
'<div style="font-weight: 600; color: #cf1322; margin-bottom: 4px;">[' + (idx + 1) + '] ' + escapeHtml(err.message) + '</div>' +
(err.stack ? '<pre style="margin: 0; font-size: 11px; color: #666; overflow-x: auto; white-space: pre-wrap; word-break: break-all;">' + escapeHtml(err.stack) + '</pre>' : '') +
'</div>'
).join('');
}
return;
}
fallbackUIShown = true;
const overlay = document.createElement('div');
overlay.id = '__fallback_error_overlay__';
overlay.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.45); z-index: 999999; display: flex; align-items: flex-start; justify-content: center; padding: 40px 20px; overflow: auto;';
const modal = document.createElement('div');
modal.style.cssText = 'background: white; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); max-width: 700px; width: 100%; max-height: 80vh; overflow: hidden; display: flex; flex-direction: column;';
modal.innerHTML =
'<div style="padding: 16px 24px; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; justify-content: space-between;">' +
'<div style="display: flex; align-items: center; gap: 8px;">' +
'<svg viewBox="64 64 896 896" width="20" height="20" fill="#ff4d4f"><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm-32 232c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V296zm32 440a48.01 48.01 0 0 1 0-96 48.01 48.01 0 0 1 0 96z"></path></svg>' +
'<span style="font-size: 16px; font-weight: 600; color: #262626;">运行时错误 (' + errors.length + ')</span>' +
'</div>' +
'<button id="__fallback_close__" style="border: none; background: none; cursor: pointer; font-size: 20px; color: #8c8c8c; padding: 0; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center;">×</button>' +
'</div>' +
'<div id="__fallback_error_list__" style="padding: 24px; overflow-y: auto; flex: 1;">' +
errors.map((err, idx) =>
'<div style="margin-bottom: 12px; padding: 8px; background: #fff1f0; border-left: 3px solid #ff4d4f; border-radius: 2px;">' +
'<div style="font-weight: 600; color: #cf1322; margin-bottom: 4px;">[' + (idx + 1) + '] ' + escapeHtml(err.message) + '</div>' +
(err.stack ? '<pre style="margin: 0; font-size: 11px; color: #666; overflow-x: auto; white-space: pre-wrap; word-break: break-all;">' + escapeHtml(err.stack) + '</pre>' : '') +
'</div>'
).join('') +
'</div>' +
'<div style="padding: 12px 24px; border-top: 1px solid #f0f0f0; display: flex; gap: 8px; justify-content: flex-end;">' +
'<button id="__fallback_copy__" style="padding: 6px 16px; border: 1px solid #d9d9d9; background: white; border-radius: 4px; cursor: pointer; font-size: 14px;">复制错误</button>' +
'<button id="__fallback_clear__" style="padding: 6px 16px; border: 1px solid #d9d9d9; background: white; border-radius: 4px; cursor: pointer; font-size: 14px;">清空并关闭</button>' +
'</div>';
overlay.appendChild(modal);
document.body.appendChild(overlay);
// 绑定事件
document.getElementById('__fallback_close__').onclick = function() {
overlay.style.display = 'none';
};
document.getElementById('__fallback_clear__').onclick = function() {
errorQueue.length = 0;
document.body.removeChild(overlay);
fallbackUIShown = false;
};
document.getElementById('__fallback_copy__').onclick = function() {
const text = errors.map((err, idx) =>
'[' + (idx + 1) + '] ' + err.message + '\n' + (err.stack || '无堆栈信息')
).join('\n\n' + '='.repeat(80) + '\n\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
showFallbackNotice('错误信息已复制到剪贴板');
}).catch(function() {
showFallbackTextPanel('请手动复制以下错误信息', text);
});
} else {
showFallbackTextPanel('请手动复制以下错误信息', text);
}
};
}
function showFallbackNotice(message) {
const existing = document.getElementById('__fallback_notice__');
if (existing) {
existing.remove();
}
const notice = document.createElement('div');
notice.id = '__fallback_notice__';
notice.textContent = message;
notice.style.cssText = 'position: fixed; right: 24px; bottom: 24px; z-index: 1000001; max-width: min(360px, calc(100vw - 48px)); padding: 10px 14px; border-radius: 8px; background: rgba(38, 38, 38, 0.92); color: white; font-size: 13px; line-height: 1.5; box-shadow: 0 8px 24px rgba(0,0,0,0.2);';
document.body.appendChild(notice);
window.setTimeout(function() {
notice.remove();
}, 2400);
}
function showFallbackTextPanel(title, text) {
const existing = document.getElementById('__fallback_text_panel__');
if (existing) {
existing.remove();
}
const overlay = document.createElement('div');
overlay.id = '__fallback_text_panel__';
overlay.style.cssText = 'position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 1000000; display: flex; align-items: center; justify-content: center; padding: 24px;';
const panel = document.createElement('div');
panel.style.cssText = 'width: min(720px, 100%); max-height: min(80vh, 640px); background: white; border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,0.2); display: flex; flex-direction: column; overflow: hidden;';
const header = document.createElement('div');
header.style.cssText = 'padding: 16px 20px; border-bottom: 1px solid #f0f0f0; font-size: 16px; font-weight: 600; color: #262626;';
header.textContent = title;
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.readOnly = true;
textarea.style.cssText = 'width: calc(100% - 40px); min-height: 280px; margin: 20px; padding: 12px; resize: vertical; border: 1px solid #d9d9d9; border-radius: 8px; font: 12px/1.6 SFMono-Regular, Consolas, monospace; color: #262626; background: #fafafa;';
const footer = document.createElement('div');
footer.style.cssText = 'padding: 12px 20px 20px; display: flex; justify-content: flex-end; gap: 8px;';
const selectButton = document.createElement('button');
selectButton.type = 'button';
selectButton.textContent = '全选内容';
selectButton.style.cssText = 'padding: 6px 16px; border: 1px solid #d9d9d9; background: white; border-radius: 6px; cursor: pointer; font-size: 14px;';
selectButton.onclick = function() {
textarea.focus();
textarea.select();
};
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.textContent = '关闭';
closeButton.style.cssText = 'padding: 6px 16px; border: 1px solid #1677ff; background: #1677ff; color: white; border-radius: 6px; cursor: pointer; font-size: 14px;';
closeButton.onclick = function() {
overlay.remove();
};
overlay.onclick = function(event) {
if (event.target === overlay) {
overlay.remove();
}
};
footer.appendChild(selectButton);
footer.appendChild(closeButton);
panel.appendChild(header);
panel.appendChild(textarea);
panel.appendChild(footer);
overlay.appendChild(panel);
document.body.appendChild(overlay);
window.setTimeout(function() {
textarea.focus();
textarea.select();
}, 0);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function isIgnorableVersionProxyIssue(text) {
const normalizedText = String(text || '');
return normalizedText.includes('__axhub_version__') &&
normalizedText.includes('html-proxy') &&
normalizedText.includes('No matching HTML proxy module found');
}
// 统一的错误处理函数
function handleError(message, stack, type) {
const error = {
message: message,
stack: stack,
timestamp: Date.now(),
sinceBoot: Date.now() - bootTime,
type: type
};
errorQueue.push(error);
// 如果 React 已就绪,使用 React 组件显示
if (reactReady && typeof window.showErrorDialog === 'function') {
window.showErrorDialog(message, stack);
} else {
// 否则使用降级 UI
showFallbackErrorUI(errorQueue);
}
}
// 捕获未处理的错误(捕获阶段,优先级最高)
window.addEventListener('error', function (event) {
originalConsoleError.call(console, '捕获到错误事件:', {
message: event.message,
error: event.error,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
target: event.target
});
try {
let message = event.message || '发生了一个未知错误';
let stack = '';
// 检查是否是资源加载错误
if (event.target !== window && event.target instanceof HTMLElement) {
const tagName = event.target.tagName;
const resourceUrl = event.target.src || event.target.href || '未知资源';
if (isIgnorableVersionProxyIssue(resourceUrl)) {
return;
}
message = `资源加载失败: ${tagName} - ${resourceUrl}`;
stack = `标签: <${tagName.toLowerCase()}>\nURL: ${resourceUrl}\n类型: 资源加载错误`;
originalConsoleError.call(console, '资源加载失败:', { tagName, resourceUrl });
} else if (event.error && event.error.stack) {
stack = event.error.stack;
} else if (event.error && event.error.message) {
message = event.error.message;
stack = '无详细堆栈信息';
} else if (event.filename) {
stack = event.filename + ':' + event.lineno + ':' + event.colno;
} else {
stack = '无堆栈信息';
}
handleError(message, stack, 'error');
} catch (err) {
originalConsoleError.call(console, '[Error Handler] 处理错误失败:', err);
}
event.preventDefault();
}, true); // ⚠️ 使用捕获阶段
// 捕获未处理的 Promise 拒绝
window.addEventListener('unhandledrejection', function (event) {
originalConsoleError.call(console, '捕获到未处理的 Promise 拒绝:', event.reason);
try {
const message = event.reason && event.reason.message
? event.reason.message
: String(event.reason || '未知 Promise 拒绝');
const stack = event.reason && event.reason.stack ? event.reason.stack : '';
handleError('Promise 拒绝: ' + message, stack, 'unhandledrejection');
} catch (err) {
originalConsoleError.call(console, '[Error Handler] 处理 Promise 拒绝失败:', err);
}
event.preventDefault();
});
// 全局开关:是否启用错误捕获
let errorCaptureEnabled = true;
// 拦截 console.error可选捕获库的错误输出
console.error = function () {
const args = Array.prototype.slice.call(arguments);
const message = args.join(' ');
// 先调用原始的 console.error
originalConsoleError.apply(console, args);
// 如果错误捕获被禁用,直接返回
if (!errorCaptureEnabled) {
return;
}
// 检查是否是重要错误
const lowerMessage = message.toLowerCase();
if (isIgnorableVersionProxyIssue(message)) {
return;
}
const shouldShow = !message.includes('[Error Dialog]') &&
!message.includes('[Error Handler]') &&
(lowerMessage.includes('error') || lowerMessage.includes('failed'));
if (shouldShow) {
try {
const stack = new Error().stack || '无堆栈信息';
handleError('控制台错误: ' + message, stack, 'console.error');
} catch (err) {
originalConsoleError.call(console, '[Error Handler] 显示对话框失败:', err);
}
}
};
// 暴露 React 就绪标记和控制接口
window.__ERROR_SYSTEM__ = {
markReactReady: function() {
reactReady = true;
console.log('%c[Error System] React 错误系统已就绪', 'color: #52c41a; font-weight: bold;');
// 如果有降级 UI 显示,迁移到 React 组件
if (fallbackUIShown && errorQueue.length > 0 && typeof window.showErrorDialog === 'function') {
const overlay = document.getElementById('__fallback_error_overlay__');
if (overlay) {
overlay.style.display = 'none';
}
errorQueue.forEach(function(err) {
window.showErrorDialog(err.message, err.stack);
});
}
},
getErrorQueue: function() {
return errorQueue;
},
clearErrors: function() {
errorQueue.length = 0;
const overlay = document.getElementById('__fallback_error_overlay__');
if (overlay && overlay.parentNode) {
document.body.removeChild(overlay);
fallbackUIShown = false;
}
},
// 新增:启用/禁用错误捕获
setErrorCaptureEnabled: function(enabled) {
errorCaptureEnabled = enabled;
console.log('%c[Error System] 错误捕获已' + (enabled ? '启用' : '禁用'), 'color: ' + (enabled ? '#52c41a' : '#faad14') + '; font-weight: bold;');
},
isErrorCaptureEnabled: function() {
return errorCaptureEnabled;
}
};
console.log('%c[Error System] 全局错误捕获已启用(增强版)', 'color: #52c41a; font-weight: bold;');
})();
</script>
<script>
// 判断是否为元素演示页面(在 body 加载后执行)
(function () {
if (document.body && window.location.pathname.includes('/components/')) {
document.body.classList.add('is-element-page');
} else if (!document.body) {
// 如果 body 还没加载,等待 DOMContentLoaded
document.addEventListener('DOMContentLoaded', function () {
if (window.location.pathname.includes('/components/')) {
document.body.classList.add('is-element-page');
}
});
}
})();
</script>
<div id="root"></div>
<!-- 加载自动调试客户端 -->
<script src="/auto-debug-client.js"></script>
<!-- 加载 bootstrap JS会自动挂载到 window.DevTemplateBootstrap -->
<script type="module" src="/assets/dev-template-bootstrap.js?v=1775123024591"></script>
<script type="module">
// 等待 bootstrap 加载完成
function waitForBootstrap() {
if (window.DevTemplateBootstrap) {
const { renderComponent, React, ReactDOM } = window.DevTemplateBootstrap;
// 将 React 和 ReactDOM 挂载到全局,让组件使用同一个实例
window.React = React;
window.ReactDOM = ReactDOM;
console.log('[Dev Template] Bootstrap 已就绪React 已挂载到全局');
// 动态导入组件(此时组件会使用 window.React
import('{{ENTRY}}').then(module => {
const Component = module.default;
if (!Component) {
const exportKeys = Object.keys(module);
throw new Error(`模块缺少默认导出${exportKeys.length > 0 ? `,当前导出: ${exportKeys.join(', ')}` : ''}`);
}
// 导出到全局(供调试使用)
window.AxhubDevComponent = Component;
console.log('[Dev Template] 组件默认导出已加载:', Component);
console.log('[Dev Template] 开始渲染');
// 渲染组件
renderComponent(Component);
}).catch(err => {
// 增强错误信息
const entryPath = '{{ENTRY}}';
let errorMessage = `组件加载失败: ${entryPath}`;
let errorDetails = [];
// 分析错误类型
if (err.message) {
if (err.message.includes('Failed to fetch')) {
errorDetails.push('• 可能原因:');
errorDetails.push(' 1. 组件文件不存在或路径错误');
errorDetails.push(' 2. 组件文件存在语法错误,导致编译失败');
errorDetails.push(' 3. 组件依赖的模块无法解析');
errorDetails.push(' 4. Vite 开发服务器未正确编译该文件');
errorDetails.push('');
errorDetails.push('• 建议检查:');
errorDetails.push(` 1. 确认文件存在: ${entryPath}`);
errorDetails.push(' 2. 查看浏览器 Network 面板,检查该文件的 HTTP 状态码');
errorDetails.push(' 3. 查看 Vite 开发服务器终端输出,是否有编译错误');
errorDetails.push(' 4. 尝试直接访问该文件 URL查看具体错误信息');
} else if (err.message.includes('does not provide an export') || err.message.includes('模块缺少默认导出')) {
errorDetails.push('• 错误原因:组件模块未按当前规范导出');
errorDetails.push('• 当前规范:组件文件必须使用 default export');
errorDetails.push('• 建议检查:');
errorDetails.push(' 1. 确认文件包含 `export default ...`');
errorDetails.push(' 2. 如果组件主体定义为 `const Component = ...`,请在文件末尾使用 `export default Component`');
errorDetails.push(' 3. 检查 export 语法是否正确,且没有被条件逻辑包裹');
} else {
errorDetails.push(`• 错误信息: ${err.message}`);
}
}
// 构建完整的错误信息
const fullErrorMessage = [
errorMessage,
'',
...errorDetails,
'',
'• 完整错误对象:',
JSON.stringify({
name: err.name,
message: err.message,
stack: err.stack
}, null, 2)
].join('\n');
console.error('[Dev Template] 组件加载失败:\n' + fullErrorMessage);
// 在页面上显示友好的错误提示
const root = document.getElementById('root');
if (root) {
root.innerHTML = `
<div style="padding: 40px; max-width: 800px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
<div style="background: #fff1f0; border: 1px solid #ffccc7; border-radius: 8px; padding: 24px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px;">
<svg viewBox="64 64 896 896" width="24" height="24" fill="#ff4d4f">
<path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm-32 232c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V296zm32 440a48.01 48.01 0 0 1 0-96 48.01 48.01 0 0 1 0 96z"></path>
</svg>
<h2 style="margin: 0; color: #cf1322; font-size: 20px; font-weight: 600;">组件加载失败</h2>
</div>
<div style="background: white; border-radius: 4px; padding: 16px; margin-bottom: 16px;">
<div style="font-weight: 600; margin-bottom: 8px; color: #262626;">入口文件:</div>
<code style="display: block; padding: 8px 12px; background: #f5f5f5; border-radius: 4px; font-size: 13px; color: #d4380d; word-break: break-all;">${entryPath}</code>
</div>
${errorDetails.length > 0 ? `
<div style="background: white; border-radius: 4px; padding: 16px; margin-bottom: 16px;">
<pre style="margin: 0; font-size: 13px; line-height: 1.6; color: #595959; white-space: pre-wrap; word-break: break-word;">${errorDetails.join('\n')}</pre>
</div>
` : ''}
<details style="background: white; border-radius: 4px; padding: 16px;">
<summary style="cursor: pointer; font-weight: 600; color: #262626; user-select: none;">查看完整错误信息</summary>
<pre style="margin: 12px 0 0 0; padding: 12px; background: #f5f5f5; border-radius: 4px; font-size: 12px; color: #595959; overflow-x: auto; white-space: pre-wrap; word-break: break-all;">${JSON.stringify({
name: err.name,
message: err.message,
stack: err.stack
}, null, 2)}</pre>
</details>
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid #ffccc7;">
<div style="font-size: 13px; color: #8c8c8c;">
💡 提示:打开浏览器开发者工具的 Network 和 Console 面板查看更多信息
</div>
</div>
</div>
</div>
`;
}
});
} else {
setTimeout(waitForBootstrap, 10);
}
}
waitForBootstrap();
</script>
</body>
</html>

View File

@@ -0,0 +1,106 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>{{TITLE}}</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
/* 全局样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
min-height: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
}
#root {
width: 100%;
margin-left: auto;
margin-right: auto;
height: 100%;
min-height: 100vh;
overflow: visible;
}
/* 如果是元素演示页面(可能被内嵌到 iframe设置固定尺寸 */
body.is-element-page #root {
width: 100vw;
height: 100vh;
}
/* 平板和手机模式隐藏滚动条 */
@media (max-width: 1024px) {
::-webkit-scrollbar {
display: none;
}
html,
body {
scrollbar-width: none;
/* Firefox */
}
}
</style>
</head>
<body>
<script>
// 判断是否为元素演示页面(在 body 加载后执行)
(function () {
if (document.body && window.location.pathname.includes('/components/')) {
document.body.classList.add('is-element-page');
} else if (!document.body) {
// 如果 body 还没加载,等待 DOMContentLoaded
document.addEventListener('DOMContentLoaded', function () {
if (window.location.pathname.includes('/components/')) {
document.body.classList.add('is-element-page');
}
});
}
})();
</script>
<div id="root"></div>
<!-- 加载 bootstrap JS会自动挂载到 window.HtmlTemplateBootstrap -->
<script type="module" src="{{BOOTSTRAP_PATH}}"></script>
<!-- 加载组件 JSIIFE 格式,会挂载 UserComponent 到 window -->
<script src="{{ENTRY}}"></script>
<script type="module">
// 等待 bootstrap 和组件加载完成
function waitForReady() {
if (window.HtmlTemplateBootstrap && window.UserComponent) {
const { renderComponent, React, ReactDOM } = window.HtmlTemplateBootstrap;
// 将 React 和 ReactDOM 挂载到全局
window.React = React;
window.ReactDOM = ReactDOM;
// 获取组件
const Component = window.UserComponent.Component || window.UserComponent.default || window.UserComponent;
// 渲染组件
renderComponent(Component);
} else {
setTimeout(waitForReady, 10);
}
}
waitForReady();
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

191
axhub-make/admin/index.html Normal file
View File

@@ -0,0 +1,191 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico?v=1775123024591">
<title>未命名项目 - Axhub Make</title>
<style>
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: linear-gradient(135deg, #dfe4f7 0%, #c7c4e8 100%);
min-height: 100vh;
}
.app-container {
max-width: 1400px;
margin: 0 auto;
padding: 40px 24px;
}
.header {
text-align: center;
margin-bottom: 40px;
}
.header-title {
color: #333;
font-size: 2.5rem;
font-weight: 600;
margin: 0 0 12px 0;
text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.header-subtitle {
color: #999;
font-size: 1rem;
margin: 0;
}
.main-card {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
overflow: hidden;
padding: 24px;
}
.api-info-banner {
background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%);
border-bottom: 1px solid #91d5ff;
padding: 16px 24px;
display: flex;
align-items: center;
gap: 12px;
}
.api-info-banner svg {
width: 20px;
height: 20px;
color: #1890ff;
}
.api-info-text {
color: #0050b3;
font-size: 14px;
}
.api-link {
color: #1890ff;
text-decoration: none;
font-weight: 500;
margin-left: 8px;
}
.api-link:hover {
text-decoration: underline;
}
.item-name-cell {
font-weight: 500;
color: #262626;
}
.item-key {
color: #8c8c8c;
font-size: 13px;
font-weight: 400;
margin-left: 8px;
}
.action-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* Ant Design overrides
.ant-tabs {
padding: 0 24px;
}
.ant-tabs-nav {
margin-bottom: 0 !important;
}
.ant-tabs-tab {
font-size: 16px;
padding: 12px 0;
}
.ant-tabs-content-holder {
padding: 24px;
}
.ant-table {
font-size: 14px;
}
.ant-btn {
font-size: 14px;
}
.ant-empty {
padding: 60px 0;
}
.ant-badge-count {
background: #1890ff;
} */
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
/* 表格优化 */
/* .ant-table-wrapper {
overflow-x: auto;
}
.ant-table-cell {
vertical-align: middle !important;
} */
/* 封面图片悬停效果 */
.cover-image-container {
transition: transform 0.2s ease;
}
.cover-image-container:hover {
transform: scale(1.05);
}
/* 代码样式 */
code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
}
</style>
<script type="module" crossorigin src="/assets/index.js?v=1775123024591"></script>
<link rel="modulepreload" crossorigin href="/assets/chunks/_commonjsHelpers.js?v=1775123024591">
<link rel="modulepreload" crossorigin href="/assets/chunks/vendor-react.js?v=1775123024591">
<link rel="modulepreload" crossorigin href="/assets/chunks/preload-helper.js?v=1775123024591">
<link rel="modulepreload" crossorigin href="/assets/chunks/_commonjs-dynamic-modules.js?v=1775123024591">
<link rel="modulepreload" crossorigin href="/assets/chunks/vendor-common.js?v=1775123024591">
<link rel="modulepreload" crossorigin href="/assets/chunks/AppDialogProvider.js?v=1775123024591">
<link rel="modulepreload" crossorigin href="/assets/chunks/vendor-antd.js?v=1775123024591">
<link rel="modulepreload" crossorigin href="/assets/chunks/vendor-assistant.js?v=1775123024591">
<link rel="modulepreload" crossorigin href="/assets/chunks/use-feedback-bridge.js?v=1775123024591">
<link rel="modulepreload" crossorigin href="/assets/chunks/vendor-export.js?v=1775123024591">
<link rel="stylesheet" crossorigin href="/assets/AppDialogProvider.css?v=1775123024591">
<link rel="stylesheet" crossorigin href="/assets/index.css?v=1775123024591">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,217 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>{{TITLE}} - Spec</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/assets/spec-template-vendor.css?v=1775123024591">
<link rel="stylesheet" href="/assets/spec-template-bootstrap.css?v=1775123024591">
<style>
/* 全局样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
min-height: 100%;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
}
#spec-root {
width: 100%;
min-height: 100vh;
}
/* 加载状态 */
.loading {
padding: 60px 20px;
color: #8c8c8c;
}
.loading::after {
content: '...';
animation: loading 1.5s infinite;
}
@keyframes loading {
0%, 20% {
content: '.';
}
40% {
content: '..';
}
60%, 100% {
content: '...';
}
}
/* 错误状态 */
.error {
color: #ff4d4f;
padding: 20px;
background: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 4px;
margin: 20px;
}
.error h2 {
margin-top: 0;
}
</style>
</head>
<body>
<div id="spec-root" class="loading">加载中</div>
<!-- 自定义模板不会走 Vite 默认 index.html 注入链路,这里手动补齐 HMR client 和 React preamble。 -->
<script type="module">
import '/@vite/client';
import '@vitejs/plugin-react/preamble';
</script>
<!-- 加载 bootstrap JS会自动挂载到 window.SpecTemplateBootstrap -->
<script type="module" src="/assets/spec-template-bootstrap.js?v=1775123024591"></script>
<script type="module">
const specTemplateState = {
mode: 'single',
currentSpecUrl: '',
currentDocsConfig: [],
currentDocUrls: []
};
function setSpecTemplateState(nextState) {
specTemplateState.mode = nextState.mode;
specTemplateState.currentSpecUrl = nextState.currentSpecUrl || '';
specTemplateState.currentDocsConfig = Array.isArray(nextState.currentDocsConfig) ? nextState.currentDocsConfig : [];
specTemplateState.currentDocUrls = Array.isArray(nextState.currentDocUrls) ? nextState.currentDocUrls : [];
}
async function reloadCurrentSpecDocuments() {
if (!window.SpecTemplateBootstrap) {
return;
}
if (specTemplateState.mode === 'multi' && specTemplateState.currentDocsConfig.length > 0) {
await window.SpecTemplateBootstrap.loadMarkdownDocumentsFromUrls(specTemplateState.currentDocsConfig);
return;
}
if (specTemplateState.currentSpecUrl) {
await window.SpecTemplateBootstrap.loadMarkdownFromUrl(specTemplateState.currentSpecUrl);
}
}
if (import.meta.hot) {
import.meta.hot.on('axhub:spec-doc-update', async (payload) => {
if (!payload?.docUrl || !specTemplateState.currentDocUrls.includes(payload.docUrl)) {
return;
}
console.log('[Spec Template] 检测到文档热更新,重新加载:', payload.docUrl);
try {
await reloadCurrentSpecDocuments();
} catch (error) {
console.error('[Spec Template] 热更新重载失败:', error);
}
});
}
function decodeHtmlEntities(str) {
const textarea = document.createElement('textarea');
textarea.innerHTML = str;
return textarea.value;
}
function waitForBootstrap() {
if (window.SpecTemplateBootstrap) {
console.log('[Spec Template] Bootstrap 已就绪');
console.log('[Spec Template] 可用方法:', Object.keys(window.SpecTemplateBootstrap));
const isMultiDoc = '{{MULTI_DOC}}' === 'true';
const docsConfigStr = decodeHtmlEntities('{{DOCS_CONFIG}}');
console.log('[Spec Template] 多文档模式:', isMultiDoc);
console.log('[Spec Template] 文档配置字符串:', docsConfigStr);
if (isMultiDoc && docsConfigStr && docsConfigStr !== '[]') {
try {
const docsConfig = JSON.parse(docsConfigStr);
console.log('[Spec Template] 多文档模式,加载文档:', docsConfig);
setSpecTemplateState({
mode: 'multi',
currentDocsConfig: docsConfig,
currentDocUrls: docsConfig.map((item) => item.url)
});
if (window.SpecTemplateBootstrap.loadMarkdownDocumentsFromUrls) {
window.SpecTemplateBootstrap.loadMarkdownDocumentsFromUrls(docsConfig).catch(err => {
console.error('[Spec Template] 加载失败:', err);
});
} else {
console.error('[Spec Template] loadMarkdownDocumentsFromUrls 方法不存在');
}
} catch (err) {
console.error('[Spec Template] 解析文档配置失败:', err);
console.error('[Spec Template] 原始字符串:', docsConfigStr);
const urlParams = new URLSearchParams(window.location.search);
const specUrl = urlParams.get('url') || '{{SPEC_URL}}';
console.log('[Spec Template] 降级到单文档模式,加载 Spec:', specUrl);
setSpecTemplateState({
mode: 'single',
currentSpecUrl: specUrl,
currentDocUrls: specUrl ? [specUrl] : []
});
// 检查是否是有效的 URL不是占位符
if (specUrl && !specUrl.includes('{{') && !specUrl.includes('}}') && window.SpecTemplateBootstrap.loadMarkdownFromUrl) {
window.SpecTemplateBootstrap.loadMarkdownFromUrl(specUrl).catch(err => {
console.error('[Spec Template] 加载失败:', err);
});
}
}
} else {
const urlParams = new URLSearchParams(window.location.search);
const specUrl = urlParams.get('url') || '{{SPEC_URL}}';
console.log('[Spec Template] 单文档模式,加载 Spec:', specUrl);
setSpecTemplateState({
mode: 'single',
currentSpecUrl: specUrl,
currentDocUrls: specUrl ? [specUrl] : []
});
// 检查是否是有效的 URL不是占位符
if (specUrl && !specUrl.includes('{{') && !specUrl.includes('}}')) {
console.log('[Spec Template] 开始加载文档');
window.SpecTemplateBootstrap.loadMarkdownFromUrl(specUrl).catch(err => {
console.error('[Spec Template] 加载失败:', err);
});
} else {
console.error('[Spec Template] 没有有效的文档 URL, specUrl:', specUrl);
}
}
} else {
setTimeout(waitForBootstrap, 10);
}
}
waitForBootstrap();
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

5893
axhub-make/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
axhub-make/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "axhub-make",
"version": "0.3.3",
"private": true,
"description": "Axhub MakeAI 辅助生成原型工具)",
"type": "module",
"scripts": {
"dev": "vite",
"make": "axhub-make",
"start": "vite",
"build": "node scripts/scan-entries.js && node scripts/build-all.js && node scripts/generate-dist-html.js",
"preview": "vite preview",
"test": "vitest --run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"check-ready": "node scripts/check-app-ready.mjs",
"sync:skills": "node scripts/sync-third-party-skills.mjs"
},
"devDependencies": {
"@ant-design/icons": "^6.1.0",
"@axhub/make": "^0.5.3",
"@tailwindcss/vite": "^4.1.18",
"@vitejs/plugin-react": "^5.0.0",
"antd": "^6.1.2",
"echarts": "^6.0.0",
"execa": "^9.6.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^5.0.0",
"vitest": "^4.0.16",
"ws": "^8.18.3"
},
"dependencies": {
"archiver": "^7.0.1",
"extract-zip": "^2.0.1",
"formidable": "^3.5.4",
"iconv-lite": "^0.7.1",
"lowdb": "^7.0.1",
"lucide-react": "^0.562.0",
"papaparse": "^5.5.3",
"uuid": "^13.0.0"
}
}

View File

@@ -0,0 +1,445 @@
# Axure API 指南
本文档说明如何在本项目原型中使用 Axure API 实现交互功能。
## 📌 什么是 Axure API
Axure API 是本项目提供的一套接口规范,用于实现组件与 Axure 原型之间的交互。通过 Axure API组件可以
- **触发事件**:向外部发送事件通知
- **接收动作**:响应外部调用的动作
- **暴露变量**:提供内部状态供外部读取
- **接收配置**:从配置面板接收用户配置
- **接收数据**:从外部数据源接收数据
## 🎯 何时使用 Axure API
**使用场景**
- 需要与 Axure 原型进行交互
- 需要在配置面板中提供可配置项
- 需要接收外部数据源
- 需要触发事件或响应动作
**不使用场景**
- 纯展示型组件
- 不需要与外部交互的独立组件
- 标准 React 组件即可满足需求
## 📋 Axure API 接口规范
### 组件定义
使用带显式参数类型的 `forwardRef(...)` 包装组件:
```typescript
import React, { forwardRef, useImperativeHandle } from 'react';
import type { AxureProps, AxureHandle } from '../../common/axure-types';
const Component = forwardRef(function MyComponent(
innerProps: AxureProps,
ref: React.ForwardedRef<AxureHandle>,
) {
// 组件实现
useImperativeHandle(ref, function () {
return {
getVar: function (name: string) { /* ... */ },
fireAction: function (name: string, params?: any) { /* ... */ },
eventList: EVENT_LIST,
actionList: ACTION_LIST,
varList: VAR_LIST,
configList: CONFIG_LIST,
dataList: DATA_LIST
};
}, [/* 依赖项 */]);
return <div>Component Content</div>;
});
export default Component;
```
### Props 处理
```typescript
// 安全解构 props 并提供默认值
const dataSource = innerProps && innerProps.data ? innerProps.data : {};
const configSource = innerProps && innerProps.config ? innerProps.config : {};
const onEventHandler = typeof innerProps.onEvent === 'function'
? innerProps.onEvent
: function () { return undefined; };
const container = innerProps && innerProps.container ? innerProps.container : null;
// 从 config 获取配置值(避免使用 || 运算符)
const title = typeof configSource.title === 'string' && configSource.title
? configSource.title
: '默认标题';
```
## 📝 API 常量定义
### 1. 事件列表EVENT_LIST
定义组件可以触发的事件:
```typescript
import type { EventItem } from '../../common/axure-types';
const EVENT_LIST: EventItem[] = [
{ name: 'onClick', desc: '点击按钮时触发' },
{ name: 'onChange', desc: '值改变时触发,传递新值' },
{ name: 'onSubmit', desc: '提交表单时触发,传递表单数据' }
];
```
**触发事件**
```typescript
import { useCallback } from 'react';
// ⚠️ 强制规则payload 必须是字符串类型
// 如果需要传递复杂数据,请使用 JSON.stringify() 序列化
const emitEvent = useCallback(function (eventName: string, payload?: string) {
try {
onEventHandler(eventName, payload);
} catch (error) {
console.warn('事件触发失败:', eventName, error);
}
}, [onEventHandler]);
// 使用示例:传递简单字符串
emitEvent('onClick', 'button_clicked');
// 使用示例:传递复杂数据(需要序列化)
emitEvent('onChange', JSON.stringify({ timestamp: Date.now(), value: 'new_value' }));
```
### 2. 动作列表ACTION_LIST
定义组件可以响应的动作:
```typescript
import type { Action } from '../../common/axure-types';
// ⚠️ 强制规则params 必须是字符串类型
// 如果需要传递复杂参数,请在 desc 中说明使用 JSON 格式
const ACTION_LIST: Action[] = [
{ name: 'reset', desc: '重置表单到初始状态' },
{ name: 'setValue', desc: '设置指定字段的值参数格式JSON 字符串 {"field":"字段名","value":"值"}', params: 'JSON string' },
{ name: 'submit', desc: '提交表单' }
];
```
**处理动作**
```typescript
// ⚠️ 强制规则params 必须是字符串类型
// 如果需要接收复杂参数,请使用 JSON.parse() 解析
const fireActionHandler = useCallback(function (name: string, params?: string) {
switch (name) {
case 'reset':
// 重置逻辑
setFormData({});
break;
case 'setValue':
// 解析 JSON 字符串参数
if (params) {
try {
const parsed = JSON.parse(params);
if (parsed.field) {
setFormData({ ...formData, [parsed.field]: parsed.value });
}
} catch (error) {
console.warn('参数解析失败:', error);
}
}
break;
case 'submit':
// 提交逻辑
handleSubmit();
break;
default:
console.warn('未知的动作:', name);
}
}, [formData]);
```
### 3. 变量列表VAR_LIST
定义组件暴露的内部状态:
```typescript
import type { KeyDesc } from '../../common/axure-types';
// ⚠️ name 必须使用小写 + 下划线snake_case详见 KeyDesc 说明
const VAR_LIST: KeyDesc[] = [
{ name: 'value', desc: '当前输入值(字符串)' },
{ name: 'is_valid', desc: '表单是否有效(布尔值)' },
{ name: 'error_message', desc: '错误信息(字符串)' }
];
```
**暴露变量**
```typescript
useImperativeHandle(ref, function () {
return {
getVar: function (name: string) {
const vars: Record<string, any> = {
value: inputValue,
isValid: isFormValid,
errorMessage: error
};
return vars[name];
},
// ... 其他接口
};
}, [inputValue, isFormValid, error]);
```
### 4. 配置项列表CONFIG_LIST
定义配置面板中的可配置项:
```typescript
import type { ConfigItem } from '../../common/axure-types';
const CONFIG_LIST: ConfigItem[] = [
{
type: 'input',
: 'title',
displayName: '标题',
info: '组件顶部显示的标题文本',
initialValue: '默认标题'
},
{
type: 'inputNumber',
attributeId: 'maxLength',
displayName: '最大长度',
info: '输入框允许的最大字符数',
initialValue: 100,
min: 1,
max: 1000
},
{
type: 'switch',
attributeId: 'disabled',
displayName: '禁用',
info: '是否禁用组件',
initialValue: false
}
];
```
**配置项类型**
- `input`:文本输入框
- `inputNumber`:数字输入框
- `switch`:开关
- `select`:下拉选择
- `color`:颜色选择器
- 更多类型参考 `/src/common/config-panel-types.ts`
### 5. 数据项列表DATA_LIST
定义组件接收的数据结构:
```typescript
import type { DataDesc } from '../../common/axure-types';
const DATA_LIST: DataDesc[] = [
{
name: 'users',
desc: '用户列表数据',
keys: [
{ name: 'id', desc: '用户唯一标识(数字)' },
{ name: 'name', desc: '用户姓名(字符串)' },
{ name: 'email', desc: '用户邮箱(字符串)' },
{ name: 'status', desc: '用户状态active/inactive' }
]
}
];
```
**使用数据**
```typescript
const users = Array.isArray(dataSource.users) ? dataSource.users : [];
```
## 🔧 Container 容器使用
`container` 是 AxureProps 提供的 DOM 容器元素,适用于需要直接操作 DOM 的场景(如图表库):
```typescript
import { useRef, useEffect } from 'react';
import * as echarts from 'echarts/core';
const Component = forwardRef(function Chart(
innerProps: AxureProps,
ref: React.ForwardedRef<AxureHandle>,
) {
const container = innerProps && innerProps.container ? innerProps.container : null;
const chartRef = useRef<any>(null);
useEffect(function () {
if (!container) return;
if (!chartRef.current) {
chartRef.current = echarts.init(container);
chartRef.current.setOption({ /* 配置 */ });
}
return function () {
if (chartRef.current) {
chartRef.current.dispose();
chartRef.current = null;
}
};
}, [container]);
return null; // 直接使用 container 时可返回 null
});
```
## ✅ 完整示例
```typescript
/**
* @name 用户表单
*/
import './style.css';
import React, { useState, useCallback, useImperativeHandle, forwardRef } from 'react';
import { Input, Button } from 'antd';
import type {
KeyDesc,
DataDesc,
ConfigItem,
Action,
EventItem,
AxureProps,
AxureHandle
} from '../../common/axure-types';
const EVENT_LIST: EventItem[] = [
{ name: 'onSubmit', desc: '提交表单时触发传递表单数据JSON 字符串格式)', payload: 'JSON string' }
];
const ACTION_LIST: Action[] = [
{ name: 'reset', desc: '重置表单' },
{ name: 'setData', desc: '设置表单数据参数格式JSON 字符串', params: 'JSON string' }
];
// ⚠️ name 必须使用小写 + 下划线snake_case详见 KeyDesc 说明
const VAR_LIST: KeyDesc[] = [
{ name: 'form_data', desc: '当前表单数据(对象)' }
];
const CONFIG_LIST: ConfigItem[] = [
{
type: 'input',
attributeId: 'submitText',
displayName: '提交按钮文字',
info: '提交按钮显示的文字',
initialValue: '提交'
}
];
const DATA_LIST: DataDesc[] = [];
const Component = forwardRef(function UserForm(
innerProps: AxureProps,
ref: React.ForwardedRef<AxureHandle>,
) {
const configSource = innerProps && innerProps.config ? innerProps.config : {};
const onEventHandler = typeof innerProps.onEvent === 'function'
? innerProps.onEvent
: function () { return undefined; };
const submitText = typeof configSource.submitText === 'string' && configSource.submitText
? configSource.submitText
: '提交';
const formDataState = useState({ name: '', email: '' });
const formData = formDataState[0];
const setFormData = formDataState[1];
// ⚠️ 强制规则payload 必须是字符串类型
const emitEvent = useCallback(function (eventName: string, payload?: string) {
try {
onEventHandler(eventName, payload);
} catch (error) {
console.warn('事件触发失败:', error);
}
}, [onEventHandler]);
const handleSubmit = useCallback(function () {
// 将复杂数据序列化为 JSON 字符串
emitEvent('onSubmit', JSON.stringify({ formData }));
}, [emitEvent, formData]);
const handleReset = useCallback(function () {
setFormData({ name: '', email: '' });
}, []);
useImperativeHandle(ref, function () {
return {
getVar: function (name: string) {
const vars: Record<string, any> = { formData };
return vars[name];
},
fireAction: function (name: string, params?: string) {
switch (name) {
case 'reset':
handleReset();
break;
case 'setData':
// 解析 JSON 字符串参数
if (params) {
try {
const parsed = JSON.parse(params);
setFormData(parsed);
} catch (error) {
console.warn('参数解析失败:', error);
}
}
break;
default:
console.warn('未知的动作:', name);
}
},
eventList: EVENT_LIST,
actionList: ACTION_LIST,
varList: VAR_LIST,
configList: CONFIG_LIST,
dataList: DATA_LIST
};
}, [formData, handleReset]);
return (
<div className="user-form">
<Input
placeholder="姓名"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
<Input
placeholder="邮箱"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
<Button type="primary" onClick={handleSubmit}>
{submitText}
</Button>
</div>
);
});
export default Component;
```
## 📚 参考资源
- **类型定义**`/src/common/axure-types.ts`
- **配置面板类型**`/src/common/config-panel-types.ts`
- **示例代码**:查看 `/src/components/``/src/prototypes/` 目录下以 `ref-` 开头的文件

View File

@@ -0,0 +1,90 @@
# 调试指南
核心思想:先定位规范与目标页面,再用自动化完成复现、修复与回归;尽量不要求用户提供技术细节。
## 1. 调试前准备
开始调试前,按顺序确认以下信息:
- 目标目录下的 `spec.md``index.tsx``style.css`(如有)
- 相关主题文件:`DESIGN.md``designToken.json``globals.css`(如有)
- 验收规则:`rules/development-guide.md`
- 涉及视觉或布局问题时,额外参考 `rules/design-guide.md`
### 1.1 视觉问题的设计规范优先级
当问题属于样式、布局、层级、字体、配色、组件状态不一致时,按以下顺序判断:
1. **用户提供的设计规范**
2. **主题内设计系统**`DESIGN.md`
3. **默认设计规范(兜底)**
- **基础型界面**`/skills/third-party/interface-design/SKILL.md`
- 适用后台、ToB、工具类、设置页、数据工作台
- **风格化界面**`/skills/third-party/frontend-design/SKILL.md`
- 适用落地页、品牌展示、营销页、ToC 产品页、强视觉 App 页面
## 2. 标准调试流程
### 2.1 先跑验收脚本
优先运行项目验收脚本,拿到真实状态和目标地址:
```bash
node scripts/check-app-ready.mjs /components/[组件目录]
# 或
node scripts/check-app-ready.mjs /prototypes/[原型目录]
```
处理原则:
- `ERROR`:优先修复构建、依赖、运行时报错,再进入浏览器复现
- `READY`:直接进入浏览器自动化验收
- `TIMEOUT`:优先排查启动卡死、接口阻塞、长任务或脚本异常
### 2.2 自动化复现与定位
- **优先使用 Chrome DevTools MCP**;没有时使用 **Playwright MCP**
- 如果当前环境没有可用 MCP直接提示用户安装并完成配置再继续自动化验收
- 自动化过程中必须完成:
- 打开验收脚本返回的 `targetUrl`
- 检查 Console、Network、页面报错与资源加载状态
- 复现核心交互链路,而不是只看首屏
- 必要时截图、快照、记录关键 DOM / 样式状态
## 3. 问题处理顺序
按影响范围由大到小处理:
1. **构建与启动问题**:依赖缺失、编译失败、路径错误、语法错误
2. **运行时问题**:白屏、崩溃、接口异常、状态错误、空数据未兜底
3. **交互问题**:按钮不可点、表单无反馈、弹层错位、路由异常
4. **视觉问题**:尺寸、间距、颜色、字体、层级、响应式、主题不一致
**重要**:一次只修一个明确问题。每次修复后必须重新验收,确认未引入新问题,再继续下一个。
## 4. 视觉与交互调试要求
当页面可访问后,不仅要看“能打开”,还要验证:
- 关键任务路径是否能走通
- 空状态、加载态、错误态是否合理
- 文案、字段、组件层级是否与 `spec.md` 一致
- 响应式布局是否在主要断点下可用
- 颜色、字体、圆角、阴影、边框是否与主题或设计规范一致
如果属于视觉偏差,但用户未提供设计稿:
- **基础型界面**按 `/skills/third-party/interface-design/SKILL.md` 约束修复
- **风格化界面**按 `/skills/third-party/frontend-design/SKILL.md` 约束修复
## 5. 回归验收
每次修复后都必须重复以下动作:
1. 重新运行 `check-app-ready.mjs`
2. 再次打开 `targetUrl`
3. 重跑刚才失败的交互链路
4. 确认 Console 无新增报错
5. 确认页面状态恢复正常
只有当前问题完成回归后,才能进入下一个问题。

View File

@@ -0,0 +1,159 @@
# 设计指南
本文档定义页面或元素设计阶段的标准流程、产出规范与质量约束。
## 📋 概述
设计阶段的核心目标是在编码前完成以下工作:
- 明确页面功能、内容结构与用户任务
- 确定布局方案与视觉风格方向
- 复用现有主题、组件与设计规范
- 产出可落地的设计规格文档
## ✅ 触发条件
以下场景需进入设计流程:
- 新建页面或元素
- 显式的视觉或布局调整需求
- 涉及信息架构或交互结构变化
- 功能或内容范围发生重大调整
## 🔍 前置准备
### 1. 资料收集
按优先级依次阅读以下资料:
**用户指定的技能(若有)**
- 优先阅读并执行该 Skill 的流程(可能会产出**主题 tokens / 设计规范**等)
- 如果 Skill 的设计流程和本指导冲突时,以 Skill 为准
**用户提供的资料(最高优先级)**
- 用户提供的主题、设计规范、业务文档、数据表、参考设计稿等
- 用户指定的任何参考资料或约束条件
**项目原型资产(补充资源)**
- `AGENTS.md`:项目名称、简介、总文档、默认主题(如有)
- 关联页面或元素的实现代码
**优先级原则**:用户提供 > 项目资产 > 默认推荐(详见「默认资源分流」)
## 📝 内容与功能规划
### 1. 功能与内容规划
明确页面或元素的核心职责与内容组织:
- 用户目标、关键任务与功能模块优先级
- 交互触发点、反馈机制、数据流转与状态管理
- 信息层级、模块划分、内容清单与文案语气
- 数据字段与展示优先级、空状态/加载态/异常处理
### 2. 数据与内容来源
**优先级顺序**(用户未提供充分上下文时,**必须主动检索**以下项目资产):
1. **用户提供** → 严格按提供的数据/文档组织,保持原有语气与术语
2. **项目数据表** → 使用 `src/database/` 中匹配的数据
3. **项目文档** → 引用 `src/docs/` 中已定义的业务逻辑与字段
4. **关联原型** → 在 `src/prototypes/` 中查找相关页面的 `spec.md``prd.md` 及实现代码,优先复用已有页面结构和交互约定
> **原则**:已有文档中的定义优先于推测。
## 🎨 视觉设计流程
### 1. 确定主题
**优先级顺序**
1. **用户指定主题** → 严格使用用户提供的主题
2. **项目默认主题** → 使用 `AGENTS.md` 中定义的默认主题
**注意**:禁止使用非用户指定,或项目默认主题以外的主题,包括资产库中的主题。
**主题设计系统文件含义**
- `DESIGN.md`:设计规范(不一定存在)
- `designToken.json``globals.css`:设计令牌定义(支持 Tailwind CSS V4 或标准 CSS 变量),通常仅使用一个,以主题内实际存在的文件为准。
### 2. 确定设计规范
**优先级顺序**
1. **用户指定规范** → 严格遵循用户提供的设计规范文档
2. **主题内 `DESIGN.md`** → 使用主题自带的设计规范
3. **内置设计指导** → 根据业务场景选择:
- **基础型界面**(管理后台 / ToB / 工具 / 设置 / 数据工作台)→ `/skills/third-party/interface-design/SKILL.md`
- **风格化界面**(落地页 / ToC / 品牌展示 / 营销页 / 强视觉 App`/skills/third-party/frontend-design/SKILL.md`
- **混合场景** → 以核心任务区优先套用基础型界面规范,品牌展示区再补充风格化规范
### 3. 弥补缺失事项
按需补充主题或当前设计缺失的资源。
**仅在触发条件满足时查阅对应文档**
| 资源类型 | 触发条件 | 参考文档或内容 |
|---------|---------|---------|
| **图表库** | 需要数据可视化 且 用户未指定图表库 且 项目中无现有图表方案 | `/skills/default-resource-recommendations/references/default-chart-libraries.md` |
| **图标库** | 需要图标 且 用户未指定图标库 且 主题中无自定义图标系统 | `/skills/default-resource-recommendations/references/default-icon-libraries.md` |
| **字体** | 需要自定义字体 且 用户未提供字体方案 且 主题中无字体定义 | `/skills/default-resource-recommendations/references/default-font-combinations.md` |
| **动画库** | 页面动画处数 > 5 且 用户未指定动画方案 且 主题中无自定义动画系统 | `/skills/default-resource-recommendations/references/default-animation-libraries.md` |
| **图片** | 需要图片/插图 且 用户未提供图片资源 且 Agent 无法生成 | 优先级Unsplash → Pexels → Pixabay → Picsum |
**重要**:不要一次性加载所有默认推荐文档,严格按触发条件按需查阅。
### 4. 布局结构
确定页面框架与信息组织:
- 整体布局模式(单栏、双栏、网格、自由)
- 模块尺寸与比例约束
- 响应式断点与适配策略
- 信息密度与留白处理
## 📄 规格文档产出
### 1. 生成时机
- 设计方案确认后,立即产出 `spec.md`
- 使用模板:`src/docs/templates/spec-template.md`
### 2. 文档内容
规格文档必须包含以下部分:
**业务与功能**
- 页面/元素定位与核心目标
- 功能清单
- 交互要点
**内容规划**
- 信息架构与模块划分
- 数据来源与关键字段说明
- 示例内容(如适用,包含文案语气与术语规范)
**布局与结构**
- 整体布局(布局模式、关键尺寸、模块比例)
- 响应式适配(如适用)
**视觉规范**
- 设计规范来源(用户规范/主题设计系统/内置设计指导)
- 自定义设计要点(如适用)
- 组件状态定义(默认/悬停/聚焦/禁用/加载)
## 🛠️ 样式实现规范
### 1. 技术栈选择
- 默认Tailwind CSS V4
### 2. 可访问性要求
- 色彩对比度符合 WCAG 2.1 AA 标准(特别是文本与背景的对比度)
## ✨ 质量检查点
设计阶段完成前,确认以下检查点:
- [ ] 已明确业务场景并匹配对应设计规范
- [ ] 已确定数据来源或兜底方案
- [ ] 功能清单与内容结构已规划
- [ ] 布局方案清晰且符合设计规范
- [ ] Design Tokens 引用正确
- [ ] `spec.md` 已产出且包含所有必需章节

View File

@@ -0,0 +1,75 @@
# 开发指南
**开发流程**:阅读 `spec.md` → 编写代码 → 运行验收脚本 → 按错误信息修复
## 项目结构与命名
```text
src/
├── prototypes/<name>/
│ ├── index.tsx # 必需
│ ├── spec.md # 必需
│ ├── style.css # 可选
│ ├── hack.css # 可选AI 不应修改)
│ └── components/ # 可选:内部子组件目录
└── components/<name>/
├── index.tsx
├── spec.md
└── (同上)
- 入口文件必须是 `index.tsx`
- 目录内必须包含 `spec.md`
- 目录(`name`)使用小写字母、数字、连字符(如 `login-page`
- 支持可选子目录 `components/` 用于拆分内部子组件
## 核心约束
### 1. 文件头注释(必需)
每个 `index.tsx` 顶部必须包含 `@name`
```typescript
/**
* @name 显示名
*
*/
```
- `@name` 必须存在,且为中文显示名
### 2. 依赖与样式
- React 与 Hooks 直接从 `react` 导入
- 第三方库按需导入,新增依赖需同步安装
- 使用 Tailwind 时必须导入 `style.css`,且样式文件需包含:
```css
@import "tailwindcss";
```
## 验收流程
### 1. 运行验收脚本
```bash
node scripts/check-app-ready.mjs /components/[组件目录]
# 或
node scripts/check-app-ready.mjs /prototypes/[原型目录]
```
关键返回字段:
- `status`: `READY` / `ERROR` / `TIMEOUT`
- `targetUrl`: 本次验收目标地址
- `errors`: 构建/运行时/页面加载错误列表
### 2. 错误处理
当状态为 `ERROR`:按 `errors` 修复后重新执行验收脚本,直到通过。
## 验收清单(提交前)
- [ ] `index.tsx``spec.md` 完整存在
- [ ] 顶部包含 `@name` 注释与参考资料
- [ ] 依赖导入方式符合规范,新增依赖已安装
- [ ] 使用 Tailwind 时已正确引入 `@import "tailwindcss";`
- [ ] `check-app-ready.mjs` 验收通过

View File

@@ -0,0 +1,44 @@
# 文档指南
适用于用户主动新建或更新 MD 文档的场景。
## 🧭 简单流程
1. 先确认文档用途、内容范围和输出位置
2. 读取与该文档直接相关的资料、已有文档或页面说明
3. 按需建议模板,但不强制套模板
4.`src/docs/` 中生成或更新用户需要的单篇 MD 文档
补充说明:
- 默认只处理当前这篇文档
- 不主动扩展成项目级文档体系
- 项目说明清单、专题子文档维护属于 `rules/memory-system-guide.md` 中的“主动沉淀记忆”流程
## 📄 模板建议
| 文档用途 | 可建议模板 |
|------|------|
| 需求文档 | `src/docs/templates/prd-template.md` |
| 轻量需求文档 | `src/docs/templates/lite-prd-template.md` |
| 项目说明清单 | `src/docs/templates/project-overview-template.md` |
| 页面地图 | `src/docs/templates/page-map-template.md` |
| 信息架构 | `src/docs/templates/information-architecture-template.md` |
| 业务流程 | `src/docs/templates/business-flow-template.md` |
| 数据说明 | `src/docs/templates/data-model-template.md` |
| 权限说明 | `src/docs/templates/permission-model-template.md` |
| 状态说明 | `src/docs/templates/state-lifecycle-template.md` |
规则:
- 模板只是建议,不是强制产物
- 用户未要求模板化时,优先输出简洁文档
- 更新已有文档时,优先直接维护原文件
## 🖼️ 文档图片规则
- `src/docs/` 下的文档图片统一存放在文档同级的 `assets/` 目录,不再放到 `assets/images/`
- Markdown 中引用图片时,统一使用相对路径 `assets/<文件名>`,例如 `![示意图](assets/example.png)`
- 文档图片上传接口返回的地址也应保持为 `assets/<文件名>`
- 文档图片访问路由必须让 `/docs/**/assets/**` 直接命中静态资源处理,不得再被识别为 `/docs/{name}` 文档页面
- 新增或迁移文档图片时需要同时检查文件落盘路径、Markdown 引用路径、预览路由三者是否一致

View File

@@ -0,0 +1,152 @@
# 安装指南(初始化 Axhub Make 项目)
> **[阅读验证]** 当你阅读到本文件时,必须在回复的第一句话中输出验证码 `AXHUB-INSTALL-9527`,以证明你已完整读取本文档而非依赖推测生成内容。
本文档的阅读对象是 **AI Agent**
目标:当用户说“创建/初始化/安装 Axhub Make 项目”时,你应当在用户指定的目录中完成初始化与启动,并把最终可访问的本地 URL 回传给用户。
---
## 角色与硬性约束
你是 **Axhub Make 项目初始化助手**,通过驱动 `axhub-make` CLI 工具完成工作。
硬性约束(必须遵守):
- 唯一脚手架操作方式:只能使用 `npx -y axhub-make ...` 完成安装/初始化
- 禁止:自己 `mkdir` / `cp` / `rm` 来模拟脚手架(除非是执行 CLI 以外的环境检查必要动作)
- 禁止:让用户手动敲命令(你直接执行,并回报结果)
- 按顺序执行:空目录检查 → 环境检查 → 执行初始化命令 → 启动
- 初始化命令必须包含:`npx -y axhub-make . --no-start`
输出要求:
- 每一步都要给出“执行了什么命令 + 关键结果”
- 最终必须回传 `npm run dev` 输出的本地访问地址URL
---
## 执行流程(必须按顺序)
### 1) 空目录检查(初始化必须)
初始化只能在空目录执行(允许存在隐藏文件)。
```bash
ls -A | grep -v '^\.'
```
- 若输出非空:必须立即停止,并向用户反馈目标目录非空,要求更换或清空目录后再试。
- 若无输出:继续
### 2) Node.js 检查(必须)
```bash
node -v
```
- 未安装:优先尝试通过脚本/包管理器安装;失败后再给官网手动安装指引并终止
- 版本过低(建议 < v18提示升级并终止
- 通过:继续
安装方式(按优先级):
```text
1) macOSHomebrew
brew install node
2) Windows优先 winget其次 Chocolatey
winget install OpenJS.NodeJS.LTS
choco install nodejs-lts -y
3) Linux按发行版选择其一
Ubuntu/Debian: sudo apt-get update && sudo apt-get install -y nodejs npm
Fedora/RHEL: sudo dnf install -y nodejs npm
Arch: sudo pacman -S --noconfirm nodejs npm
```
官网手动安装指引(仅在上述方式失败/无权限/无包管理器时使用):
> **注意**:在发送引导时,必须明确说明**为什么**需要用户手动安装(例如:自动安装缺少权限、系统未检测到包管理器、安装命令报错等)。
```text
❌ 未检测到 Node.js建议安装 v18+ LTS
由于【请简述原因:如自动安装受到权限限制 / 未找到包管理器】,需要你配合进行手动安装。
安装方式:
- Windows/macOS/Linux去 nodejs.org 下载 LTS 安装包并安装
安装完成后再继续初始化。
如果你在安装/环境配置上遇到任何问题,把报错信息原样发我,我会继续一步步带你排查。
```
### 3) Git 检查(必须)
```bash
git --version
```
- 未安装:优先尝试通过脚本/包管理器安装;失败后再给官网手动安装指引并终止
- 通过:继续
安装方式(按优先级):
```text
1) macOSHomebrew
brew install git
2) Windows优先 winget其次 Chocolatey
winget install Git.Git
choco install git -y
3) Linux按发行版选择其一
Ubuntu/Debian: sudo apt-get update && sudo apt-get install -y git
Fedora/RHEL: sudo dnf install -y git
Arch: sudo pacman -S --noconfirm git
```
官网手动安装指引(仅在上述方式失败/无权限/无包管理器时使用):
> **注意**:在发送引导时,必须明确说明**为什么**需要用户手动安装(例如:自动安装缺少权限、系统未检测到包管理器、安装命令报错等)。
```text
❌ 未检测到 Git
由于【请简述原因:如自动安装受到权限限制 / 未找到包管理器】,需要你配合进行手动安装。
安装方式:
- Windows/macOS/Linuxgit-scm.com 下载并安装
安装完成后再继续初始化。
如果你在安装/环境配置上遇到任何问题,把报错信息原样发我,我会继续一步步带你排查。
```
### 4) 初始化(必须命令)
```bash
npx -y axhub-make . --no-start
```
说明:
- 必须包含 `-y`
- 必须包含 `--no-start`(避免占用终端,方便后续继续执行)
### 5) 启动开发服务
```bash
npm run dev
```
把终端里输出的本地访问地址(例如 `http://localhost:51720`)回传给用户,提醒用户打开验证。
---
## 常用参数(按需使用)
- 指定目录:`npx -y axhub-make my-project --no-start`
- 指定包管理器:`--pm pnpm`npm/pnpm/yarn
- 跳过依赖安装:`--no-install`(不推荐)
- 强制模式:`--force`(高风险,必须先解释可能覆盖并征得确认)
- 指定模板源:`-t <git-url>`

View File

@@ -0,0 +1,202 @@
# 旧架构更新指南(旧版 Axhub Make 项目升级到新架构)
本文档的阅读对象是 **AI Agent**
目标:当用户明确表示要更新一个**旧架构 Axhub Make 项目**时,你应当先完成旧目录结构迁移,再执行标准更新,最后启动验证并回传本地 URL。
这里的“旧架构”特指:项目里还没有新版本 marker且目录结构仍停留在旧命名方式例如
- `src/elements/`
- `src/pages/`
- `assets/docs/`
- `assets/database/`
升级后的目标新结构为:
- `src/components/`
- `src/prototypes/`
- `src/docs/`
- `src/database/`
---
## 角色与硬性约束
你是 **Axhub Make 旧架构升级助手**,通过驱动 `axhub-make` CLI 工具完成工作。
硬性约束(必须遵守):
- 更新脚手架动作只能使用 `npx -y axhub-make ...`,不要手写脚手架逻辑替代更新命令
- 禁止:让用户手动敲命令(你直接执行,并回报结果)
- 更新前必须检查Node.js、Git
- 在执行 `npx -y axhub-make --no-start` 之前,必须先完成旧目录迁移
- 更新后必须启动:`npm run dev` 并回传 URL
- 每一步都要汇报“执行了什么命令 + 关键结果”
---
## 执行流程(必须按顺序)
### 0) 识别旧架构项目(必须)
先检查:
- 是否存在 `package.json`
然后检查是否**缺少合法 marker**
- `.axhub/make/make.json`
并结合旧目录特征进行识别。
满足以下条件时,才能按本规则继续:
- 没有合法的 `.axhub/make/make.json`
- 且至少存在以下任一旧目录:
- `src/elements/`
- `src/pages/`
- `assets/docs/`
- `assets/database/`
如果已经存在合法的 `.axhub/make/make.json`,说明它属于**新架构项目**,应改为使用 `rules/update-guide.md`,不要走本规则。
如果既没有 marker也没有旧目录特征则停止并提示用户切到正确项目目录。
### 1) Node.js 检查(必须)
```bash
node -v
```
- 未安装或版本过低(建议 < v18提示安装/升级并终止
### 2) Git 检查(必须)
```bash
git --version
```
- 未安装:提示安装并终止(脚手架需要 git 拉取模板)
### 3) 迁移前备份(必须)
在执行任何目录改动前,先创建带时间戳的备份目录,例如:
```bash
mkdir -p .axhub/make/backups/<timestamp>/
```
建议备份这些目录(存在才备份):
- `src/elements/`
- `src/pages/`
- `assets/docs/`
- `assets/database/`
- `package.json`
如果目录不存在,跳过即可,但必须在结果里说明“哪些目录实际存在并被备份”。
### 4) 迁移旧目录到新目录(必须)
将以下目录迁到新架构:
- `src/elements/``src/components/`
- `src/pages/``src/prototypes/`
- `assets/docs/``src/docs/`
- `assets/database/``src/database/`
迁移原则:
- 目标目录不存在:直接迁移
- 目标目录已存在:按“不覆盖用户已有新目录内容”的原则进行合并
- 如果存在同名冲突:
- 优先保留目标目录中的现有文件
- 把旧目录里的冲突文件转存到备份冲突目录
- 在结果汇报中明确列出冲突项
- 迁移完成后,删除已经清空的旧目录
注意:
- 这是**物理目录迁移**,不是只靠运行时 URL 兼容
- 因为更新策略通常保留 `src/**``assets/**`,如果不先迁移,脚手架更新后旧目录仍会残留
### 5) 补写新架构 marker必须
迁移完成后,补写 marker
```bash
mkdir -p .axhub/make
cat > .axhub/make/make.json <<'EOF2'
{ "schemaVersion": 1, "projectType": "axhub-make" }
EOF2
```
写完后,这个项目就视为**已迁到新架构**。
### 6) 执行标准更新(必须命令)
```bash
npx -y axhub-make --no-start
```
说明:
- 必须包含 `-y`
- 必须包含 `--no-start`
- 这一步必须在“目录迁移 + marker 补写”完成后执行
### 7) 启动验证
```bash
npm run dev
```
把终端里输出的本地访问地址URL回传给用户提醒用户打开验证。
---
## 为什么旧架构需要单独处理
旧架构项目没有 `.axhub/make/make.json`,无法通过新规则中的 marker 检查。
同时,更新策略通常会保留:
- `src/**`
- `assets/**`
这意味着脚手架不会自动把这些旧目录改名:
- `src/elements/`
- `src/pages/`
- `assets/docs/`
- `assets/database/`
所以对旧架构项目,必须先迁移目录,再执行标准更新。
---
## 升级后预期结果
升级完成后,项目应满足:
- 存在 `.axhub/make/make.json`
- 原有业务内容已经迁到:
- `src/components/`
- `src/prototypes/`
- `src/docs/`
- `src/database/`
- 可以继续使用标准更新规则 `rules/update-guide.md`
---
## 出问题时的最小恢复路径
### 1) 查找升级备份
```bash
ls -la .axhub/make/backups/
ls -la package.json.backup.*
```
### 2) 恢复迁移目录(仅在确认目录迁移有误时)
优先从:
- `.axhub/make/backups/<timestamp>/`
恢复对应目录(例如 `assets/docs/``assets/database/`),再重新执行升级流程。
### 3) 恢复 package.json仅在确认是依赖问题时
```bash
cp package.json.backup.<timestamp> package.json
npm install
npm run dev
```
如果仍失败:继续收集 `npm install` / `npm run dev` 的报错,按“每次只修一个问题”的方式推进。

View File

@@ -0,0 +1,25 @@
# 资源指南
适用于资源的新增、整理、替换与维护。
## 📁 资源范围
- `src/docs/assets/`:文档配图等附属资源
- `src/docs/templates/`:文档模板
- `src/database/`:页面可直接消费的数据表
- `src/themes/`:主题及其配套资源
## ✅ 管理规则
- 先检查是否已有可复用资源,再决定是否新增
- 资源按类型放回对应目录,不混放
- 命名保持清晰,并与同类资源风格一致
- 未经用户确认,不删除、不覆盖已有资源
- 引用使用稳定相对路径,避免临时路径或外部临时链接
- 数据资源遵循 `src/database/README.md`
- 主题资源按 `src/themes/<theme-key>/` 维护,并同步相关说明
## 🔗 相关规则
- `rules/theme-guide.md`
- `src/database/README.md`

View File

@@ -0,0 +1,241 @@
# 主题指南Design Tokens + Tailwind CSS + 演示页)
本文档约束"主题"的生成产物与实现方式,供 AI 在用户提供任意形式输入token、设计规范文档、截图、样式提取结果等稳定产出可用的主题文件与演示页面。
## 🎯 交付物
每个主题推荐生成以下文件(根据信息完整度灵活调整):
```
src/themes/<theme-key>/
├── globals.css # Tailwind CSS 定义(可选,优先使用)
├── designToken.json # 主题 Token可选兼容传统模式
├── DESIGN.md # 设计规范文档(可选,信息充分时推荐)
├── index.tsx # 主题演示页(必需)
├── components/ # 演示组件 2-3 个(推荐)
│ ├── Button.tsx
│ ├── Card.tsx
│ └── Input.tsx
└── templates/ # 页面模板 2-3 个(推荐)
├── LoginTemplate.tsx
└── DashboardTemplate.tsx
```
约束:
- `<theme-key>` 使用 `kebab-case`(如 `antd``my-brand``trae-dark`
- **二选一原则**`globals.css``designToken.json` **只生成一个**,避免维护负担。
- **优先 Tailwind CSS**:默认生成 `globals.css`(现代化、易维护)。
- **例外情况**:仅当使用场景明确不支持 Tailwind CSS 时,才生成 `designToken.json`
- **禁止干扰性依赖**:主题演示页不得引入与该主题/设计系统无关的 UI 库,以免影响视觉表达。默认只使用原生 HTML + CSS Variables或该设计系统指定的组件库
## 1) `globals.css` 规范 (Tailwind CSS)
这是主题的核心定义文件(若生成)。
### 1.1 格式要求
- 使用 CSS Variables 定义主题变量(`:root``.dark`)。
- 支持 Tailwind CSS v4 语法(如 `@theme inline`)。
- 必须包含基础配色、圆角、字体等定义。
示例结构:
```css
@import "tailwindcss";
/* 自定义变体 */
@custom-variant dark (&:is(.dark *));
:root {
/* 基础色变量 */
--background: #ffffff;
--foreground: #000000;
--primary: #3b82f6;
/* ... */
}
.dark {
/* 深色模式变量 */
--background: #000000;
--foreground: #ffffff;
/* ... */
}
@theme inline {
/* 映射变量到 Tailwind theme */
--color-background: var(--background);
--color-primary: var(--primary);
/* ... */
}
```
## 2) `designToken.json` 规范
### 2.1 必须字段
- `name`:主题名称(必需,字符串,用于 UI 展示与演示页标题)
推荐字段:
- `description`:主题描述(字符串)
- `token`Ant Design 风格的 Token 对象(如果存在 `globals.css`,推荐引用变量如 `var(--primary)`
### 2.2 何时生成
**仅在以下情况生成 `designToken.json`**
- 使用场景明确不支持 Tailwind CSS如纯 JS 环境、特定框架限制)
- 用户明确要求使用 JSON Token 格式
- 需要与不支持 CSS Variables 的组件库集成
**默认情况下优先生成 `globals.css`**,避免维护两套配置。
## 3) `DESIGN.md` 规范(设计规范文档)
**可选但推荐**的产物,用于系统化记录主题的设计价值、使用约束和实现细节。
### 3.1 推荐结构
参考 `src/themes/firecrawl/DESIGN.md`,包含:
- 设计系统价值(品牌定位、核心价值、设计原则)
- 能力边界(适合/不适合的场景)
- 色彩/字体/间距/圆角/阴影/图标系统
- 组件规范Button、Card、Input 等样式规范)
- 使用约束(必须遵守、建议做法、禁止做法)
### 3.2 生成策略
- 信息充分时生成完整文档
- 信息不足时可省略或生成简化版本
## 4) `index.tsx`(主题演示页)规范
主题演示页的目标:在本项目环境中直观看到主题 token 的内容与效果。
### 4.1 基本约束
- 文件必须 `export default Component`
- **按需引入**
- 如果有 `globals.css`,必须 `import './globals.css';`
- 如果有 `designToken.json`,导入并使用它。
- 如果有 `DESIGN.md`,在演示页中提供查看入口。
- 演示页应展示主题效果。
- 默认只使用原生 HTML 元素div/button/input 等)与 CSS Variables 展示效果。
### 4.2 演示内容优先级
1. **优先自定义演示**:根据当前主题的设计规范,从零创建符合该主题风格的演示页面、组件和模板
2. **避免照搬已有主题**:不要直接参考或复制其他主题的演示页面,因为它们是按各自设计规范定制的
### 4.3 注入方式
- 若有 `globals.css`:使用 CSS 变量展示(推荐)。
- 若有 `designToken.json`:通过 `ConfigProvider` 注入。
- **不会同时存在两者**(避免维护负担)。
## 5) `components/` 规范(演示组件)
推荐生成 **2-3 个核心组件**(如 Button、Card、Input展示主题在具体 UI 组件上的应用效果。
### 5.1 组件结构
```tsx
import React from 'react';
interface ComponentSectionProps {
tokens: Record<string, any>;
}
export const ButtonSection: React.FC<ComponentSectionProps> = ({ tokens }) => {
return (
<div className="space-y-8 animate-in fade-in duration-500">
<div>
<h1 className="text-3xl font-bold mb-2"> Button</h1>
<p className="text-neutral-600"></p>
</div>
<div className="canvas-panel p-8">
{/* 展示多种变体、尺寸、状态 */}
</div>
</div>
);
};
```
### 5.2 演示内容
- 多种变体primary、secondary、ghost 等)
- 多种尺寸large、default、small
- 多种状态normal、hover、disabled、loading
## 6) `templates/` 规范(页面模板)
推荐生成 **2-3 个典型页面**(如登录页、仪表盘、表单页),展示主题在完整页面场景中的应用效果。
### 6.1 推荐模板类型
| 模板类型 | 适用场景 | 展示重点 |
|---------|---------|---------|
| 登录页 | 通用 | 表单、按钮、品牌展示 |
| 仪表盘 | 数据密集型产品 | 卡片、图表、数据展示 |
| 表单页 | 业务系统 | 表单组件、布局、验证 |
| 列表页 | 内容管理 | 表格、筛选、分页 |
### 6.2 模板要求
- 展示完整的页面布局和交互流程
- 使用真实/模拟数据
- 严格遵循主题的设计规范
## 7) 输入来源与生成策略
**优先级原则**:用户提供 > 项目主题 > 默认设计指导
用户输入可能包含:
- Tailwind CSS 文件或配置(**最高优先级**
- CSS 变量定义
- JSON Token
- 设计规范文档或截图
### 7.1 生成策略(按输入类型)
1. **用户提供 CSS/Tailwind**
- 必须生成 `globals.css`
- **不生成** `designToken.json`(除非明确不支持 TW
- 推荐生成 `DESIGN.md` + 组件 + 模板
2. **用户提供 JSON Token**
- 必须生成 `designToken.json`
- **不生成** `globals.css`(除非用户要求迁移到 TW
- 推荐生成 `DESIGN.md` + 组件 + 模板
3. **用户提供设计规范文档**
- 必须生成 `DESIGN.md`
- **优先生成 `globals.css`**(现代化方案)
- 生成符合规范的组件和模板
4. **截图提取/无明确格式**
- **默认生成 `globals.css`**(推荐)
- 尽量生成 `DESIGN.md` + 组件 + 模板
### 7.2 默认设计指导(兜底方案)
**仅在以下条件同时满足时查阅**
- 用户未提供设计规范文档
- 项目中无可复用的主题
- 需要设计风格指导
**参考文档(渐进式披露)**
| 业务场景 | 参考文档 | 判断依据 |
|---------|---------|---------|
| **基础型界面 / ToB / 工具类** | `/skills/third-party/interface-design/SKILL.md` | • 目标用户:企业员工或专业用户<br>• 使用频率:高频操作<br>• 核心任务:完成工作、处理数据、执行操作 |
| **风格化页面 / ToC / App / 移动端** | `/skills/third-party/frontend-design/SKILL.md` | • 目标用户:普通消费者或品牌受众<br>• 使用频率:低频浏览或内容消费<br>• 核心任务:获取信息、建立品牌感知、提升视觉吸引力 |
| **混合场景** | 基础区用 `/skills/third-party/interface-design/SKILL.md`,展示区用 `/skills/third-party/frontend-design/SKILL.md` | 核心功能简洁稳定,展示区域可适度风格化 |
**重要**:不要提前加载设计指导文档,仅在真正需要且无其他参考时查阅。
## 8) 开发后验收流程
### 8.1 运行验收脚本
```bash
node scripts/check-app-ready.mjs /themes/[主题名]
```
### 8.2 根据状态处理
- **状态为 ERROR**:根据错误信息修复。
- **状态为 READY**:访问预览 URL检查主题展示效果颜色、字体、深色模式切换等

100
axhub-make/rules/update.md Normal file
View File

@@ -0,0 +1,100 @@
# 更新指南(更新现有 Axhub Make 项目)
本文档的阅读对象是 **AI Agent**
目标:当用户说“更新 Axhub Make 项目”时,你应当在项目根目录执行更新并启动验证,最终回传本地 URL若更新后启动失败优先走最小恢复路径例如 `package.json.backup.*`)。
---
## 角色与硬性约束
你是 **Axhub Make 项目更新助手**,通过驱动 `axhub-make` CLI 工具完成工作。
硬性约束(必须遵守):
- 更新脚手架动作只能使用 `npx -y axhub-make ...`,不要手写脚手架逻辑
- 禁止:让用户手动敲命令(你直接执行,并回报结果)
- 更新前必须检查Node.js、Git
- 更新命令必须包含:`npx -y axhub-make --no-start`
- 更新后必须启动:`npm run dev` 并回传 URL
输出要求:
- 每一步都要给出“执行了什么命令 + 关键结果”
- 最终必须回传 `npm run dev` 输出的本地访问地址URL
---
## 执行流程(必须按顺序)
### 0) 防跑错目录(必须)
从此以后Axhub Make 项目的“更新识别”以根目录的 marker 文件为准:
- `.axhub/make/make.json`(内容要求:`{ "schemaVersion": 1, "projectType": "axhub-make" }`
因此在更新前必须检查:
- 是否存在 `package.json`
- 是否存在 `.axhub/make/make.json` 且内容合法
若缺失或不合法:必须停止,让用户切到正确项目目录(不做旧项目兼容/迁移)。
### 1) Node.js 检查(必须)
```bash
node -v
```
- 未安装或版本过低(建议 < v18提示安装/升级并终止
### 2) Git 检查(必须)
```bash
git --version
```
- 未安装:提示安装并终止(脚手架需要 git 拉取模板)
### 3) 执行更新(必须命令)
```bash
npx -y axhub-make --no-start
```
说明:
- 必须包含 `-y`
- 必须包含 `--no-start`
### 4) 启动验证
```bash
npm run dev
```
把终端里输出的本地访问地址URL回传给用户提醒用户打开验证。
---
## 更新会做什么(用于解释与排障)
更新遵循项目内的 `scaffold.update.json` 策略,一般原则:
- 用户业务内容默认保留(例如 `src/**``assets/**` 常被配置为不覆盖)
- 基础公共定义可能会强制更新(例如 `src/common/**`
- `package.json` 如果有差异,通常会备份旧版本为 `package.json.backup.<timestamp>`,并将旧版本里“新版本缺失的依赖”合并到新版本中(避免丢依赖)
---
## 更新后出问题的最小恢复路径
### 1) 查找备份
```bash
ls -la package.json.backup.*
```
### 2) 恢复备份(只在确认是 package.json 引起的问题时)
```bash
cp package.json.backup.<timestamp> package.json
npm install
npm run dev
```
如果仍失败:继续收集 `npm install` / `npm run dev` 的报错,按“每次只修一个问题”的方式推进。

View File

@@ -0,0 +1,306 @@
# 方案比选指南
本文档指导 AI Agent 如何为用户提供多方案比选,以及如何引导用户进行决策收敛。
## 🎯 目标
通过结构化的比选流程,确保:
1. 为用户提供有价值的备选方案
2. 方案数量可控,便于用户决策
3. 及时引导用户进行收敛决策
4. 使用 `VariantSwitcher` 组件进行方案展示
## 📋 核心规则
### 1. 方案数量原则
| 规则 | 说明 |
|------|------|
| **推荐方案数** | 原则上不超过 **3 个**方案 |
| **最小方案数** | 当存在明确分歧时,至少提供 2 个方案 |
| **特殊情况** | 如用户有特殊需求,可适当增加方案数量 |
**为什么建议不超过 3 个?**
- 超过 3 个方案会增加用户决策负担
- 方案过多容易导致决策疲劳
- 3 个方案足以覆盖大多数设计维度(如:保守、平衡、激进)
> 注意:这是工作指导原则,组件本身不强制限制方案数量。
### 2. 何时触发比选
以下情况 **应该** 提供多方案比选:
| 场景 | 示例 |
|------|------|
| 设计风格有多种合理选择 | 卡片式 vs 列表式布局 |
| 交互模式存在权衡 | 弹窗确认 vs 内联确认 |
| 技术实现有多条路径 | 动画效果的不同实现方式 |
| 用户需求模糊 | "做一个好看的按钮" |
| 用户明确要求比选 | "给我几个方案看看" |
以下情况 **不应该** 提供比选:
| 场景 | 原因 |
|------|------|
| 需求明确且唯一 | 用户已明确指定实现方式 |
| 只有一种合理实现 | 无需增加决策负担 |
| 差异极小 | 如仅颜色深浅微调,应直接询问偏好 |
### 3. 方案差异化原则
每个方案之间应该有 **明显的差异**,避免提供相似方案:
```
✅ 正确:
- 方案 A极简风格留白多突出内容
- 方案 B信息密集功能明显效率优先
- 方案 C视觉丰富强调品牌感
❌ 错误:
- 方案 A蓝色按钮
- 方案 B深蓝色按钮
- 方案 C浅蓝色按钮
```
## 🛠️ 使用 VariantSwitcher 组件
### 组件导入
```typescript
import { VariantSwitcher, VariantItem } from '@/common/VariantSwitcher';
```
### 数据结构
每个方案需要定义为 `VariantItem` 类型:
```typescript
interface VariantItem {
key?: string; // 唯一标识(可选)
content: ReactNode; // 渲染内容
title: string; // 方案标题
description: string; // 方案一句话描述
}
```
### 基本用法
```tsx
// 1. 定义方案数据
const CARD_VARIANTS: VariantItem[] = [
{
key: 'minimal',
content: <CardStyleA />,
title: '极简风格',
description: '大量留白,突出内容本身'
},
{
key: 'dense',
content: <CardStyleB />,
title: '信息密集',
description: '功能区块明显,操作便捷'
},
{
key: 'visual',
content: <CardStyleC />,
title: '视觉冲击',
description: '强调视觉效果,品牌感强'
}
];
// 2. 使用组件
<VariantSwitcher
id="card-style"
name="卡片样式"
variants={CARD_VARIANTS}
onConfirm={(index, item) => {
console.log(`用户选择了: ${item.title}`);
}}
/>
```
### Props 说明
| 属性 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `id` | string | 否 | 唯一标识符,用于全局寻址 |
| `name` | string | 否 | **中文名称**,显示在全局面板中(如"头部设计"、"登录页布局" |
| `variants` | VariantItem[] | 是 | 方案列表 |
| `defaultIndex` | number | 否 | 默认选中索引,默认 0 |
| `onConfirm` | function | 否 | 确认回调,参数为 (index, item) |
| `onReset` | function | 否 | 重置回调 |
| `style` | CSSProperties | 否 | 容器样式 |
| `className` | string | 否 | 容器类名 |
### 组件特性
#### 1. 轻量化入口
- 鼠标悬停在组件上时,右上角显示切换图标
- 点击图标弹出方案选择面板
- 面板显示每个方案的标题和描述
#### 2. 全局面板
- 页面右下角有全局入口按钮(当存在比选组件时自动显示)
- 点击打开侧边栏,显示所有比选项目
- 每个项目显示 **中文名称**`name` 属性)
- 支持快速定位到具体组件
#### 3. 快捷键支持
- `Ctrl + .``Cmd + .`:显示/隐藏比选入口
### 外部控制
```javascript
// 在浏览器控制台或 AI Agent 中
const manager = window.AXHUB_VARIANT_MANAGER;
// 获取实例
const switcher = manager.instances['card-style'];
// 切换方案
switcher.select(1); // 选择第 2 个方案
// 确认选择
switcher.confirm();
// 定位到组件
switcher.focus();
// 获取所有实例
Object.values(manager.instances).forEach(inst => {
console.log(`${inst.name}: 当前选择 ${inst.variants[inst.currentIndex].title}`);
});
```
## 💬 用户交互规则
### 1. 方案介绍
在提供方案时,必须简要说明每个方案的特点:
```markdown
我为您准备了 3 个设计方案,请预览后选择您偏好的风格:
**极简风格**
大量留白,突出核心内容,适合内容展示型页面
**信息密集**
功能区块明显,操作便捷,适合工具型/管理型页面
**视觉冲击**
强调视觉效果,品牌感强,适合营销型/展示型页面
请悬停在组件上点击切换图标预览,或点击右下角全局按钮查看所有比选项。
选定后点击"确认"按钮。
```
### 2. 收敛提醒
当用户长时间未做决策或继续提出新需求时,应主动提醒:
```markdown
💡 提醒:您目前还有待决策的方案比选,可以点击右下角的比选按钮查看:
- 卡片样式
- 头部设计
建议先确认这些方案,以便我基于您的选择继续优化。
```
### 3. 决策后确认
用户确认方案后,应给予反馈:
```markdown
✅ 已记录您的选择:
- 卡片样式:**极简风格**
我将基于这个选择继续后续开发。如需调整,可随时点击组件上的切换图标重新选择。
```
## 📝 典型使用场景
### 场景 1用户需求模糊
```
用户: "帮我做一个登录页面"
AI: 我为您准备了 3 种登录页面风格:
**居中卡片**
经典设计,表单居中,背景可自定义
**左右分栏**
左侧品牌展示区,右侧登录表单
**沉浸式**
大图背景,表单叠加,视觉冲击力强
[使用 VariantSwitcher name="登录页布局" 展示三个方案]
```
### 场景 2组件样式选择
```
用户: "这个按钮给我几个样式选择"
AI: 好的,这里是 3 种按钮样式:
**实心填充** - 高对比度,强调主要操作
**描边镂空** - 轻量化,适合次要操作
**渐变高亮** - 时尚动感,吸引用户注意
[使用 VariantSwitcher name="按钮样式" 展示三个方案]
```
### 场景 3页面整体布局
```
用户: "帮我做一个产品详情页"
AI: 我为您准备了 3 种页面布局方案:
**左右分栏** - 左侧产品图片,右侧产品信息,经典电商布局
**上下结构** - 顶部大图轮播,下方详情信息,适合视觉型产品
**沉浸式** - 全屏背景,信息叠加,强调品牌调性
[使用 VariantSwitcher name="产品详情页布局" 展示三个页面方案]
```
## ⚠️ 注意事项
1. **命名规范**`name` 属性应使用清晰的中文名称,便于用户在全局面板中识别
2. **方案描述**:每个 `VariantItem``title``description` 应简洁明了
3. **布局影响**:组件外层是 relative 定位的 div确保父容器正确设置尺寸
4. **事件冒泡**:控制条按钮已阻止事件冒泡,不会触发下层点击事件
5. **层级覆盖**:全局面板 z-index 为 100000注意与其他 Modal 的层级冲突
6. **快捷键**:使用 `Ctrl + .` 可以隐藏/显示比选入口,适合演示场景
## ✅ 检查清单
### 提供比选前
- [ ] 确认确实存在多种合理方案
- [ ] 方案之间有明显差异
- [ ] 原则上方案数量不超过 3 个
- [ ] 每个方案都有清晰的标题和描述
### 比选进行中
- [ ] 使用 `VariantSwitcher` 组件展示方案
- [ ] 设置有意义的 `name` 属性(中文名称)
- [ ] 为每个方案提供 `title``description`
- [ ] 向用户说明如何切换和确认
### 比选完成后
- [ ] 确认用户的选择
- [ ] 基于选择继续后续工作
- [ ] 如有多个待决策项,提醒用户收敛
## 🔗 相关文档
- `src/common/VariantSwitcher.tsx` - 组件实现代码
- `src/prototypes/ref-variant-switcher-demo/` - 演示页面
- `skills/third-party/brainstorming/SKILL.md` - 需求对齐规则
- `development-guide.md` - 开发指南

View File

@@ -0,0 +1,49 @@
# 微信回复指南
## 获取项目首页地址
先读取开发服务信息,拿到 `port``localIP`
```bash
cat .axhub/make/.dev-server-info.json
# 如果当前工作目录是仓库根目录,则读取:
cat apps/axhub-make/.axhub/make/.dev-server-info.json
```
对外沟通时不要返回 `127.0.0.1``localhost`。至少返回 `localIP` 对应的局域网地址。
如果机器上还存在额外的可访问组网地址,也要一起返回,例如 Tailscale、ZeroTier 或其他 VPN/组网 IPv4。可以额外检查
```bash
ifconfig | rg '^[A-Za-z0-9:._-]+:|\\s+inet\\s'
# 如果安装了 tailscale再检查
tailscale ip -4
```
只返回外部设备可访问的地址,忽略回环地址和明显不可对外访问的虚拟测试地址。
回复示例(根据实际值替换;按实际存在的地址返回,不要虚构不存在的项):
```
项目首页:
• 局域网: http://{localIP}:{port}
• Tailscale: http://{tailscaleIP}:{port}
```
## 切换 AI Agent
直接运行项目内的脚本,无需网络请求:
```bash
node scripts/switch-agent.mjs <agent>
```
agent 可选值:`codex``claudecode`(别名 `claude`)、`gemini`(别名 `gem`
查看当前状态:
```bash
node scripts/switch-agent.mjs --status
```
> 切换后 daemon 重启,会话上下文会重置。完成后告知用户已切换到哪个 Agent。

View File

@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"neverOverwrite": [
"src/**",
"assets/**"
],
"alwaysOverwrite": [
"src/common/**"
],
"conflictCheck": [
"package.json"
],
"defaultOverwrite": true
}

View File

@@ -0,0 +1,624 @@
#!/usr/bin/env node
/**
* AI Studio 项目预处理器(最小化处理模式)
*
* 只做 100% 有把握的操作:
* 1. 完整复制项目
* 2. 分析项目结构
* 3. 生成任务文档
*
* 不做任何代码修改,全部留给 AI 处理
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TARGET_TYPE_TO_SRC_DIR = {
prototypes: 'src/prototypes',
components: 'src/components',
themes: 'src/themes',
};
const THEME_SPLIT_SKILL_DOCS = [
'/skills/axure-prototype-workflow/theme-generation.md',
'/skills/axure-prototype-workflow/doc-generation.md',
'/skills/axure-prototype-workflow/data-generation.md',
'/skills/web-page-workflow/theme-generation.md',
'/skills/web-page-workflow/doc-generation.md',
'/skills/web-page-workflow/data-generation.md',
];
const CONFIG = {
projectRoot: path.resolve(__dirname, '..'),
tempDir: path.resolve(__dirname, '../temp'),
};
function log(message, type = 'info') {
const prefix = { info: '✓', warn: '⚠', error: '✗', progress: '⏳' }[type] || '';
console.log(`${prefix} ${message}`);
}
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function sanitizeName(rawName) {
return String(rawName || '')
.replace(/[^a-z0-9-]/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.toLowerCase();
}
function getTargetInfo(targetType, outputName) {
const srcDir = TARGET_TYPE_TO_SRC_DIR[targetType];
const outputBaseDir = path.resolve(CONFIG.projectRoot, srcDir);
const outputDir = path.join(outputBaseDir, outputName);
const relativeOutputDir = `${srcDir}/${outputName}`;
if (targetType === 'themes') {
return {
targetType,
srcDir,
outputBaseDir,
outputDir,
relativeOutputDir,
tasksFileName: '.ai-studio-theme-tasks.md',
analysisFileName: '.ai-studio-theme-analysis.json',
checkPath: `/themes/${outputName}`,
label: '主题',
};
}
return {
targetType,
srcDir,
outputBaseDir,
outputDir,
relativeOutputDir,
tasksFileName: '.ai-studio-tasks.md',
analysisFileName: '.ai-studio-analysis.json',
checkPath: `/${targetType}/${outputName}`,
label: targetType === 'components' ? '组件' : '页面',
};
}
// 递归查找所有 .tsx/.ts 文件
function findFiles(dir, extensions = ['.tsx', '.ts']) {
const results = [];
if (!fs.existsSync(dir)) return results;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// 跳过 node_modules
if (entry.name === 'node_modules') continue;
results.push(...findFiles(fullPath, extensions));
} else {
const ext = path.extname(entry.name);
if (extensions.includes(ext)) {
results.push(fullPath);
}
}
}
return results;
}
function copyDirectory(src, dest) {
if (!fs.existsSync(src)) return 0;
ensureDir(dest);
const entries = fs.readdirSync(src, { withFileTypes: true });
let count = 0;
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules') continue;
count += copyDirectory(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
count++;
}
}
return count;
}
console.log('AI Studio Converter - Minimal Processing Mode\n');
function analyzeProject(targetDir) {
const analysis = {
files: [],
components: [],
dependencies: {},
structure: {},
indexHtml: null,
viteConfig: null
};
const files = findFiles(targetDir, ['.tsx', '.ts']);
files.forEach(file => {
const relativePath = path.relative(targetDir, file);
const content = fs.readFileSync(file, 'utf8');
const fileName = path.basename(file);
const fileInfo = {
path: relativePath,
isAppTsx: fileName === 'App.tsx',
isIndexTsx: fileName === 'index.tsx',
imports: []
};
const importMatches = content.matchAll(/import\s+.*from\s+['"]([^'"]+)['"]/g);
for (const match of importMatches) {
fileInfo.imports.push(match[1]);
}
analysis.files.push(fileInfo);
if (relativePath.startsWith('components/')) {
analysis.components.push(relativePath);
}
});
const indexHtmlPath = path.join(targetDir, 'index.html');
if (fs.existsSync(indexHtmlPath)) {
const htmlContent = fs.readFileSync(indexHtmlPath, 'utf8');
const importMapMatch = htmlContent.match(/<script type="importmap">([\s\S]*?)<\/script>/);
const importMap = importMapMatch ? JSON.parse(importMapMatch[1]) : null;
const styleMatches = htmlContent.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g);
const customStyles = Array.from(styleMatches).map(m => m[1].trim());
const fontMatches = htmlContent.matchAll(/<link[^>]*href=["']([^"']*fonts\.googleapis\.com[^"']*)["'][^>]*>/g);
const fonts = Array.from(fontMatches).map(m => m[1]);
analysis.indexHtml = {
importMap,
customStyles,
fonts,
hasTailwindCDN: htmlContent.includes('cdn.tailwindcss.com')
};
}
const viteConfigPath = path.join(targetDir, 'vite.config.ts');
if (fs.existsSync(viteConfigPath)) {
const viteContent = fs.readFileSync(viteConfigPath, 'utf8');
const aliasMatch = viteContent.match(/alias:\s*{([^}]*)}/);
const aliases = aliasMatch ? aliasMatch[1].trim() : null;
const defineMatch = viteContent.match(/define:\s*{([^}]*)}/);
const envVars = defineMatch ? defineMatch[1].trim() : null;
analysis.viteConfig = {
hasAlias: !!aliases,
aliases,
hasEnvVars: !!envVars,
envVars
};
}
const packageJsonPath = path.join(targetDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const deps = packageJson.dependencies || {};
analysis.dependencies = {
all: deps,
toInstall: Object.keys(deps).filter(dep => {
if (dep === 'react' || dep === 'react-dom') return false;
return true;
}),
excluded: ['react', 'react-dom']
};
} else if (analysis.indexHtml?.importMap) {
const imports = analysis.indexHtml.importMap.imports || {};
const cdnDeps = Object.keys(imports).filter(dep => {
if (dep === 'react' || dep === 'react-dom') return false;
return true;
});
analysis.dependencies = {
fromCDN: cdnDeps,
toInstall: cdnDeps,
excluded: ['react', 'react-dom']
};
}
analysis.structure = {
hasAppTsx: fs.existsSync(path.join(targetDir, 'App.tsx')),
hasIndexTsx: fs.existsSync(path.join(targetDir, 'index.tsx')),
hasIndexHtml: fs.existsSync(path.join(targetDir, 'index.html')),
hasComponentsDir: fs.existsSync(path.join(targetDir, 'components')),
hasAssetsDir: fs.existsSync(path.join(targetDir, 'assets')),
hasConstantsTs: fs.existsSync(path.join(targetDir, 'constants.ts')),
hasTypesTs: fs.existsSync(path.join(targetDir, 'types.ts')),
hasViteConfig: fs.existsSync(path.join(targetDir, 'vite.config.ts')),
hasMetadataJson: fs.existsSync(path.join(targetDir, 'metadata.json'))
};
return analysis;
}
function buildAnalysisReport(analysis) {
return {
summary: {
totalFiles: analysis.files.length,
componentCount: analysis.components.length,
dependenciesToInstall: analysis.dependencies.toInstall?.length || 0,
hasImportMap: !!analysis.indexHtml?.importMap,
hasCustomStyles: (analysis.indexHtml?.customStyles?.length || 0) > 0,
hasFonts: (analysis.indexHtml?.fonts?.length || 0) > 0
},
structure: analysis.structure,
components: analysis.components,
dependencies: analysis.dependencies,
indexHtml: analysis.indexHtml,
viteConfig: analysis.viteConfig,
files: analysis.files
};
}
function generateDefaultTasksDocument(report, targetInfo, outputName, tempDir) {
const reportPath = path.join(targetInfo.outputDir, targetInfo.analysisFileName);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
let markdown = `# AI Studio 项目转换任务清单\n\n`;
markdown += `> **重要**: 请先阅读 \`/skills/ai-studio-project-converter/SKILL.md\` 了解转换规范\n\n`;
markdown += `**名称**: ${outputName}\n`;
markdown += `**项目位置**: \`${targetInfo.relativeOutputDir}/\`\n`;
markdown += `**原始文件**: \`${tempDir}\` (仅供参考,不要修改)\n`;
markdown += `**生成时间**: ${new Date().toLocaleString()}\n\n`;
markdown += `## 📊 项目概况\n\n`;
markdown += `- 总文件数: ${report.summary.totalFiles}\n`;
markdown += `- 组件数: ${report.summary.componentCount}\n`;
markdown += `- Import Map: ${report.summary.hasImportMap ? '✓ 存在' : '✗ 不存在'}\n`;
markdown += `- 自定义样式: ${report.summary.hasCustomStyles ? '✓ 存在' : '✗ 不存在'}\n`;
markdown += `- 外部字体: ${report.summary.hasFonts ? '✓ 存在' : '✗ 不存在'}\n`;
markdown += `- 需要安装的依赖: ${report.summary.dependenciesToInstall}\n\n`;
markdown += `## ✅ 转换任务(共 5 个)\n\n`;
markdown += `### 任务 1: 转换主应用组件\n\n`;
markdown += `**目标**: 将 \`App.tsx\` 转换为本项目组件规范\n\n`;
if (report.structure.hasAppTsx) {
markdown += `**参考文件**: \`${targetInfo.relativeOutputDir}/App.tsx\`\n\n`;
markdown += `**操作**:\n`;
markdown += `1. 重命名 \`App.tsx\`\`index.tsx\`\n`;
markdown += `2. 按照 \`/skills/ai-studio-project-converter/SKILL.md\` 中的本项目组件规范改造\n`;
markdown += `3. 添加文件头部注释(\`@name\` 和参考资料)\n`;
markdown += `4. 使用 \`forwardRef(function ComponentName(props: AxureProps, ref: React.ForwardedRef<AxureHandle>) { ... })\` 包装\n`;
markdown += `5. 实现 \`useImperativeHandle\`\n`;
markdown += `6. 保持原有的 JSX、Hooks 和 Tailwind 类名不变\n\n`;
} else {
markdown += `⚠️ 未找到 \`App.tsx\`,请手动创建 \`index.tsx\`\n\n`;
}
markdown += `### 任务 2: 创建 style.css\n\n`;
markdown += `**目标**: 提取 index.html 中的样式信息\n\n`;
if (report.indexHtml) {
markdown += `**操作**:\n`;
markdown += `1. 创建 \`style.css\`,开头添加 \`@import "tailwindcss";\`\n`;
if (report.indexHtml.customStyles.length > 0) {
markdown += `2. 从 \`index.html\`\`<style>\` 标签提取自定义样式\n`;
markdown += `3. 将提取的样式添加到 \`style.css\`\n`;
}
if (report.indexHtml.fonts.length > 0) {
markdown += `4. 添加外部字体引用:\n`;
report.indexHtml.fonts.forEach(font => {
markdown += ` \`@import url('${font}');\`\n`;
});
}
markdown += `\n`;
} else {
markdown += `**操作**: 创建基础样式文件,内容为 \`@import "tailwindcss";\`\n\n`;
}
markdown += `### 任务 3: 移除 AI Studio 特定文件\n\n`;
markdown += `**目标**: 删除不需要的文件\n\n`;
markdown += `**需要删除**:\n`;
if (report.structure.hasIndexHtml) {
markdown += `- ✓ \`index.html\` (已提取信息)\n`;
}
if (report.structure.hasIndexTsx) {
markdown += `- ✓ \`index.tsx\` (本项目有自己的入口)\n`;
}
if (report.structure.hasViteConfig) {
markdown += `- ⚠️ \`vite.config.ts\` (检查路径别名后可删除)\n`;
}
if (report.structure.hasMetadataJson) {
markdown += `- ⚠️ \`metadata.json\` (可选保留作为参考)\n`;
}
markdown += `\n`;
markdown += `### 任务 4: 安装依赖\n\n`;
if (report.dependencies.toInstall && report.dependencies.toInstall.length > 0) {
markdown += `**执行命令**:\n`;
markdown += `\`\`\`bash\n`;
markdown += `pnpm add ${report.dependencies.toInstall.join(' ')}\n`;
markdown += `\`\`\`\n\n`;
if (report.dependencies.fromCDN) {
markdown += `**CDN 依赖映射**:\n`;
report.dependencies.fromCDN.forEach(dep => {
markdown += `- \`${dep}\` (从 Import Map 识别)\n`;
});
markdown += `\n`;
}
} else {
markdown += `✓ 无需安装额外依赖\n\n`;
}
if (report.viteConfig?.hasEnvVars) {
markdown += `**环境变量**:\n`;
markdown += `⚠️ 项目使用了环境变量,需要配置 \`.env.local\`\n`;
markdown += `\`\`\`\n${report.viteConfig.envVars}\n\`\`\`\n\n`;
}
markdown += `### 任务 5: 验收测试\n\n`;
markdown += `**执行命令**:\n`;
markdown += `\`\`\`bash\n`;
markdown += `node scripts/check-app-ready.mjs ${targetInfo.checkPath}\n`;
markdown += `\`\`\`\n\n`;
markdown += `**验收标准**: 状态为 READY页面正常渲染无控制台错误\n\n`;
markdown += `## 📚 参考资料\n\n`;
markdown += `- **转换规范**: \`/skills/ai-studio-project-converter/SKILL.md\`\n`;
markdown += `- **原始项目**: \`${tempDir}\` (仅供参考)\n`;
markdown += `- **详细数据**: \`${targetInfo.analysisFileName}\`\n\n`;
markdown += `## 💡 注意事项\n\n`;
markdown += `1. **Import Map**: CDN 依赖需转换为 npm 包\n`;
markdown += `2. **自定义样式**: 从 index.html 提取到 style.css\n`;
markdown += `3. **路径别名**: 检查 vite.config.ts 中的 alias 配置\n`;
markdown += `4. **环境变量**: \`process.env.*\` 需改为 \`import.meta.env.VITE_*\`\n`;
markdown += `5. **原始文件**: \`${tempDir}\` 目录保留作为参考,不要修改\n`;
markdown += `6. **验证**: 完成后务必运行验收脚本确认\n`;
const mdPath = path.join(targetInfo.outputDir, targetInfo.tasksFileName);
fs.writeFileSync(mdPath, markdown);
return { reportPath, mdPath };
}
function generateThemeTasksDocument(report, targetInfo, outputName, tempDir) {
const reportPath = path.join(targetInfo.outputDir, targetInfo.analysisFileName);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
let markdown = `# AI Studio 主题导入任务清单\n\n`;
markdown += `> **重要**: 请先阅读 \`/skills/ai-studio-project-converter/SKILL.md\` 与主题拆分技能文档,按任务顺序执行\n\n`;
markdown += `**主题 key**: ${outputName}\n`;
markdown += `**主题目录**: \`${targetInfo.relativeOutputDir}/\`\n`;
markdown += `**原始文件**: \`${tempDir}\` (仅供参考,不要修改)\n`;
markdown += `**生成时间**: ${new Date().toLocaleString()}\n\n`;
markdown += `## 📊 输入概况\n\n`;
markdown += `- 总文件数: ${report.summary.totalFiles}\n`;
markdown += `- 组件数: ${report.summary.componentCount}\n`;
markdown += `- Import Map: ${report.summary.hasImportMap ? '✓' : '✗'}\n`;
markdown += `- 自定义样式: ${report.summary.hasCustomStyles ? '✓' : '✗'}\n`;
markdown += `- 待评估依赖: ${report.summary.dependenciesToInstall}\n\n`;
markdown += `## 📚 参考文档(必须阅读)\n\n`;
markdown += `- \`/skills/ai-studio-project-converter/SKILL.md\`\n`;
THEME_SPLIT_SKILL_DOCS.forEach((docPath) => {
markdown += `- \`${docPath}\`\n`;
});
markdown += `\n`;
markdown += `## ✅ 主题导入任务(共 5 个)\n\n`;
markdown += `### 任务 1生成主题 token\n\n`;
markdown += `**目标**:在 \`${targetInfo.relativeOutputDir}/\` 下生成 \`globals.css\`\`designToken.json\`(二选一)\n\n`;
markdown += `**要求**\n`;
markdown += `- 若输出 \`designToken.json\`,必须包含 \`name\` 字段\n`;
markdown += `- 结合 AI Studio 项目中的配色、字体和组件样式提炼 token\n\n`;
markdown += `### 任务 2生成 DESIGN-SPEC.md\n\n`;
markdown += `**目标**:输出 \`${targetInfo.relativeOutputDir}/DESIGN-SPEC.md\`\n\n`;
markdown += `**要求**:说明视觉语言、组件规范、排版层级、交互状态与适配策略\n\n`;
markdown += `### 任务 3按需生成项目文档\n\n`;
markdown += `**目标**:在 \`src/docs/\` 下补充主题相关文档(如页面地图、项目概览)\n\n`;
markdown += `**要求**:保证文档与主题规范和业务上下文一致\n\n`;
markdown += `### 任务 4按需生成数据模型\n\n`;
markdown += `**目标**:在 \`src/database/\` 下补充或更新数据模型\n\n`;
markdown += `**要求**\n`;
markdown += `- 文件名英文、\`tableName\` 中文\n`;
markdown += `- 每个表包含 \`records\` 数组,记录 id 唯一\n\n`;
markdown += `### 任务 5生成/更新主题演示入口\n\n`;
markdown += `**目标**:生成或更新 \`${targetInfo.relativeOutputDir}/index.tsx\`\n\n`;
markdown += `**要求**:能清晰展示主题样式与 token 使用方式\n\n`;
markdown += `## 🔍 验收建议\n\n`;
markdown += `- 目录检查:\`${targetInfo.relativeOutputDir}/\` 是否包含 token 文件、\`DESIGN-SPEC.md\`\`index.tsx\`\n`;
markdown += `- 文档检查:\`src/docs/\` 是否按需补充\n`;
markdown += `- 数据检查:\`src/database/\` JSON 结构是否满足约束\n\n`;
markdown += `## 📎 产物索引\n\n`;
markdown += `- 任务清单:\`${targetInfo.tasksFileName}\`\n`;
markdown += `- 分析报告:\`${targetInfo.analysisFileName}\`\n`;
const mdPath = path.join(targetInfo.outputDir, targetInfo.tasksFileName);
fs.writeFileSync(mdPath, markdown);
return { reportPath, mdPath };
}
function parseArgs(rawArgs) {
const args = [...rawArgs];
const help = args.length === 0 || args.includes('--help') || args.includes('-h');
if (help) {
return { help: true };
}
let projectDirArg = '';
let outputNameArg = '';
let targetType = 'prototypes';
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--target-type') {
const nextValue = args[index + 1];
if (!nextValue) {
throw new Error('参数 --target-type 缺少值');
}
targetType = String(nextValue).trim();
index += 1;
continue;
}
if (!projectDirArg) {
projectDirArg = arg;
continue;
}
if (!outputNameArg) {
outputNameArg = arg;
continue;
}
}
if (!projectDirArg) {
throw new Error('缺少 <ai-studio-project-dir> 参数');
}
if (!Object.prototype.hasOwnProperty.call(TARGET_TYPE_TO_SRC_DIR, targetType)) {
throw new Error(`不支持的 targetType: ${targetType}。可选值: ${Object.keys(TARGET_TYPE_TO_SRC_DIR).join(', ')}`);
}
const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg));
if (!outputName) {
throw new Error('无法生成有效的输出名称,请显式传入 [output-name]');
}
return {
help: false,
projectDirArg,
outputName,
targetType,
};
}
function printHelp() {
console.log(`
AI Studio 项目预处理器
使用方法:
node scripts/ai-studio-converter.mjs <ai-studio-project-dir> [output-name] [--target-type <prototypes|components|themes>]
示例:
node scripts/ai-studio-converter.mjs "temp/my-ai-studio-project" my-page
node scripts/ai-studio-converter.mjs "temp/my-ai-studio-project" brand-theme --target-type themes
功能:
- 完整复制 AI Studio 项目(不修改代码)
- 生成 AI 工作文档(默认 .ai-studio-tasks.md主题模式 .ai-studio-theme-tasks.md
- 生成分析报告(默认 .ai-studio-analysis.json主题模式 .ai-studio-theme-analysis.json
`);
}
async function main() {
let parsed;
try {
parsed = parseArgs(process.argv.slice(2));
} catch (error) {
log(`参数错误: ${error.message}`, 'error');
printHelp();
process.exit(1);
}
if (parsed.help) {
printHelp();
process.exit(0);
}
const aiStudioDir = path.resolve(CONFIG.projectRoot, parsed.projectDirArg);
const targetInfo = getTargetInfo(parsed.targetType, parsed.outputName);
const outputDir = targetInfo.outputDir;
if (!fs.existsSync(aiStudioDir)) {
log(`错误: 找不到目录 ${aiStudioDir}`, 'error');
process.exit(1);
}
const appTsx = path.join(aiStudioDir, 'App.tsx');
const indexHtml = path.join(aiStudioDir, 'index.html');
if (!fs.existsSync(appTsx) && !fs.existsSync(indexHtml)) {
log('错误: 这不是一个有效的 AI Studio 项目(缺少 App.tsx 或 index.html', 'error');
process.exit(1);
}
try {
ensureDir(targetInfo.outputBaseDir);
log(`开始预处理 AI Studio 项目targetType=${parsed.targetType}...`, 'info');
log('步骤 1/4: 复制项目文件...', 'progress');
const fileCount = copyDirectory(aiStudioDir, outputDir);
log(`已复制 ${fileCount} 个文件`, 'info');
log('步骤 2/4: 复制 assets 到目标目录...', 'progress');
const assetsDir = path.join(aiStudioDir, 'assets');
const outputAssetsDir = path.join(outputDir, 'assets');
let assetCount = 0;
if (fs.existsSync(assetsDir)) {
assetCount = copyDirectory(assetsDir, outputAssetsDir);
log(`已复制 ${assetCount} 个资源文件到 ${targetInfo.relativeOutputDir}/assets/`, 'info');
} else {
log('未找到 assets 目录,跳过', 'info');
}
log('步骤 3/4: 分析项目结构...', 'progress');
const analysis = analyzeProject(outputDir);
log(`发现 ${analysis.components.length} 个组件`, 'info');
log('步骤 4/4: 生成任务文档...', 'progress');
const report = buildAnalysisReport(analysis);
const { reportPath, mdPath } = parsed.targetType === 'themes'
? generateThemeTasksDocument(report, targetInfo, parsed.outputName, `temp/${path.basename(aiStudioDir)}`)
: generateDefaultTasksDocument(report, targetInfo, parsed.outputName, `temp/${path.basename(aiStudioDir)}`);
log('✅ 预处理完成!', 'info');
log('', 'info');
log(`📁 ${targetInfo.label}位置: ${targetInfo.relativeOutputDir}/`, 'info');
log(`📋 AI 工作文档: ${path.relative(CONFIG.projectRoot, mdPath)}`, 'info');
log(`📊 详细数据: ${path.relative(CONFIG.projectRoot, reportPath)}`, 'info');
log('', 'info');
log('📈 统计:', 'info');
log(` - 文件数: ${analysis.files.length}`, 'info');
log(` - 组件数: ${analysis.components.length}`, 'info');
log(` - 依赖: ${analysis.dependencies.toInstall?.length || 0}`, 'info');
log('', 'info');
log('🎯 下一步:', 'info');
log(`1. 查看任务文档: cat ${path.relative(CONFIG.projectRoot, mdPath)}`, 'info');
log(parsed.targetType === 'themes'
? '2. 让 AI 按任务单完成主题/文档/数据生成'
: '2. 让 AI 根据任务清单完成转换', 'info');
} catch (error) {
log(`预处理失败: ${error.message}`, 'error');
console.error(error);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,48 @@
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { scanProjectEntries, writeEntriesManifestAtomic } from '../vite-plugins/utils/entriesManifestCore.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const workspaceRoot = path.resolve(__dirname, '..');
const entriesPath = path.resolve(workspaceRoot, '.axhub/make/entries.json');
// Always rescan before build to keep entries manifest fresh and deterministic.
const scanned = scanProjectEntries(workspaceRoot, ['components', 'prototypes', 'themes']);
const entries = writeEntriesManifestAtomic(workspaceRoot, scanned);
if (!fs.existsSync(entriesPath)) {
console.error('.axhub/make/entries.json 写入失败,无法继续构建。');
process.exit(1);
}
const jsEntries = entries.js || {};
const entryKeys = Object.keys(jsEntries);
if (entryKeys.length === 0) {
console.log('未发现 JS 入口,跳过构建。');
process.exit(0);
}
const distDir = path.resolve(workspaceRoot, 'dist');
if (fs.existsSync(distDir)) {
fs.rmSync(distDir, { recursive: true, force: true });
}
for (const key of entryKeys) {
console.log(`\n==== 构建入口: ${key} ====\n`);
const result = spawnSync('npx', ['vite', 'build'], {
cwd: workspaceRoot,
env: { ...process.env, ENTRY_KEY: key },
stdio: 'inherit'
});
if (result.status !== 0) {
console.error(`构建 ${key} 失败,退出码 ${result.status}`);
process.exit(result.status ?? 1);
}
}
console.log('\n所有入口构建完成 ✅');

View File

@@ -0,0 +1,778 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { decodeBinarySchema, compileSchema } from '../../../packages/axhub-export-core/node_modules/kiwi-schema/kiwi-esm.js';
import { inflateRaw } from '../../../packages/axhub-export-core/node_modules/pako/dist/pako.esm.mjs';
const PRELUDE_LENGTH = 8;
const VERSION_OFFSET = PRELUDE_LENGTH;
const PARTS_OFFSET = VERSION_OFFSET + 4;
const DEFAULT_SOURCE_ROOT = 'src';
const MANIFEST_FILENAME = 'canvas.code-manifest.json';
function printUsage() {
console.log(`Usage:
node scripts/canvas-fig-sync.mjs inspect --fig <canvas.fig> [--manifest <file>]
node scripts/canvas-fig-sync.mjs extract --fig <canvas.fig> --out <project-dir> [--source-root src] [--manifest <file>]
node scripts/canvas-fig-sync.mjs pack --fig <canvas.fig> --from <project-dir> [--source-root src] [--out <new-canvas.fig>] [--manifest <file>] [--prune-missing] [--sanitize-for-export]
`);
}
function parseArgs(argv) {
const [command, ...rest] = argv;
if (!command || command === '--help' || command === '-h') {
return { command: 'help', options: {} };
}
const options = {};
for (let index = 0; index < rest.length; index += 1) {
const token = rest[index];
if (!token.startsWith('--')) {
throw new Error(`Unexpected argument: ${token}`);
}
const key = token.slice(2);
const value = rest[index + 1];
if (!value || value.startsWith('--')) {
options[key] = true;
continue;
}
options[key] = value;
index += 1;
}
return { command, options };
}
function getRequiredOption(options, key) {
const value = options[key];
if (!value) {
throw new Error(`Missing required option --${key}`);
}
return value;
}
function resolvePath(value) {
return path.resolve(value);
}
function sha1(content) {
return crypto.createHash('sha1').update(content).digest('hex');
}
function toPosixPath(value) {
return value.split(path.sep).join(path.posix.sep);
}
function normalizeRelativePath(value) {
const normalized = path.posix.normalize(value).replace(/^\/+/, '');
if (!normalized || normalized === '.') {
return '';
}
if (normalized.startsWith('../') || normalized === '..') {
throw new Error(`Unsafe relative path: ${value}`);
}
return normalized;
}
function joinLogicalPath(codeFilePath, name) {
const basePath = codeFilePath ? normalizeRelativePath(toPosixPath(codeFilePath)) : '';
const fileName = normalizeRelativePath(name);
return basePath ? path.posix.join(basePath, fileName) : fileName;
}
function normalizeSourceRoot(sourceRoot) {
return normalizeRelativePath(toPosixPath(sourceRoot || DEFAULT_SOURCE_ROOT));
}
function guidToString(value) {
if (!value || typeof value !== 'object') {
return '';
}
if (value.guid && typeof value.guid === 'object') {
return guidToString(value.guid);
}
if (typeof value.sessionID === 'number' && typeof value.localID === 'number') {
return `${value.sessionID}:${value.localID}`;
}
return '';
}
function createCollaborativeSourceCode(sourceCode, sessionID = 1) {
const contentBuffer = Uint8Array.from(Buffer.from(sourceCode, 'utf8'));
return {
historyOpsWithIds: [
{
firstId: { sessionID, counterID: 1 },
runLength: sourceCode.length,
parentIds: [],
},
],
historyOpsWithLoc: [
{
type: 'INSERT',
range: { startIndex: 0, endIndexExclusive: sourceCode.length },
contentBytesInBuffer: { startIndex: 0, endIndexExclusive: contentBuffer.length },
},
],
historyStringContentBuffer: contentBuffer,
};
}
function resolveProjectFilePath(projectDir, sourceRoot, logicalPath) {
const root = normalizeSourceRoot(sourceRoot);
const relativePath = root ? path.posix.join(root, logicalPath) : logicalPath;
return path.resolve(projectDir, ...relativePath.split('/'));
}
function ensureParentDirectory(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function parseArchive(buffer) {
if (buffer.byteLength < PARTS_OFFSET) {
throw new Error('Archive is too small.');
}
const prelude = Buffer.from(buffer.subarray(0, PRELUDE_LENGTH)).toString('utf8');
const version = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint32(
VERSION_OFFSET,
true,
);
const parts = [];
let offset = PARTS_OFFSET;
while (offset + 4 <= buffer.byteLength) {
const size = new DataView(buffer.buffer, buffer.byteOffset + offset, 4).getUint32(0, true);
offset += 4;
if (offset + size > buffer.byteLength) {
throw new Error(`Invalid archive bounds at offset ${offset}.`);
}
parts.push(buffer.subarray(offset, offset + size));
offset += size;
}
return { prelude, version, parts };
}
function encodeArchive({ prelude, version, parts }) {
const totalLength =
PARTS_OFFSET + parts.reduce((sum, part) => sum + 4 + Buffer.byteLength(part), 0);
const output = Buffer.alloc(totalLength);
output.write(prelude, 0, PRELUDE_LENGTH, 'utf8');
output.writeUInt32LE(version, VERSION_OFFSET);
let offset = PARTS_OFFSET;
for (const part of parts) {
const buffer = Buffer.from(part);
output.writeUInt32LE(buffer.length, offset);
offset += 4;
buffer.copy(output, offset);
offset += buffer.length;
}
return output;
}
function loadCanvasFig(figPath) {
const archiveBytes = new Uint8Array(fs.readFileSync(figPath));
const { prelude, version, parts } = parseArchive(archiveBytes);
if (prelude !== 'fig-make') {
throw new Error(`Unsupported prelude: ${prelude}`);
}
if (parts.length !== 2) {
throw new Error(`Expected 2 archive parts, got ${parts.length}`);
}
const schemaPart = parts[0];
const messagePart = parts[1];
const schemaBytes = inflateRaw(schemaPart);
const schema = decodeBinarySchema(schemaBytes);
const compiled = compileSchema(schema);
const messageBytes = new Uint8Array(zlib.zstdDecompressSync(Buffer.from(messagePart)));
const message = compiled.decodeMessage(messageBytes);
return {
figPath,
prelude,
version,
schemaPart,
compiled,
message,
};
}
function buildCodeFileEntries(message) {
const codeEntries = [];
for (const [nodeChangeIndex, node] of (message.nodeChanges || []).entries()) {
if (node?.type !== 'CODE_FILE') {
continue;
}
const name = node.name || `unnamed-${nodeChangeIndex}`;
const codeFilePath = node.codeFilePath || '';
const logicalPath = joinLogicalPath(codeFilePath, name);
const sourceCode = node.sourceCode || '';
codeEntries.push({
nodeChangeIndex,
node,
name,
codeFilePath,
logicalPath,
sourceCode,
sourceCodeSha1: sha1(sourceCode),
});
}
const duplicateMap = new Map();
for (const entry of codeEntries) {
duplicateMap.set(entry.logicalPath, (duplicateMap.get(entry.logicalPath) || 0) + 1);
}
return codeEntries.map((entry) => ({
...entry,
isDuplicate: (duplicateMap.get(entry.logicalPath) || 0) > 1,
duplicateCount: duplicateMap.get(entry.logicalPath) || 1,
}));
}
function summarizeEntries(entries) {
const pathCounts = new Map();
const duplicateGroups = [];
const grouped = new Map();
for (const entry of entries) {
const codeFilePath = entry.codeFilePath || '(root)';
pathCounts.set(codeFilePath, (pathCounts.get(codeFilePath) || 0) + 1);
if (!grouped.has(entry.logicalPath)) {
grouped.set(entry.logicalPath, []);
}
grouped.get(entry.logicalPath).push(entry.nodeChangeIndex);
}
for (const [logicalPath, indices] of grouped.entries()) {
if (indices.length > 1) {
duplicateGroups.push({
logicalPath,
nodeChangeIndices: indices,
});
}
}
return {
totalCodeFiles: entries.length,
pathCounts: Object.fromEntries([...pathCounts.entries()].sort(([a], [b]) => a.localeCompare(b))),
duplicateGroups,
};
}
function collectCodeGraph(message) {
const codeFiles = [];
const codeFilesByGuid = new Map();
const codeFilesByLogicalPath = new Map();
for (const [nodeChangeIndex, node] of (message.nodeChanges || []).entries()) {
if (node?.type !== 'CODE_FILE') {
continue;
}
const logicalPath = joinLogicalPath(node.codeFilePath || '', node.name || `unnamed-${nodeChangeIndex}`);
const guid = guidToString(node.guid);
const codeFile = {
nodeChangeIndex,
node,
guid,
logicalPath,
};
codeFiles.push(codeFile);
if (guid) {
codeFilesByGuid.set(guid, codeFile);
}
if (!codeFilesByLogicalPath.has(logicalPath)) {
codeFilesByLogicalPath.set(logicalPath, []);
}
codeFilesByLogicalPath.get(logicalPath).push(codeFile);
}
return {
codeFiles,
codeFilesByGuid,
codeFilesByLogicalPath,
};
}
function listRelativeImportSpecifiers(sourceCode) {
const specifiers = new Set();
const patterns = [
/import\s+[^'"]*?from\s+['"]([^'"]+)['"]/g,
/import\s*['"]([^'"]+)['"]/g,
/export\s+[^'"]*?from\s+['"]([^'"]+)['"]/g,
/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
];
for (const pattern of patterns) {
pattern.lastIndex = 0;
for (let match = pattern.exec(sourceCode); match; match = pattern.exec(sourceCode)) {
const specifier = match[1];
if (specifier && specifier.startsWith('.')) {
specifiers.add(specifier);
}
}
}
return [...specifiers];
}
function resolveImportToLogicalPath(fromLogicalPath, specifier, availableLogicalPaths) {
const baseDir = path.posix.dirname(fromLogicalPath);
const normalizedBaseDir = baseDir === '.' ? '' : baseDir;
const resolvedBase = path.posix.normalize(
normalizedBaseDir ? path.posix.join(normalizedBaseDir, specifier) : specifier,
);
const candidates = [
resolvedBase,
`${resolvedBase}.ts`,
`${resolvedBase}.tsx`,
`${resolvedBase}.js`,
`${resolvedBase}.jsx`,
`${resolvedBase}.css`,
path.posix.join(resolvedBase, 'index.ts'),
path.posix.join(resolvedBase, 'index.tsx'),
path.posix.join(resolvedBase, 'index.js'),
path.posix.join(resolvedBase, 'index.jsx'),
path.posix.join(resolvedBase, 'index.css'),
];
for (const candidate of candidates) {
const normalized = normalizeRelativePath(candidate);
if (availableLogicalPaths.has(normalized)) {
return normalized;
}
}
return null;
}
function sanitizeForExport(message) {
const warnings = [];
const codeGraph = collectCodeGraph(message);
const availableLogicalPaths = new Set(codeGraph.codeFiles.map((codeFile) => codeFile.logicalPath));
const existingCodeFileGuids = new Set(codeGraph.codeFiles.map((codeFile) => codeFile.guid).filter(Boolean));
let clearedChatMessageCount = 0;
let clearedLibraryCount = 0;
let rebuiltImportReferenceCount = 0;
let prunedDanglingImportReferenceCount = 0;
let prunedCodeComponentCount = 0;
let clearedCodeInstanceSnapshotCount = 0;
for (const node of message.nodeChanges || []) {
if (node?.type !== 'CODE_LIBRARY') {
continue;
}
const chatMessages = Array.isArray(node.chatMessages) ? node.chatMessages : [];
if (chatMessages.length > 0) {
clearedChatMessageCount += chatMessages.length;
node.chatMessages = [];
}
if (node.chatCompressionState !== undefined) {
delete node.chatCompressionState;
}
clearedLibraryCount += 1;
}
for (const node of message.nodeChanges || []) {
if (node?.type !== 'CODE_INSTANCE') {
continue;
}
if (node.codeSnapshot !== undefined) {
delete node.codeSnapshot;
clearedCodeInstanceSnapshotCount += 1;
}
}
for (const codeFile of codeGraph.codeFiles) {
const importedLogicalPaths = [];
for (const specifier of listRelativeImportSpecifiers(codeFile.node.sourceCode || '')) {
const resolved = resolveImportToLogicalPath(codeFile.logicalPath, specifier, availableLogicalPaths);
if (resolved) {
importedLogicalPaths.push(resolved);
continue;
}
warnings.push(`Unresolved relative import ${specifier} in ${codeFile.logicalPath}; omitted from importedCodeFiles.`);
}
const uniqueImportedPaths = [...new Set(importedLogicalPaths)].filter(
(logicalPath) => logicalPath !== codeFile.logicalPath,
);
const nextEntries = [];
for (const logicalPath of uniqueImportedPaths) {
const target = codeGraph.codeFilesByLogicalPath.get(logicalPath)?.[0];
if (!target?.guid) {
continue;
}
nextEntries.push({
codeFileId: {
guid: target.node.guid,
},
});
}
const previousEntries = Array.isArray(codeFile.node.importedCodeFiles?.entries)
? codeFile.node.importedCodeFiles.entries
: [];
const previousGuidCount = previousEntries.filter((entry) => existingCodeFileGuids.has(guidToString(entry?.codeFileId))).length;
prunedDanglingImportReferenceCount += Math.max(0, previousEntries.length - previousGuidCount);
rebuiltImportReferenceCount += nextEntries.length;
if (nextEntries.length > 0) {
codeFile.node.importedCodeFiles = { entries: nextEntries };
} else {
delete codeFile.node.importedCodeFiles;
}
}
message.nodeChanges = (message.nodeChanges || []).filter((node) => {
if (node?.type !== 'CODE_COMPONENT') {
return true;
}
const exportedFromGuid = guidToString(node.exportedFromCodeFileId);
if (exportedFromGuid && existingCodeFileGuids.has(exportedFromGuid)) {
return true;
}
prunedCodeComponentCount += 1;
warnings.push(`Pruned CODE_COMPONENT ${node.name || '(unnamed)'} because exportedFromCodeFileId no longer exists.`);
return false;
});
return {
warnings,
clearedChatMessageCount,
clearedLibraryCount,
clearedCodeInstanceSnapshotCount,
rebuiltImportReferenceCount,
prunedDanglingImportReferenceCount,
prunedCodeComponentCount,
};
}
function buildBaseManifest(command, figData, entries, sourceRoot) {
const summary = summarizeEntries(entries);
return {
command,
generatedAt: new Date().toISOString(),
figPath: figData.figPath,
archive: {
prelude: figData.prelude,
version: figData.version,
parts: 2,
},
sourceRoot: normalizeSourceRoot(sourceRoot),
summary,
};
}
function defaultManifestPath(command, figPath, options) {
if (options.manifest) {
return resolvePath(options.manifest);
}
const figBaseName = path.basename(
command === 'pack' ? resolvePath(options.out || options.fig) : resolvePath(options.fig),
path.extname(command === 'pack' ? resolvePath(options.out || options.fig) : resolvePath(options.fig)),
);
if (command === 'extract') {
return path.resolve(resolvePath(options.out), MANIFEST_FILENAME);
}
const baseDir =
command === 'pack'
? path.dirname(resolvePath(options.out || options.fig))
: path.dirname(resolvePath(options.fig));
return path.resolve(baseDir, `${figBaseName}.code-manifest.json`);
}
function writeManifest(manifestPath, manifest) {
ensureParentDirectory(manifestPath);
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
}
function extractCommand(options) {
const figPath = resolvePath(getRequiredOption(options, 'fig'));
const outputDir = resolvePath(getRequiredOption(options, 'out'));
const sourceRoot = options['source-root'] || DEFAULT_SOURCE_ROOT;
const manifestPath = defaultManifestPath('extract', figPath, options);
const figData = loadCanvasFig(figPath);
const entries = buildCodeFileEntries(figData.message);
const latestByLogicalPath = new Map();
for (const entry of entries) {
latestByLogicalPath.set(entry.logicalPath, entry);
}
const manifestEntries = [];
for (const entry of entries) {
const outputFilePath = resolveProjectFilePath(outputDir, sourceRoot, entry.logicalPath);
const isLatest = latestByLogicalPath.get(entry.logicalPath)?.nodeChangeIndex === entry.nodeChangeIndex;
const status = isLatest ? 'written' : 'shadowed-by-later-duplicate';
if (isLatest) {
ensureParentDirectory(outputFilePath);
fs.writeFileSync(outputFilePath, entry.sourceCode, 'utf8');
}
manifestEntries.push({
nodeChangeIndex: entry.nodeChangeIndex,
name: entry.name,
codeFilePath: entry.codeFilePath || null,
logicalPath: entry.logicalPath,
sourceCodeSha1: entry.sourceCodeSha1,
isDuplicate: entry.isDuplicate,
duplicateCount: entry.duplicateCount,
extractedPath: outputFilePath,
extractStatus: status,
});
}
const manifest = {
...buildBaseManifest('extract', figData, entries, sourceRoot),
outputDirectory: outputDir,
entries: manifestEntries,
};
writeManifest(manifestPath, manifest);
const duplicateCount = manifest.summary.duplicateGroups.length;
console.log(`Extracted ${manifest.summary.totalCodeFiles} CODE_FILE nodes from ${path.basename(figPath)}`);
console.log(`Source root: ${normalizeSourceRoot(sourceRoot) || '.'}`);
console.log(`Output directory: ${outputDir}`);
console.log(`Manifest: ${manifestPath}`);
if (duplicateCount > 0) {
console.warn(`Warning: ${duplicateCount} duplicate logical path group(s) were resolved with last-wins semantics.`);
}
}
function inspectCommand(options) {
const figPath = resolvePath(getRequiredOption(options, 'fig'));
const manifestPath = defaultManifestPath('inspect', figPath, options);
const figData = loadCanvasFig(figPath);
const entries = buildCodeFileEntries(figData.message);
const manifest = {
...buildBaseManifest('inspect', figData, entries, DEFAULT_SOURCE_ROOT),
entries: entries.map((entry) => ({
nodeChangeIndex: entry.nodeChangeIndex,
name: entry.name,
codeFilePath: entry.codeFilePath || null,
logicalPath: entry.logicalPath,
sourceCodeSha1: entry.sourceCodeSha1,
isDuplicate: entry.isDuplicate,
duplicateCount: entry.duplicateCount,
})),
};
writeManifest(manifestPath, manifest);
console.log(`FIG: ${figPath}`);
console.log(`Prelude: ${figData.prelude}`);
console.log(`Version: ${figData.version}`);
console.log(`CODE_FILE nodes: ${manifest.summary.totalCodeFiles}`);
console.log('Path distribution:');
for (const [codeFilePath, count] of Object.entries(manifest.summary.pathCounts)) {
console.log(` ${codeFilePath}: ${count}`);
}
if (manifest.summary.duplicateGroups.length > 0) {
console.log('Duplicate logical paths:');
for (const duplicate of manifest.summary.duplicateGroups) {
console.log(` ${duplicate.logicalPath} -> [${duplicate.nodeChangeIndices.join(', ')}]`);
}
} else {
console.log('Duplicate logical paths: none');
}
console.log(`Manifest: ${manifestPath}`);
}
function packCommand(options) {
const figPath = resolvePath(getRequiredOption(options, 'fig'));
const projectDir = resolvePath(getRequiredOption(options, 'from'));
const sourceRoot = options['source-root'] || DEFAULT_SOURCE_ROOT;
const outputFigPath = resolvePath(options.out || figPath);
const manifestPath = defaultManifestPath('pack', outputFigPath, options);
const pruneMissing = options['prune-missing'] === true || options['prune-missing'] === 'true';
const sanitizeForExportMode =
options['sanitize-for-export'] === true || options['sanitize-for-export'] === 'true';
const figData = loadCanvasFig(figPath);
const entries = buildCodeFileEntries(figData.message);
const byLogicalPath = new Map();
for (const entry of entries) {
if (!byLogicalPath.has(entry.logicalPath)) {
byLogicalPath.set(entry.logicalPath, []);
}
byLogicalPath.get(entry.logicalPath).push(entry);
}
const manifestEntries = [];
const warnings = [];
const updatedLogicalPaths = new Set();
const prunedLogicalPaths = new Set();
const prunedNodeChangeIndices = new Set();
for (const [logicalPath, group] of byLogicalPath.entries()) {
const projectFilePath = resolveProjectFilePath(projectDir, sourceRoot, logicalPath);
const exists = fs.existsSync(projectFilePath);
if (!exists) {
if (pruneMissing) {
warnings.push(`Missing source file for ${logicalPath}; pruned ${group.length} CODE_FILE node(s).`);
prunedLogicalPaths.add(logicalPath);
for (const entry of group) {
prunedNodeChangeIndices.add(entry.nodeChangeIndex);
manifestEntries.push({
nodeChangeIndex: entry.nodeChangeIndex,
name: entry.name,
codeFilePath: entry.codeFilePath || null,
logicalPath,
sourceCodeSha1: entry.sourceCodeSha1,
isDuplicate: entry.isDuplicate,
duplicateCount: entry.duplicateCount,
packedPath: projectFilePath,
packStatus: 'pruned-missing-file',
});
}
continue;
}
warnings.push(`Missing source file for ${logicalPath}; preserved original canvas.fig content.`);
for (const entry of group) {
manifestEntries.push({
nodeChangeIndex: entry.nodeChangeIndex,
name: entry.name,
codeFilePath: entry.codeFilePath || null,
logicalPath,
sourceCodeSha1: entry.sourceCodeSha1,
isDuplicate: entry.isDuplicate,
duplicateCount: entry.duplicateCount,
packedPath: projectFilePath,
packStatus: 'preserved-missing-file',
});
}
continue;
}
const nextSource = fs.readFileSync(projectFilePath, 'utf8');
const nextSha1 = sha1(nextSource);
if (group.length > 1) {
warnings.push(`Duplicate logical path ${logicalPath} updated across ${group.length} CODE_FILE nodes.`);
}
for (const entry of group) {
entry.node.sourceCode = nextSource;
entry.node.collaborativeSourceCode = createCollaborativeSourceCode(
nextSource,
entry.node.guid?.sessionID ?? 1,
);
updatedLogicalPaths.add(logicalPath);
manifestEntries.push({
nodeChangeIndex: entry.nodeChangeIndex,
name: entry.name,
codeFilePath: entry.codeFilePath || null,
logicalPath,
sourceCodeSha1: nextSha1,
isDuplicate: entry.isDuplicate,
duplicateCount: entry.duplicateCount,
packedPath: projectFilePath,
packStatus: 'updated-from-disk',
});
}
}
if (pruneMissing && prunedNodeChangeIndices.size > 0) {
figData.message.nodeChanges = (figData.message.nodeChanges || []).filter((_, index) => !prunedNodeChangeIndices.has(index));
}
let exportSanitization = null;
if (sanitizeForExportMode) {
exportSanitization = sanitizeForExport(figData.message);
warnings.push(...exportSanitization.warnings);
}
const encodedMessage = figData.compiled.encodeMessage(figData.message);
const compressedMessage = zlib.zstdCompressSync(Buffer.from(encodedMessage));
const encodedArchive = encodeArchive({
prelude: figData.prelude,
version: figData.version,
parts: [figData.schemaPart, compressedMessage],
});
ensureParentDirectory(outputFigPath);
fs.writeFileSync(outputFigPath, encodedArchive);
const finalEntries = buildCodeFileEntries(figData.message);
const manifest = {
...buildBaseManifest('pack', figData, finalEntries, sourceRoot),
projectDirectory: projectDir,
outputFigPath,
updatedLogicalPathCount: updatedLogicalPaths.size,
prunedLogicalPathCount: prunedLogicalPaths.size,
sanitizeForExport: exportSanitization,
warnings,
entries: manifestEntries,
};
writeManifest(manifestPath, manifest);
console.log(`Packed ${updatedLogicalPaths.size} logical path(s) into ${outputFigPath}`);
if (prunedLogicalPaths.size > 0) {
console.log(`Pruned ${prunedLogicalPaths.size} logical path(s) without source files.`);
}
console.log(`Manifest: ${manifestPath}`);
if (warnings.length > 0) {
for (const warning of warnings) {
console.warn(`Warning: ${warning}`);
}
}
}
function main() {
try {
const { command, options } = parseArgs(process.argv.slice(2));
switch (command) {
case 'help':
printUsage();
return;
case 'inspect':
inspectCommand(options);
return;
case 'extract':
extractCommand(options);
return;
case 'pack':
packCommand(options);
return;
default:
throw new Error(`Unknown command: ${command}`);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
printUsage();
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,879 @@
#!/usr/bin/env node
/**
* =====================================================
* CLI: check-app-ready
*
* 功能:
* - AI 调用,检测 Vite dev server 和页面状态
* - 不依赖页面注入
* - 捕获已存在和后续构建/热更新错误
* - 页面可访问即 READY出现错误即 ERROR
* - 超时返回 TIMEOUT
* - 默认包含构建校验,可通过 --skip-build 跳过
*
* 使用:
* node scripts/check-app-ready.mjs [页面路径]
* 例如node scripts/check-app-ready.mjs /components/button
* node scripts/check-app-ready.mjs /prototypes/home
*
* 跳过构建校验:
* node scripts/check-app-ready.mjs --skip-build /components/button
*
* 输出JSON
* {
* status: "READY" | "ERROR" | "TIMEOUT",
* phase: "server|build|page|done",
* message: "...",
* url: "http://localhost:51720/components/button",
* errors: [...],
* logs: [...],
* buildCheck?: { status: "SUCCESS" | "FAILED" | "SKIPPED", errors: [...], logs: [...] }
* lintCheck?: { status: "SUCCESS" | "FAILED" | "SKIPPED", errors: [...], logs: [...] }
* typeCheck?: { status: "SUCCESS" | "FAILED" | "SKIPPED", errors: [...], logs: [...] }
* checks?: [{ name: "lint|typecheck|build", status: "...", message: "...", errors: [...] }]
* homeUrl?: "http://localhost:51720"
* targetUrl?: "http://localhost:51720/components/button"
* targetPath?: "http://localhost:51720/prototypes/ref-app-home/index.html"
* }
* =====================================================
*/
import { spawn } from 'node:child_process'
import { setTimeout as sleep } from 'node:timers/promises'
import process from 'node:process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { decodeOutput, getPreferredNpmCommand, getPreferredNpxCommand } from './utils/command-runtime.mjs'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const APP_ROOT = path.resolve(__dirname, '..')
/* ================= 配置 ================= */
// 解析命令行参数
const args = process.argv.slice(2);
const skipBuild = args.includes('--skip-build');
const pagePath = args.find(arg => !arg.startsWith('--')) || '/';
const CONFIG = {
devCommand: ['run', 'dev'], // 启动 Vite 的命令参数
devServerInfoPath: path.resolve(__dirname, '../.axhub/make/.dev-server-info.json'), // 开发服务器信息文件
pagePath, // 目标页面路径(从命令行参数获取)
pollIntervalMs: 500, // 页面轮询间隔
stableCheckMs: 1000, // 错误稳定判断时间
timeoutMs: 30_000, // 总超时
skipBuild // 是否跳过构建校验
}
/* ================= 工具函数 ================= */
function jsonExit(payload, code = 0) {
process.stdout.write(JSON.stringify(payload, null, 2))
process.exit(code)
}
/**
* 尝试通过 HTTP 请求获取页面内容,检查是否有错误信息
*/
async function checkPageForErrors(url) {
try {
const res = await fetch(url, { method: 'GET' })
const text = await res.text()
// Only treat the HTML as an error page when Vite's overlay is present.
// The app template includes global error handlers with object literals like
// `{ error: event.error }`, which would otherwise cause false positives.
const hasViteOverlay =
text.includes('vite-error-overlay') ||
text.includes('__vite_error_overlay__') ||
/\[plugin:vite:/i.test(text) ||
/Transform failed/i.test(text)
if (!hasViteOverlay) return []
const errorPatterns = [
/\bError:\s*([^\n]+)/,
/\bSyntaxError:\s*([^\n]+)/,
/\bReferenceError:\s*([^\n]+)/,
/\[plugin:vite:[^\]]+\]\s*([^\n]+)/i,
/Transform failed/i
]
for (const pattern of errorPatterns) {
const match = text.match(pattern)
if (match) return [match[1] || match[0]]
}
return ['Detected Vite error overlay but could not extract message']
} catch (err) {
return []
}
}
async function isServerAlive(url) {
try {
const res = await fetch(url, { method: 'GET' })
return res.ok
} catch {
return false
}
}
/**
* 读取开发服务器信息
* 优先从 .axhub/make/.dev-server-info.json 读取实际运行的端口
*/
function getServerInfo() {
try {
if (fs.existsSync(CONFIG.devServerInfoPath)) {
const info = JSON.parse(fs.readFileSync(CONFIG.devServerInfoPath, 'utf8'))
return {
port: info.port,
host: info.host || 'localhost',
localIP: info.localIP || 'localhost'
}
}
} catch (err) {
logs.push(`Failed to read .axhub/make/.dev-server-info.json: ${err.message}`)
}
// 如果没有端口信息,返回 null 表示需要等待服务器启动
return null
}
/**
* 生成服务器首页 URL
* 使用 localhost 而不是 0.0.0.0,因为浏览器无法访问 0.0.0.0
*/
function getHomeUrl(serverInfo) {
// 如果 host 是 0.0.0.0,使用 localhost 替代
const host = serverInfo.host === '0.0.0.0' ? 'localhost' : serverInfo.host
return `http://${host}:${serverInfo.port}`
}
/**
* 获取可访问的 host
* 将 0.0.0.0 转换为 localhost因为浏览器无法直接访问 0.0.0.0
*/
function getAccessibleHost(serverInfo) {
return serverInfo.host === '0.0.0.0' ? 'localhost' : serverInfo.host
}
function getTargetUrl(serverInfo, targetPath) {
const host = getAccessibleHost(serverInfo)
return `http://${host}:${serverInfo.port}${targetPath}`
}
function getEntryHtmlPath(targetPath) {
const normalized = targetPath.startsWith('/') ? targetPath : `/${targetPath}`
if (normalized.endsWith('.html')) return normalized
if (normalized.endsWith('/')) return `${normalized}index.html`
return `${normalized}/index.html`
}
/* ================= 全局状态 ================= */
let logs = []
let errors = []
let lastErrorTime = 0
let errorCache = new Set() // 用于去重错误信息
/* ================= 阶段 1启动或 attach Vite ================= */
function startOrAttachVite() {
logs.push('Checking Vite server...')
const npmCommand = getPreferredNpmCommand()
const child = spawn(npmCommand, CONFIG.devCommand, {
stdio: ['ignore', 'pipe', 'pipe'],
cwd: APP_ROOT,
shell: false,
})
child.stdout.on('data', (data) => {
const text = decodeOutput(data).trim()
if (text) logs.push(text)
// 检测构建错误
if (/error/i.test(text) || /failed to compile/i.test(text)) {
errors.push(text)
lastErrorTime = Date.now()
}
})
child.stderr.on('data', (data) => {
const text = decodeOutput(data).trim()
if (text) {
// 过滤掉一些正常的警告信息
if (!/deprecated|experimental/i.test(text)) {
errors.push(text)
lastErrorTime = Date.now()
}
logs.push(text)
}
})
child.on('error', (err) => {
errors.push(`Process error: ${err.message}`)
lastErrorTime = Date.now()
})
return child
}
/* ================= 阶段 2轮询页面可访问性 ================= */
async function waitForPage(url) {
const start = Date.now()
let lastCheckTime = 0
while (Date.now() - start < CONFIG.timeoutMs) {
const now = Date.now()
// 每隔一段时间尝试获取错误信息(即使页面不可访问)
if (now - lastCheckTime > 2000) {
const pageErrors = await checkPageForErrors(url)
if (pageErrors.length > 0) {
// 去重:只添加未见过的错误
pageErrors.forEach(err => {
const errorKey = err.substring(0, 200) // 使用前200个字符作为唯一标识
if (!errorCache.has(errorKey)) {
errorCache.add(errorKey)
errors.push(err)
}
})
}
lastCheckTime = now
}
if (await isServerAlive(url)) return true
await sleep(CONFIG.pollIntervalMs)
}
return false
}
/* ================= 阶段 3等待稳定状态 ================= */
async function waitForStable(pageUrl) {
const startTime = Date.now()
while (Date.now() - startTime < CONFIG.timeoutMs) {
const now = Date.now()
// 页面可访问
const pageOk = await isServerAlive(pageUrl)
// 如果页面可访问,尝试检查页面内容中的错误
if (pageOk) {
const pageErrors = await checkPageForErrors(pageUrl)
if (pageErrors.length > 0) {
return {
status: 'ERROR',
phase: 'build',
message: 'Detected error in page content',
url: pageUrl,
errors: pageErrors,
logs
}
}
}
// 错误稳定:最近 stableCheckMs 内没有新的错误
const stable = (now - lastErrorTime) > CONFIG.stableCheckMs
if (!pageOk) {
// 页面不可访问,继续轮询
await sleep(CONFIG.pollIntervalMs)
continue
}
if (errors.length > 0) {
return {
status: 'ERROR',
phase: 'build',
message: 'Detected Vite build/runtime error',
url: pageUrl,
errors,
logs
}
}
if (pageOk && stable) {
return {
status: 'READY',
phase: 'done',
message: 'Page ready and stable',
url: pageUrl,
errors: [],
logs
}
}
await sleep(CONFIG.pollIntervalMs)
}
return {
status: 'TIMEOUT',
phase: 'server',
message: 'Timeout waiting for page/stable state',
url: pageUrl,
errors,
logs
}
}
/**
* 为结果添加服务器首页信息
*/
function addUrls(result, serverInfo) {
if (!serverInfo) {
return {
...result,
homeUrl: null,
targetUrl: null,
targetPath: null
}
}
const entryHtmlPath = getEntryHtmlPath(CONFIG.pagePath)
return {
...result,
homeUrl: getHomeUrl(serverInfo),
targetUrl: getTargetUrl(serverInfo, CONFIG.pagePath),
targetPath: getTargetUrl(serverInfo, entryHtmlPath)
}
}
function readPackageJson() {
const pkgPath = path.resolve(APP_ROOT, 'package.json')
try {
return JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
} catch (err) {
logs.push(`Failed to read package.json: ${err.message}`)
return null
}
}
function getScriptCommand(pkgJson, scriptName) {
if (!pkgJson || !pkgJson.scripts) return null
return pkgJson.scripts[scriptName] || null
}
function hasEslintConfig(pkgJson) {
if (pkgJson && pkgJson.eslintConfig) return true
const configFiles = [
'.eslintrc',
'.eslintrc.js',
'.eslintrc.cjs',
'.eslintrc.json',
'.eslintrc.yaml',
'.eslintrc.yml',
'eslint.config.js',
'eslint.config.cjs',
'eslint.config.mjs'
]
return configFiles.some((file) => fs.existsSync(path.resolve(APP_ROOT, file)))
}
function hasTsConfig() {
return fs.existsSync(path.resolve(APP_ROOT, 'tsconfig.json'))
}
function toCheckItem(name, result) {
if (!result) return null
return {
name,
status: result.status,
message: result.message,
errors: result.errors || []
}
}
function buildChecksSummary({ lintResult, typeCheckResult, buildResult }) {
return [
toCheckItem('lint', lintResult),
toCheckItem('typecheck', typeCheckResult),
toCheckItem('build', buildResult)
].filter(Boolean)
}
async function runCommandCheck({ label, command, args = [], env = {}, logTag }) {
logs.push(`${label} check started`)
return new Promise((resolve) => {
const checkErrors = []
const checkLogs = []
const resolvedCommand = command === 'npm'
? getPreferredNpmCommand()
: command === 'npx'
? getPreferredNpxCommand()
: command
const proc = spawn(resolvedCommand, args, {
cwd: APP_ROOT,
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe']
})
const appendLog = (line, isError = false) => {
if (!line) return
checkLogs.push(line)
logs.push(`[${logTag}] ${line}`)
if (isError && !/deprecated|experimental/i.test(line)) {
checkErrors.push(line)
}
}
proc.stdout.on('data', (data) => {
appendLog(decodeOutput(data).trim(), false)
})
proc.stderr.on('data', (data) => {
appendLog(decodeOutput(data).trim(), true)
})
proc.on('close', (code) => {
if (code === 0 && checkErrors.length === 0) {
resolve({
status: 'SUCCESS',
message: `${label} completed successfully`,
errors: [],
logs: checkLogs
})
return
}
resolve({
status: 'FAILED',
message: `${label} failed (exit code: ${code})`,
errors: checkErrors.length > 0 ? checkErrors : [`${label} exited with code ${code}`],
logs: checkLogs
})
})
proc.on('error', (err) => {
logs.push(`${label} process error: ${err.message}`)
resolve({
status: 'FAILED',
message: `${label} process error: ${err.message}`,
errors: [err.message],
logs: checkLogs
})
})
})
}
async function runLintCheck() {
const pkgJson = readPackageJson()
const lintScript = getScriptCommand(pkgJson, 'lint')
if (lintScript) {
return runCommandCheck({
label: 'Lint',
command: 'npm',
args: ['run', 'lint'],
logTag: 'LINT'
})
}
if (!hasEslintConfig(pkgJson)) {
return {
status: 'SKIPPED',
message: 'Lint skipped: no eslint config or lint script found',
errors: [],
logs: []
}
}
return runCommandCheck({
label: 'Lint',
command: 'npx',
args: ['eslint', '.'],
logTag: 'LINT'
})
}
async function runTypeCheck() {
const pkgJson = readPackageJson()
const typecheckScript = getScriptCommand(pkgJson, 'typecheck')
if (typecheckScript) {
return runCommandCheck({
label: 'Typecheck',
command: 'npm',
args: ['run', 'typecheck'],
logTag: 'TYPECHECK'
})
}
if (!hasTsConfig()) {
return {
status: 'SKIPPED',
message: 'Typecheck skipped: no tsconfig.json or typecheck script found',
errors: [],
logs: []
}
}
return runCommandCheck({
label: 'Typecheck',
command: 'npx',
args: ['tsc', '--noEmit'],
logTag: 'TYPECHECK'
})
}
/**
* 扫描并更新 .axhub/make/entries.json
* 确保新创建的目录被包含在入口列表中
*/
async function scanEntries() {
logs.push('Scanning entries...')
return new Promise((resolve) => {
const scanProcess = spawn(process.execPath, ['scripts/scan-entries.js'], {
cwd: APP_ROOT,
stdio: ['ignore', 'pipe', 'pipe']
})
scanProcess.stdout.on('data', (data) => {
const text = decodeOutput(data).trim()
if (text) logs.push(`[SCAN] ${text}`)
})
scanProcess.stderr.on('data', (data) => {
const text = decodeOutput(data).trim()
if (text) logs.push(`[SCAN ERROR] ${text}`)
})
scanProcess.on('close', (code) => {
if (code === 0) {
logs.push('Entry scanning completed')
resolve({ success: true })
} else {
logs.push(`Entry scanning failed with exit code ${code}`)
resolve({ success: false })
}
})
scanProcess.on('error', (err) => {
logs.push(`Entry scanning error: ${err.message}`)
resolve({ success: false })
})
})
}
/**
* 执行独立构建校验
* 针对指定的入口 key 执行单独构建,不是全量构建
*/
async function runBuildCheck(entryKey) {
const originalEntryKey = String(entryKey ?? '').trim()
logs.push(`Starting build check for entry: ${originalEntryKey || '(auto)'}`)
// 先扫描入口,确保 .axhub/make/entries.json 是最新的
const scanResult = await scanEntries()
if (!scanResult.success) {
return {
status: 'FAILED',
message: 'Failed to scan entries before build',
errors: ['Entry scanning failed'],
logs: []
}
}
const resolvedEntryKey = originalEntryKey || resolveDefaultEntryKey()
if (!resolvedEntryKey) {
logs.push('Build check skipped: no entry key resolved')
return {
status: 'SKIPPED',
message: 'Build check skipped: no entry key resolved',
errors: [],
logs: []
}
}
return new Promise((resolve) => {
const buildErrors = []
const buildLogs = []
const npxCommand = getPreferredNpxCommand()
// 使用 ENTRY_KEY 环境变量触发单独构建
const buildProcess = spawn(npxCommand, ['vite', 'build'], {
cwd: APP_ROOT,
env: { ...process.env, ENTRY_KEY: resolvedEntryKey },
stdio: ['ignore', 'pipe', 'pipe']
})
buildProcess.stdout.on('data', (data) => {
const text = decodeOutput(data).trim()
if (text) {
buildLogs.push(text)
logs.push(`[BUILD] ${text}`)
}
})
buildProcess.stderr.on('data', (data) => {
const text = decodeOutput(data).trim()
if (text) {
buildLogs.push(text)
logs.push(`[BUILD ERROR] ${text}`)
// 捕获构建错误
if (/error|failed/i.test(text) && !/deprecated|experimental/i.test(text)) {
buildErrors.push(text)
}
}
})
buildProcess.on('close', (code) => {
if (code === 0 && buildErrors.length === 0) {
logs.push(`Build check completed successfully for ${resolvedEntryKey}`)
resolve({
status: 'SUCCESS',
message: `Build completed successfully for ${resolvedEntryKey}`,
errors: [],
logs: buildLogs
})
} else {
logs.push(`Build check failed for ${resolvedEntryKey} with exit code ${code}`)
resolve({
status: 'FAILED',
message: `Build failed for ${resolvedEntryKey} (exit code: ${code})`,
errors: buildErrors.length > 0 ? buildErrors : [`Build process exited with code ${code}`],
logs: buildLogs
})
}
})
buildProcess.on('error', (err) => {
logs.push(`Build process error: ${err.message}`)
resolve({
status: 'FAILED',
message: `Build process error: ${err.message}`,
errors: [err.message],
logs: buildLogs
})
})
})
}
function resolveDefaultEntryKey() {
try {
const entriesPath = path.resolve(APP_ROOT, '.axhub/make/entries.json')
if (!fs.existsSync(entriesPath)) return null
const raw = JSON.parse(fs.readFileSync(entriesPath, 'utf8'))
const jsEntries = raw && typeof raw === 'object' ? (raw.js || {}) : {}
const keys = Object.keys(jsEntries || {}).filter(Boolean).sort((a, b) => a.localeCompare(b))
if (keys.length === 0) return null
const pickFromPrefix = (prefix) => keys.find((k) => k.startsWith(prefix))
return (
pickFromPrefix('prototypes/') ||
pickFromPrefix('components/') ||
pickFromPrefix('themes/') ||
keys[0] ||
null
)
} catch (err) {
logs.push(`Failed to resolve default entry key: ${err.message}`)
return null
}
}
/**
* 从页面路径推断入口 key
* 例如:/components/button -> components/button
*/
function getEntryKeyFromPath(pagePath) {
// 移除开头的斜杠
return pagePath.replace(/^\//, '')
}
/* ================= 主流程 ================= */
async function main() {
try {
// 获取服务器信息
const serverInfo = getServerInfo()
// 如果没有端口信息,等待服务器启动
if (!serverInfo) {
logs.push('Waiting for server to start...')
// 启动服务器并等待
const viteProcess = startOrAttachVite()
// 等待 .axhub/make/.dev-server-info.json 文件生成
const maxWait = 10000 // 10秒
const startTime = Date.now()
let newServerInfo = null
while (Date.now() - startTime < maxWait) {
await new Promise(resolve => setTimeout(resolve, 500))
newServerInfo = getServerInfo()
if (newServerInfo) break
}
if (!newServerInfo) {
return jsonExit(addUrls({
status: 'ERROR',
phase: 'server',
message: 'Server failed to start - no port information available',
url: CONFIG.pagePath,
errors: ['Server did not write port information within timeout'],
logs
}, null), 1)
}
// 使用新获取的服务器信息
const accessibleHost = getAccessibleHost(newServerInfo)
const pageUrl = `http://${accessibleHost}:${newServerInfo.port}${CONFIG.pagePath}`
logs.push(`Target URL: ${pageUrl}`)
logs.push(`Server info: port=${newServerInfo.port}, host=${newServerInfo.host}`)
// 继续后续流程...
await continueWithServerInfo(newServerInfo, pageUrl, viteProcess)
} else {
const accessibleHost = getAccessibleHost(serverInfo)
const pageUrl = `http://${accessibleHost}:${serverInfo.port}${CONFIG.pagePath}`
logs.push(`Target URL: ${pageUrl}`)
logs.push(`Server info: port=${serverInfo.port}, host=${serverInfo.host}`)
// 继续后续流程...
await continueWithServerInfo(serverInfo, pageUrl, null)
}
} catch (err) {
const serverInfo = getServerInfo()
jsonExit(addUrls({
status: 'ERROR',
phase: 'server',
message: err.message,
url: CONFIG.pagePath,
errors: [String(err)],
logs
}, serverInfo), 1)
}
}
async function continueWithServerInfo(serverInfo, pageUrl, viteProcess) {
try {
// 步骤 1: 执行 lint 检查
const lintResult = await runLintCheck()
if (lintResult.status === 'FAILED') {
return jsonExit(addUrls({
status: 'ERROR',
phase: 'lint',
message: lintResult.message,
url: pageUrl,
errors: lintResult.errors,
logs,
lintCheck: lintResult,
checks: buildChecksSummary({ lintResult })
}, serverInfo), 1)
}
// 步骤 2: 执行 typecheck 检查
const typeCheckResult = await runTypeCheck()
if (typeCheckResult.status === 'FAILED') {
return jsonExit(addUrls({
status: 'ERROR',
phase: 'typecheck',
message: typeCheckResult.message,
url: pageUrl,
errors: typeCheckResult.errors,
logs,
lintCheck: lintResult,
typeCheck: typeCheckResult,
checks: buildChecksSummary({ lintResult, typeCheckResult })
}, serverInfo), 1)
}
// 步骤 3: 执行构建校验(除非指定 --skip-build
let buildResult = null
if (!CONFIG.skipBuild) {
const entryKey = getEntryKeyFromPath(CONFIG.pagePath)
logs.push(`Build check enabled for entry: ${entryKey}`)
buildResult = await runBuildCheck(entryKey)
// 如果构建失败,直接返回错误
if (buildResult.status === 'FAILED') {
return jsonExit(addUrls({
status: 'ERROR',
phase: 'build',
message: buildResult.message,
url: pageUrl,
errors: buildResult.errors,
logs,
buildCheck: buildResult,
lintCheck: lintResult,
typeCheck: typeCheckResult,
checks: buildChecksSummary({ lintResult, typeCheckResult, buildResult })
}, serverInfo), 1)
}
} else {
logs.push('Build check skipped (--skip-build flag)')
buildResult = {
status: 'SKIPPED',
message: 'Build check skipped (--skip-build flag)',
errors: [],
logs: []
}
}
// 步骤 4: 开发服务器校验
const accessibleHost = getAccessibleHost(serverInfo)
// 检查服务器是否已经在运行
const serverAlreadyRunning = await isServerAlive(`http://${accessibleHost}:${serverInfo.port}`)
let viteChild = viteProcess
if (!serverAlreadyRunning && !viteChild) {
logs.push('Server not running, starting Vite...')
viteChild = startOrAttachVite()
} else {
logs.push('Server already running, skipping start')
}
// 等待页面可访问
const pageReachable = await waitForPage(pageUrl)
if (!pageReachable) {
if (viteChild) viteChild.kill()
return jsonExit(addUrls({
status: 'TIMEOUT',
phase: 'page',
message: 'Page never became reachable',
url: pageUrl,
errors,
logs,
buildCheck: buildResult,
lintCheck: lintResult,
typeCheck: typeCheckResult,
checks: buildChecksSummary({ lintResult, typeCheckResult, buildResult })
}, serverInfo), 1)
}
// 等待稳定状态
const result = await waitForStable(pageUrl)
// 清理进程
if (viteChild) viteChild.kill()
// 添加构建结果到最终输出
const finalResult = {
...result,
buildCheck: buildResult,
lintCheck: lintResult,
typeCheck: typeCheckResult,
checks: buildChecksSummary({ lintResult, typeCheckResult, buildResult })
}
jsonExit(addUrls(finalResult, serverInfo), result.status === 'READY' ? 0 : 1)
} catch (err) {
jsonExit(addUrls({
status: 'ERROR',
phase: 'server',
message: err.message,
url: CONFIG.pagePath,
errors: [String(err)],
logs
}, serverInfo), 1)
}
}
main()

View File

@@ -0,0 +1,39 @@
import fs from 'fs';
import path from 'path';
const appRoot = process.cwd();
const srcRoot = path.join(appRoot, 'src');
const previewGroups = ['components', 'prototypes', 'themes'];
const legacyHtmlFiles = [];
function walk(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
continue;
}
if (entry.isFile() && entry.name === 'index.html') {
legacyHtmlFiles.push(path.relative(appRoot, fullPath));
}
}
}
for (const group of previewGroups) {
const groupRoot = path.join(srcRoot, group);
if (fs.existsSync(groupRoot)) {
walk(groupRoot);
}
}
if (legacyHtmlFiles.length > 0) {
console.error('Found legacy preview HTML files under src/. Dev preview must use the virtual host pipeline only:');
for (const file of legacyHtmlFiles) {
console.error(`- ${file}`);
}
process.exit(1);
}
console.log('No legacy src preview HTML files found.');

View File

@@ -0,0 +1,826 @@
#!/usr/bin/env node
/**
* =====================================================
* Chrome 扩展导出转换器
*
* 专门处理通过 Chrome 扩展本项目导出的 HTML 文件
*
* 功能:
* 1. 转换 index.html 为 React 组件
* 2. 智能处理字体CDN 保留链接,本地文件复制
* 3. 复制静态资源(图片、字体)
* 4. 保留完整的 style.css 样式
* =====================================================
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { assertValidGeneratedTsx } from './utils/generatedTsxValidator.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONFIG = {
projectRoot: path.resolve(__dirname, '..'),
pagesDir: path.resolve(__dirname, '../src/prototypes')
};
const JSX_ATTRIBUTE_REPLACEMENTS = [
['class', 'className'],
['for', 'htmlFor'],
['tabindex', 'tabIndex'],
['readonly', 'readOnly'],
['maxlength', 'maxLength'],
['minlength', 'minLength'],
['colspan', 'colSpan'],
['rowspan', 'rowSpan'],
['viewbox', 'viewBox'],
['preserveaspectratio', 'preserveAspectRatio'],
['clip-path', 'clipPath'],
['fill-rule', 'fillRule'],
['clip-rule', 'clipRule'],
['stroke-width', 'strokeWidth'],
['stroke-dasharray', 'strokeDasharray'],
['stroke-dashoffset', 'strokeDashoffset'],
['stroke-linecap', 'strokeLinecap'],
['stroke-linejoin', 'strokeLinejoin'],
['stroke-miterlimit', 'strokeMiterlimit'],
['stroke-opacity', 'strokeOpacity'],
['fill-opacity', 'fillOpacity'],
['stop-color', 'stopColor'],
['stop-opacity', 'stopOpacity'],
['xlink:href', 'xlinkHref'],
['xmlns:xlink', 'xmlnsXlink'],
];
function log(message, type = 'info') {
const prefix = { info: '✓', warn: '⚠', error: '✗', progress: '⏳' }[type] || '';
console.log(`${prefix} ${message}`);
}
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function normalizeRelativeDir(value) {
return String(value ?? '')
.replace(/\\/g, '/')
.split('/')
.filter(Boolean)
.join('/');
}
function isSafeRelativeDir(value) {
if (!value) return false;
if (value.startsWith('/') || value.startsWith('~')) return false;
const segments = value.split('/');
if (segments.length === 0) return false;
return segments.every((segment) => {
if (!segment || segment === '.' || segment === '..') return false;
return !/[\\/]/.test(segment);
});
}
/**
* 判断是否为 CDN 链接
*/
function isCDNUrl(url) {
return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//');
}
/**
* 递归复制目录
*/
function copyDirectory(src, dest) {
if (!fs.existsSync(src)) return 0;
ensureDir(dest);
const entries = fs.readdirSync(src, { withFileTypes: true });
let count = 0;
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
count += copyDirectory(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
count++;
}
}
return count;
}
/**
* 提取 head 内容(仅处理外部资源和字体)
*/
function extractHeadContent(html) {
const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
if (!headMatch) return { scripts: [], links: [] };
const headContent = headMatch[1];
const scripts = [];
const links = [];
// 提取 script 标签(排除 Tailwind CDN
const scriptRegex = /<script([^>]*)>([\s\S]*?)<\/script>/gi;
let match;
while ((match = scriptRegex.exec(headContent)) !== null) {
const attrs = match[1];
const content = match[2].trim();
const srcMatch = attrs.match(/src=["']([^"']+)["']/);
if (srcMatch) {
const src = srcMatch[1].replace(/&amp;/g, '&');
// 跳过 Tailwind CDN
if (src.includes('tailwindcss.com')) continue;
scripts.push({
src,
id: attrs.match(/id=["']([^"']+)["']/)?.[1]
});
} else if (content) {
const id = attrs.match(/id=["']([^"']+)["']/)?.[1];
scripts.push({ id, content });
}
}
// 提取 link 标签
const linkRegex = /<link[^>]*>/gi;
while ((match = linkRegex.exec(headContent)) !== null) {
const tag = match[0];
const href = tag.match(/href=["']([^"']+)["']/)?.[1];
if (href) {
links.push({
href: href.replace(/&amp;/g, '&'),
rel: tag.match(/rel=["']([^"']+)["']/)?.[1] || 'stylesheet',
crossorigin: tag.includes('crossorigin')
});
}
}
return { scripts, links };
}
/**
* 转义文本节点中的花括号
* 只处理标签之间的文本内容,不处理属性值
*/
function escapeTextBraces(html) {
const parts = [];
let lastIndex = 0;
const tagRegex = /<[^>]+>/g;
let match;
while ((match = tagRegex.exec(html)) !== null) {
// 提取标签之前的文本
const textBefore = html.substring(lastIndex, match.index);
if (textBefore) {
// 转义文本中的花括号 - 使用占位符避免重复替换
const escaped = textBefore
.replace(/\{/g, "__LBRACE__")
.replace(/\}/g, "__RBRACE__");
parts.push(escaped);
}
// 添加标签本身(不转义)
parts.push(match[0]);
lastIndex = tagRegex.lastIndex;
}
// 添加最后一段文本
const textAfter = html.substring(lastIndex);
if (textAfter) {
const escaped = textAfter
.replace(/\{/g, "__LBRACE__")
.replace(/\}/g, "__RBRACE__");
parts.push(escaped);
}
return parts.join('')
.replace(/__LBRACE__/g, "{'{'}")
.replace(/__RBRACE__/g, "{'}'}")
}
function convertCommonAttributesToJSX(content) {
let nextContent = content;
JSX_ATTRIBUTE_REPLACEMENTS.forEach(([from, to]) => {
nextContent = nextContent.replace(new RegExp(`(\\s)${from}=`, 'gi'), `$1${to}=`);
});
return nextContent;
}
function createCommentPlaceholders(content) {
const comments = [];
const withPlaceholders = content.replace(/<!--([\s\S]*?)-->/g, (_, commentBody) => {
const placeholder = `__HTML_COMMENT_${comments.length}__`;
comments.push(`{/* ${commentBody} */}`);
return placeholder;
});
return { withPlaceholders, comments };
}
function restoreCommentPlaceholders(content, comments) {
return comments.reduce(
(currentContent, comment, index) => currentContent.replaceAll(`__HTML_COMMENT_${index}__`, comment),
content,
);
}
function convertHtmlToJSX(content) {
let nextContent = convertCommonAttributesToJSX(content)
.replace(/(<pre[^>]*>)([\s\S]*?)(<\/pre>)/gi, (_, openTag, preContent) => {
const escapedContent = preContent
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$')
.replace(/\{/g, '\\{');
return `${openTag.slice(0, -1)} dangerouslySetInnerHTML={{ __html: \`${escapedContent}\` }} />`;
})
.replace(/style='([^']*)'/gi, (_, styleStr) => convertStyleToJSX(styleStr))
.replace(/style="([^"]*)"/gi, (_, styleStr) => convertStyleToJSX(styleStr))
.replace(/<\/(br|hr|img|input|meta|link)>/gi, '')
.replace(/<(br|hr|img|input|meta|link)([^>]*)>/gi, '<$1$2 />')
.replace(/<body\b([^>]*)>/gi, '<div data-chrome-export-body="true"$1>')
.replace(/<\/body>/gi, '</div>')
.replace(/&lt;\//g, '__LTSLASH__')
.replace(/&lt;/g, '__LT__')
.replace(/&gt;/g, '__GT__')
.replace(/&amp;/g, '__AMP__');
const { withPlaceholders, comments } = createCommentPlaceholders(nextContent);
nextContent = escapeTextBraces(withPlaceholders);
nextContent = restoreCommentPlaceholders(nextContent, comments);
return nextContent
.replace(/__LTSLASH__/g, "{'</'}")
.replace(/__LT__/g, "{'<'}")
.replace(/__GT__/g, "{'>'}")
.replace(/__AMP__/g, '&');
}
/**
* 提取并转换 body 内容
*/
function extractBodyContent(html) {
const bodyMatch = html.match(/(<body[^>]*>)([\s\S]*?)(<\/body>)/i);
if (!bodyMatch) return '';
const [, openTag, innerContent] = bodyMatch;
// 移除 <root> 标签Chrome 扩展导出特有的包装标签)
let cleanedContent = innerContent.trim()
.replace(/^\s*<root>\s*/i, '')
.replace(/\s*<\/root>\s*$/i, '');
const convertedOpenTag = convertCommonAttributesToJSX(openTag)
.replace(/^<body\b/i, '<div data-chrome-export-root="true"');
const content = convertHtmlToJSX(cleanedContent);
return `${convertedOpenTag}\n${content}\n </div>`;
}
function convertStyleToJSX(styleStr) {
if (!styleStr.trim()) return 'style={{}}';
// 先解码 HTML 实体
const decodedStr = styleStr
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
const styles = [];
let currentProp = '';
let inUrl = false;
for (let i = 0; i < decodedStr.length; i++) {
const char = decodedStr[i];
if (char === '(' && decodedStr.substring(i - 3, i) === 'url') inUrl = true;
else if (char === ')' && inUrl) inUrl = false;
if (char === ';' && !inUrl) {
if (currentProp.trim()) styles.push(currentProp.trim());
currentProp = '';
} else {
currentProp += char;
}
}
if (currentProp.trim()) styles.push(currentProp.trim());
const jsxStyles = styles
.filter(s => s.includes(':'))
.map(s => {
const colonIndex = s.indexOf(':');
const key = s.substring(0, colonIndex).trim();
const value = s.substring(colonIndex + 1).trim();
if (!key || !value) return '';
const camelKey = key.startsWith('-')
? JSON.stringify(key)
: key.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
let jsxValue;
if (value.startsWith('url(') || value.includes('var(')) {
jsxValue = `'${value.replace(/'/g, "\\'")}'`;
} else if (/^-?\d+(\.\d+)?$/.test(value)) {
jsxValue = value;
} else {
// 转义单引号和反斜杠
const escapedValue = value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
jsxValue = `'${escapedValue}'`;
}
return `${camelKey}: ${jsxValue}`;
})
.filter(Boolean)
.join(', ');
return `style={{ ${jsxStyles} }}`;
}
/**
* 生成组件代码
*/
function normalizeDisplayName(displayName) {
const text = String(displayName ?? '').trim();
const singleLine = text.replace(/\r?\n/g, ' ');
const safeText = singleLine.replace(/\*\//g, '* /');
return safeText.slice(0, 200);
}
function generateComponent(pageSlug, displayName, bodyContent, headContent) {
const componentName = pageSlug
.split(/[-_\s]+/)
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
const safeDisplayName = normalizeDisplayName(displayName || pageSlug);
let cleanedContent = bodyContent.trim();
if (cleanedContent.startsWith('{/*')) {
const firstTagIndex = cleanedContent.indexOf('<');
if (firstTagIndex > 0) {
cleanedContent = cleanedContent.substring(firstTagIndex);
}
}
const needsWrapper = !isWrappedInSingleElement(cleanedContent);
const finalContent = needsWrapper ? `<>\n${cleanedContent}\n </>` : cleanedContent;
// 生成注入代码
let injectionCode = '';
if (headContent.links.length > 0 || headContent.scripts.length > 0) {
const hasExternalScripts = headContent.scripts.some(s => s.src);
injectionCode = `
// 动态注入外部资源
React.useEffect(function () {
const injected: (HTMLElement)[] = [];
`;
if (headContent.links.length > 0) {
injectionCode += `
// 注入 links
${JSON.stringify(headContent.links)}.forEach(function (linkInfo: any) {
const existing = document.querySelector(\`link[href="\${linkInfo.href}"]\`);
if (!existing) {
const link = document.createElement('link');
link.rel = linkInfo.rel;
link.href = linkInfo.href;
if (linkInfo.crossorigin) link.crossOrigin = 'anonymous';
document.head.appendChild(link);
injected.push(link);
}
});
`;
}
if (hasExternalScripts) {
injectionCode += `
// 注入外部脚本
${JSON.stringify(headContent.scripts.filter(s => s.src))}.forEach(function (scriptInfo: any) {
const existing = document.querySelector(\`script[src="\${scriptInfo.src}"]\`);
if (!existing) {
const script = document.createElement('script');
if (scriptInfo.id) script.id = scriptInfo.id;
script.src = scriptInfo.src;
document.head.appendChild(script);
injected.push(script);
}
});
`;
}
injectionCode += `
return function () {
injected.forEach(function (el) {
if (el.parentNode) el.parentNode.removeChild(el);
});
};
}, []);
`;
}
return `/**
* @name ${safeDisplayName}
*
* 参考资料:
* - /rules/development-guide.md
* - /skills/default-resource-recommendations/SKILL.md
*/
import './style.css';
import React, { forwardRef, useImperativeHandle } from 'react';
import type { AxureProps, AxureHandle } from '../../common/axure-types';
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean; error: Error | null }
> {
constructor(props: any) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('[${pageSlug}] 组件渲染错误:', error);
console.error('[${pageSlug}] 错误详情:', errorInfo);
console.error('[${pageSlug}] 错误堆栈:', error.stack);
}
render() {
if (this.state.hasError) {
return (
<div style={{ padding: '20px', color: 'red', border: '2px solid red', margin: '20px' }}>
<h2>组件渲染失败: ${safeDisplayName}</h2>
<pre style={{ whiteSpace: 'pre-wrap', fontSize: '12px' }}>
{this.state.error?.toString()}
{this.state.error?.stack}
</pre>
</div>
);
}
return this.props.children;
}
}
const Component = forwardRef(function ${componentName}(
innerProps: AxureProps,
ref: React.ForwardedRef<AxureHandle>,
) {
console.log('[${pageSlug}] 组件开始渲染');
useImperativeHandle(ref, function () {
return {
getVar: function () { return undefined; },
fireAction: function () {},
eventList: [],
actionList: [],
varList: [],
configList: [],
dataList: []
};
}, []);
${injectionCode}
console.log('[${pageSlug}] 准备返回 JSX');
try {
return (
${finalContent.split('\n').map(line => ' ' + line).join('\n')}
);
} catch (error) {
console.error('[${pageSlug}] JSX 渲染错误:', error);
throw error;
}
});
const WrappedComponent = forwardRef(function WrappedComponent(
props: AxureProps,
ref: React.ForwardedRef<AxureHandle>,
) {
return (
<ErrorBoundary>
<Component {...props} ref={ref} />
</ErrorBoundary>
);
});
export default WrappedComponent;
`;
}
function isWrappedInSingleElement(content) {
const trimmed = content.trim();
if (!trimmed.startsWith('<')) return false;
if (trimmed.startsWith('<body')) return trimmed.endsWith('</body>');
const firstTagMatch = trimmed.match(/^<([a-zA-Z][a-zA-Z0-9]*)/);
if (!firstTagMatch) return false;
const tagName = firstTagMatch[1];
const closingTag = `</${tagName}>`;
if (!trimmed.endsWith(closingTag)) return false;
const openCount = (trimmed.match(new RegExp(`<${tagName}[\\s>]`, 'g')) || []).length;
const closeCount = (trimmed.match(new RegExp(`</${tagName}>`, 'g')) || []).length;
return openCount === closeCount && openCount === 1;
}
/**
* 生成 CSS 文件(仅处理外部 style.css
*/
function generateStyleCSS(fonts, sourcePath) {
let css = '@import "tailwindcss";\n';
// 处理字体
if (fonts && fonts.length > 0) {
css += '\n/* 字体定义 */\n';
const cdnFonts = fonts.filter(f => f.isCDN);
const localFonts = fonts.filter(f => !f.isCDN);
if (cdnFonts.length > 0) {
css += '\n/* CDN 字体(保留原始链接) */\n';
cdnFonts.forEach(font => {
css += font.rule + '\n\n';
});
}
if (localFonts.length > 0) {
css += '\n/* 本地字体(已复制到 assets 目录) */\n';
localFonts.forEach(font => {
// 将字体路径改为相对于 style.css 的路径
const modifiedRule = font.rule.replace(
/url\(['"]?([^'")\s]+)['"]?\)/g,
(match, url) => `url('./${url}')`
);
css += modifiedRule + '\n\n';
});
}
}
// 读取外部 CSS 文件的完整内容(排除字体定义)
const externalCSSPath = path.join(sourcePath, 'style.css');
if (fs.existsSync(externalCSSPath)) {
const externalCSS = fs.readFileSync(externalCSSPath, 'utf8');
// 移除 @font-face 规则(已单独处理)
const withoutFontFace = externalCSS.replace(/@font-face\s*\{[^}]+\}/g, '').trim();
if (withoutFontFace) {
css += '\n/* 样式类定义(来自 style.css*/\n';
css += withoutFontFace + '\n';
}
}
return css;
}
/**
* 从外部 CSS 文件提取字体
*/
function extractFontsFromCSS(cssPath) {
if (!fs.existsSync(cssPath)) return [];
const css = fs.readFileSync(cssPath, 'utf8');
const fonts = [];
const fontFaceRegex = /@font-face\s*\{([^}]+)\}/g;
let match;
while ((match = fontFaceRegex.exec(css)) !== null) {
const fontRule = match[1];
const srcMatch = fontRule.match(/src:\s*url\(['"]?([^'")\s]+)['"]?\)/);
const familyMatch = fontRule.match(/font-family:\s*['"]([^'"]+)['"]/);
if (srcMatch && familyMatch) {
const fontSrc = srcMatch[1];
const fontFamily = familyMatch[1];
fonts.push({
family: fontFamily,
src: fontSrc,
isCDN: isCDNUrl(fontSrc),
rule: match[0]
});
}
}
return fonts;
}
/**
* 转换单个页面
*/
function convertPage(sourcePath, outputDir, pageSlug, displayName) {
log(`正在转换页面: ${pageSlug}`, 'progress');
// Chrome 扩展导出固定使用 index.html
const htmlPath = path.join(sourcePath, 'index.html');
if (!fs.existsSync(htmlPath)) {
throw new Error(`找不到 index.html 文件: ${htmlPath}`);
}
const html = fs.readFileSync(htmlPath, 'utf8');
const headContent = extractHeadContent(html);
const bodyContent = extractBodyContent(html);
// 从外部 CSS 文件提取字体
const externalCSSPath = path.join(sourcePath, 'style.css');
let fonts = [];
if (fs.existsSync(externalCSSPath)) {
fonts = extractFontsFromCSS(externalCSSPath);
if (fonts.length > 0) {
log(` ✓ 从 style.css 提取了 ${fonts.length} 个字体定义`, 'info');
}
}
ensureDir(outputDir);
// 生成组件和样式
const componentCode = generateComponent(pageSlug, displayName, bodyContent, headContent);
const styleCSS = generateStyleCSS(fonts, sourcePath);
const outputTsxPath = path.join(outputDir, 'index.tsx');
assertValidGeneratedTsx(componentCode, outputTsxPath);
fs.writeFileSync(outputTsxPath, componentCode);
fs.writeFileSync(path.join(outputDir, 'style.css'), styleCSS);
// 复制静态资源
const assetsPath = path.join(sourcePath, 'assets');
if (fs.existsSync(assetsPath)) {
const outputAssetsPath = path.join(outputDir, 'assets');
// 复制图片
const imagesPath = path.join(assetsPath, 'images');
if (fs.existsSync(imagesPath)) {
const imageCount = copyDirectory(imagesPath, path.join(outputAssetsPath, 'images'));
if (imageCount > 0) {
log(` ✓ 复制了 ${imageCount} 个图片文件`, 'info');
}
}
// 复制本地字体
const localFonts = fonts.filter(f => !f.isCDN);
if (localFonts.length > 0) {
const fontsPath = path.join(assetsPath, 'fonts');
if (fs.existsSync(fontsPath)) {
const fontCount = copyDirectory(fontsPath, path.join(outputAssetsPath, 'fonts'));
log(` ✓ 复制了 ${fontCount} 个字体文件`, 'info');
}
}
// 统计 CDN 字体
const cdnFonts = fonts.filter(f => f.isCDN);
if (cdnFonts.length > 0) {
log(` ✓ 保留了 ${cdnFonts.length} 个 CDN 字体链接`, 'info');
}
}
// 复制参考文件(如果存在)
const filesToCopy = ['screenshot.png', 'content.md', 'theme.json'];
filesToCopy.forEach(filename => {
const srcFile = path.join(sourcePath, filename);
if (fs.existsSync(srcFile)) {
const destFile = path.join(outputDir, filename);
fs.copyFileSync(srcFile, destFile);
log(` ✓ 复制了 ${filename}`, 'info');
}
});
log(`页面转换完成: ${pageSlug}`, 'info');
}
/**
* 检测项目类型(仅支持 Chrome 扩展导出)
*/
function detectProjectType(sourcePath) {
const items = fs.readdirSync(sourcePath);
// 检查是否为 Chrome 扩展导出格式(有 index.html
if (items.includes('index.html')) {
return { type: 'chrome-export', prototypes: [{ name: 'index', path: sourcePath }] };
}
throw new Error('未找到 index.html 文件,请确认这是 Chrome 扩展导出的项目');
}
/**
* 主函数
*/
async function main() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help') {
console.log(`
Chrome 扩展导出转换器
使用方法:
node scripts/chrome-export-converter.mjs <source-dir> [output-name] [display-name]
node scripts/chrome-export-converter.mjs <source-dir> --name <output-name> --display-name <display-name> --target-dir <relative-dir>
参数说明:
source-dir : Chrome 扩展导出的目录(包含 index.html
output-name : 输出页面名称(可选,默认使用目录名)
display-name : 页面显示名(可选,写入 index.tsx 的 @name
target-dir : 输出到 src/prototypes 下的相对目录(可选)
示例:
node scripts/chrome-export-converter.mjs ".drafts/my-export" my-page
node scripts/chrome-export-converter.mjs ".drafts/my-export" my-page "登录页"
node scripts/chrome-export-converter.mjs ".drafts/my-export" --name my-page --target-dir grouped/login-page
`);
process.exit(0);
}
const flags = {};
const positionals = [];
for (let i = 0; i < args.length; i += 1) {
const token = args[i];
if (token === '--name' || token === '--display-name' || token === '--target-dir') {
const next = args[i + 1];
if (typeof next === 'string' && next) {
flags[token] = next;
i += 1;
} else {
flags[token] = '';
}
continue;
}
positionals.push(token);
}
const sourceDirArg = positionals[0];
const outputNameRaw = flags['--name'] || positionals[1] || path.basename(sourceDirArg);
const outputName = String(outputNameRaw)
.replace(/[^a-z0-9-]/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.toLowerCase();
const displayNameRaw = flags['--display-name'] ?? positionals[2];
const displayName = (displayNameRaw !== undefined ? String(displayNameRaw).trim() : '') || outputName;
if (displayNameRaw !== undefined) {
const trimmedDisplayName = String(displayNameRaw).trim();
if (!trimmedDisplayName || trimmedDisplayName.length > 200) {
throw new Error('displayName 长度必须在 1-200 字符');
}
}
const requestedTargetDir = normalizeRelativeDir(flags['--target-dir'] || outputName);
if (!isSafeRelativeDir(requestedTargetDir)) {
throw new Error('target-dir 必须是 src/prototypes 下的安全相对路径');
}
const sourcePath = path.resolve(CONFIG.projectRoot, sourceDirArg);
const outputDir = path.resolve(CONFIG.pagesDir, requestedTargetDir);
const resolvedPagesDir = path.resolve(CONFIG.pagesDir);
if (outputDir === resolvedPagesDir || !outputDir.startsWith(`${resolvedPagesDir}${path.sep}`)) {
throw new Error('target-dir 超出 src/prototypes 目录范围');
}
if (!fs.existsSync(sourcePath)) {
log(`错误: 找不到目录 ${sourcePath}`, 'error');
process.exit(1);
}
try {
log('开始转换 Chrome 扩展导出...', 'info');
const { type, prototypes } = detectProjectType(sourcePath);
log(`项目类型: ${type}`, 'info');
convertPage(prototypes[0].path, outputDir, outputName, displayName);
log('✅ 转换完成!', 'info');
log(`📁 页面位置: ${outputDir}`, 'info');
} catch (error) {
log(`转换失败: ${error.message}`, 'error');
console.error(error);
process.exit(1);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main();
}
export {
convertCommonAttributesToJSX,
convertHtmlToJSX,
convertStyleToJSX,
extractBodyContent,
generateComponent,
};

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* 清理所有 components 和 prototypes 目录下的 index.html 文件
* 因为现在使用统一的模板自动生成
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const srcDir = path.resolve(__dirname, '../src');
const elementsDir = path.join(srcDir, 'components');
const pagesDir = path.join(srcDir, 'prototypes');
let removedCount = 0;
function cleanupHtmlFiles(dir) {
if (!fs.existsSync(dir)) {
return;
}
const items = fs.readdirSync(dir);
for (const item of items) {
const itemPath = path.join(dir, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
const htmlPath = path.join(itemPath, 'index.html');
if (fs.existsSync(htmlPath)) {
console.log(`删除: ${path.relative(srcDir, htmlPath)}`);
fs.unlinkSync(htmlPath);
removedCount++;
}
}
}
}
console.log('开始清理 HTML 文件...\n');
cleanupHtmlFiles(elementsDir);
cleanupHtmlFiles(pagesDir);
console.log(`\n完成!共删除 ${removedCount} 个 HTML 文件。`);
console.log('现在所有组件和页面将使用统一的模板: src/common/dev-template.html');

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env node
/**
* 代码检查命令行工具
*
* 使用方法:
* node scripts/code-review.js prototypes/landing-page
* node scripts/code-review.js components/button
*
* 或者在 package.json 中添加脚本:
* "review": "node scripts/code-review.js"
*/
import http from 'http';
// 从命令行参数获取要检查的路径
const targetPath = process.argv[2];
if (!targetPath) {
console.error('❌ 错误:缺少路径参数');
console.log('\n使用方法');
console.log(' node scripts/code-review.js prototypes/landing-page');
console.log(' node scripts/code-review.js components/button');
process.exit(1);
}
// 配置
const HOST = 'localhost';
const PORT = 51720;
// 发送检查请求
function reviewCode(targetPath) {
return new Promise((resolve, reject) => {
const postData = JSON.stringify({ path: targetPath });
const options = {
hostname: HOST,
port: PORT,
path: '/api/code-review',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData)
}
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const result = JSON.parse(data);
resolve(result);
} catch (error) {
reject(new Error(`解析响应失败: ${error.message}`));
}
});
});
req.on('error', (error) => {
reject(new Error(`请求失败: ${error.message}\n请确保开发服务器正在运行 (npm run dev)`));
});
req.write(postData);
req.end();
});
}
// 格式化输出
function formatResult(result) {
console.log('\n' + '='.repeat(60));
console.log(`📋 检查文件: ${result.file}`);
console.log('='.repeat(60));
if (result.issues.length === 0) {
console.log('\n✅ 太棒了!代码完全符合规范,没有发现任何问题。\n');
return;
}
// 统计问题数量
const errors = result.issues.filter(issue => issue.type === 'error');
const warnings = result.issues.filter(issue => issue.type === 'warning');
console.log(`\n发现 ${errors.length} 个错误,${warnings.length} 个警告\n`);
// 输出错误
if (errors.length > 0) {
console.log('❌ 错误 (必须修复):');
console.log('-'.repeat(60));
errors.forEach((issue, index) => {
console.log(`\n${index + 1}. [${issue.rule}]`);
console.log(` ${issue.message}`);
if (issue.suggestion) {
console.log(` 💡 建议: ${issue.suggestion}`);
}
});
console.log();
}
// 输出警告
if (warnings.length > 0) {
console.log('⚠️ 警告 (建议修复):');
console.log('-'.repeat(60));
warnings.forEach((issue, index) => {
console.log(`\n${index + 1}. [${issue.rule}]`);
console.log(` ${issue.message}`);
if (issue.suggestion) {
console.log(` 💡 建议: ${issue.suggestion}`);
}
});
console.log();
}
// 总结
console.log('='.repeat(60));
if (result.passed) {
console.log('✅ 检查通过 (仅有警告,不影响使用)');
} else {
console.log('❌ 检查未通过 (存在错误,需要修复)');
}
console.log('='.repeat(60) + '\n');
}
// 主函数
async function main() {
console.log(`\n🔍 正在检查: ${targetPath}...\n`);
try {
const result = await reviewCode(targetPath);
if (result.error) {
console.error(`❌ 检查失败: ${result.error}`);
process.exit(1);
}
formatResult(result);
// 如果有错误,退出码为 1
if (!result.passed) {
process.exit(1);
}
} catch (error) {
console.error(`\n${error.message}\n`);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,913 @@
#!/usr/bin/env node
/**
* Figma Make 项目预处理器(最小化处理模式)
*
* 只做 100% 有把握的操作:
* 1. 完整复制项目
* 2. 转换 @/ 路径别名
* 3. 转换 package@version 导入
* 4. 分析项目结构
* 5. 生成任务文档
*
* 不做任何组件逻辑改写,全部留给 AI 处理
*
* 约定输入:
* - 这里接收的 projectDir 应来自 Figma 原始导出的 ZIP 工程包解压结果
* - 不建议传入人工整理过的文件夹,以免破坏官方项目结构
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TARGET_TYPE_TO_SRC_DIR = {
prototypes: 'src/prototypes',
components: 'src/components',
themes: 'src/themes',
};
const THEME_SPLIT_SKILL_DOCS = [
'/skills/axure-prototype-workflow/theme-generation.md',
'/skills/axure-prototype-workflow/doc-generation.md',
'/skills/axure-prototype-workflow/data-generation.md',
'/skills/web-page-workflow/theme-generation.md',
'/skills/web-page-workflow/doc-generation.md',
'/skills/web-page-workflow/data-generation.md',
];
const CODE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs'];
const IGNORED_DIRS = new Set(['node_modules', '.npm-local-cache', 'build']);
const CONFIG = {
projectRoot: path.resolve(__dirname, '..'),
};
function log(message, type = 'info') {
const prefix = { info: '✓', warn: '⚠', error: '✗', progress: '⏳' }[type] || '';
console.log(`${prefix} ${message}`);
}
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function sanitizeName(rawName) {
return String(rawName || '')
.replace(/[^a-z0-9-]/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.toLowerCase();
}
function normalizeSlashes(input) {
return String(input || '').replace(/\\/g, '/');
}
function ensureRelativeSpecifier(specifier) {
const normalized = normalizeSlashes(specifier);
if (normalized.startsWith('.')) return normalized;
return `./${normalized}`;
}
function countUniqueCssVariables(content) {
const matches = content.matchAll(/--([a-z0-9-_]+)\s*:/gi);
const names = new Set();
for (const match of matches) {
if (match[1]) {
names.add(match[1]);
}
}
return names.size;
}
function getTargetInfo(targetType, outputName) {
const srcDir = TARGET_TYPE_TO_SRC_DIR[targetType];
const outputBaseDir = path.resolve(CONFIG.projectRoot, srcDir);
const outputDir = path.join(outputBaseDir, outputName);
const relativeOutputDir = `${srcDir}/${outputName}`;
if (targetType === 'themes') {
return {
targetType,
srcDir,
outputBaseDir,
outputDir,
relativeOutputDir,
tasksFileName: '.figma-make-theme-tasks.md',
analysisFileName: '.figma-make-theme-analysis.json',
checkPath: `/themes/${outputName}`,
label: '主题',
};
}
return {
targetType,
srcDir,
outputBaseDir,
outputDir,
relativeOutputDir,
tasksFileName: '.figma-make-tasks.md',
analysisFileName: '.figma-make-analysis.json',
checkPath: `/${targetType}/${outputName}`,
label: targetType === 'components' ? '组件' : '页面',
};
}
function walkFiles(dir, options = {}) {
const {
extensions = null,
includeAllFiles = false,
} = options;
const results = [];
if (!fs.existsSync(dir)) return results;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (IGNORED_DIRS.has(entry.name)) {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walkFiles(fullPath, options));
continue;
}
if (includeAllFiles) {
results.push(fullPath);
continue;
}
const ext = path.extname(entry.name);
if (!extensions || extensions.includes(ext)) {
results.push(fullPath);
}
}
return results;
}
function copyDirectory(src, dest) {
if (!fs.existsSync(src)) return 0;
ensureDir(dest);
const entries = fs.readdirSync(src, { withFileTypes: true });
let count = 0;
for (const entry of entries) {
if (IGNORED_DIRS.has(entry.name)) {
continue;
}
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
count += copyDirectory(srcPath, destPath);
continue;
}
fs.copyFileSync(srcPath, destPath);
count += 1;
}
return count;
}
function parsePackageSpecifier(specifier) {
if (!specifier || specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#')) {
return null;
}
if (specifier.startsWith('@/')) {
return null;
}
if (specifier.startsWith('@')) {
const firstSlash = specifier.indexOf('/');
if (firstSlash === -1) return null;
const secondSlash = specifier.indexOf('/', firstSlash + 1);
const packageWithVersion = secondSlash === -1 ? specifier : specifier.slice(0, secondSlash);
const rest = secondSlash === -1 ? '' : specifier.slice(secondSlash);
const match = packageWithVersion.match(/^(@[^/]+\/[^@/]+)@(.+)$/);
if (!match) return null;
return {
packageName: match[1],
version: match[2],
normalized: `${match[1]}${rest}`,
};
}
const slashIndex = specifier.indexOf('/');
const packageWithVersion = slashIndex === -1 ? specifier : specifier.slice(0, slashIndex);
const rest = slashIndex === -1 ? '' : specifier.slice(slashIndex);
const match = packageWithVersion.match(/^([^@/]+)@(.+)$/);
if (!match) return null;
return {
packageName: match[1],
version: match[2],
normalized: `${match[1]}${rest}`,
};
}
function replaceAliasAndVersionImports(targetDir) {
const srcRoot = path.join(targetDir, 'src');
const files = walkFiles(targetDir, { extensions: CODE_EXTENSIONS });
const pathAliases = [];
const versionedImports = [];
let processedCount = 0;
for (const file of files) {
let content = fs.readFileSync(file, 'utf8');
const originalContent = content;
const relativeFilePath = normalizeSlashes(path.relative(targetDir, file));
content = content.replace(
/((?:from|import|export)\s*(?:[^'"]*?\sfrom\s*)?['"])@\/([^'"]+)(['"])/g,
(fullMatch, prefix, targetPath, suffix) => {
const resolvedTarget = path.join(srcRoot, targetPath);
const relativeSpecifier = ensureRelativeSpecifier(path.relative(path.dirname(file), resolvedTarget));
pathAliases.push({
file: relativeFilePath,
original: `@/${targetPath}`,
replacement: normalizeSlashes(relativeSpecifier),
});
return `${prefix}${normalizeSlashes(relativeSpecifier)}${suffix}`;
},
);
content = content.replace(
/(\bimport\s*\(\s*['"])([^'"]+)(['"]\s*\))/g,
(fullMatch, prefix, specifier, suffix) => {
if (specifier.startsWith('@/')) {
const targetPath = specifier.slice(2);
const resolvedTarget = path.join(srcRoot, targetPath);
const relativeSpecifier = ensureRelativeSpecifier(path.relative(path.dirname(file), resolvedTarget));
pathAliases.push({
file: relativeFilePath,
original: specifier,
replacement: normalizeSlashes(relativeSpecifier),
});
return `${prefix}${normalizeSlashes(relativeSpecifier)}${suffix}`;
}
const parsed = parsePackageSpecifier(specifier);
if (!parsed) return fullMatch;
versionedImports.push({
file: relativeFilePath,
original: specifier,
replacement: parsed.normalized,
packageName: parsed.packageName,
version: parsed.version,
});
return `${prefix}${parsed.normalized}${suffix}`;
},
);
content = content.replace(
/((?:from|import|export)\s*(?:[^'"]*?\sfrom\s*)?['"])([^'"]+)(['"])/g,
(fullMatch, prefix, specifier, suffix) => {
const parsed = parsePackageSpecifier(specifier);
if (!parsed) return fullMatch;
versionedImports.push({
file: relativeFilePath,
original: specifier,
replacement: parsed.normalized,
packageName: parsed.packageName,
version: parsed.version,
});
return `${prefix}${parsed.normalized}${suffix}`;
},
);
if (content !== originalContent) {
fs.writeFileSync(file, content);
processedCount += 1;
}
}
return {
processedCount,
pathAliases,
versionedImports,
};
}
function parseImports(content) {
const imports = [];
const importMatches = content.matchAll(/(?:from\s+['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\))/g);
for (const match of importMatches) {
const specifier = match[1] || match[2];
if (specifier) {
imports.push(specifier);
}
}
return imports;
}
function parseVersionedAliases(viteContent) {
const aliasMap = new Map();
const objectMatches = viteContent.matchAll(/['"]([^'"]+@[^'"]+)['"]\s*:\s*['"]([^'"]+)['"]/g);
for (const match of objectMatches) {
aliasMap.set(match[1], match[2]);
}
const arrayMatches = viteContent.matchAll(/find\s*:\s*['"]([^'"]+@[^'"]+)['"][\s\S]*?replacement\s*:\s*['"]([^'"]+)['"]/g);
for (const match of arrayMatches) {
aliasMap.set(match[1], match[2]);
}
return Array.from(aliasMap.entries()).map(([find, replacement]) => {
const parsed = parsePackageSpecifier(find);
return {
find,
replacement,
packageName: parsed?.packageName || replacement,
version: parsed?.version || '',
};
});
}
function detectAtAlias(viteContent) {
const objectMatch = viteContent.match(/['"]@['"]\s*:\s*['"]([^'"]+)['"]/);
if (objectMatch) return objectMatch[1];
const arrayMatch = viteContent.match(/find\s*:\s*['"]@['"][\s\S]*?replacement\s*:\s*['"]([^'"]+)['"]/);
return arrayMatch ? arrayMatch[1] : '';
}
function resolveOptionalPath(targetDir, candidates) {
for (const candidate of candidates) {
const fullPath = path.join(targetDir, candidate);
if (fs.existsSync(fullPath)) {
return {
exists: true,
path: candidate,
absolutePath: fullPath,
};
}
}
return {
exists: false,
path: '',
absolutePath: '',
};
}
function countFilesRecursive(dirPath) {
if (!fs.existsSync(dirPath)) return 0;
let total = 0;
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
total += countFilesRecursive(fullPath);
} else if (entry.isFile()) {
total += 1;
}
}
return total;
}
function collectFigmaMakeAssets(targetDir) {
const imagesDir = path.join(targetDir, 'images');
return {
hasCanvasFig: fs.existsSync(path.join(targetDir, 'canvas.fig')),
hasMetaJson: fs.existsSync(path.join(targetDir, 'meta.json')),
hasAiChat: fs.existsSync(path.join(targetDir, 'ai_chat.json')),
hasThumbnail: fs.existsSync(path.join(targetDir, 'thumbnail.png')),
hasImagesDir: fs.existsSync(imagesDir),
hasCodeManifest: fs.existsSync(path.join(targetDir, 'canvas.code-manifest.json')),
imageCount: countFilesRecursive(imagesDir),
};
}
function analyzeProject(targetDir, conversionResult) {
const srcDir = path.join(targetDir, 'src');
const allFiles = walkFiles(targetDir, { includeAllFiles: true });
const codeFiles = walkFiles(targetDir, { extensions: CODE_EXTENSIONS });
const pageFiles = walkFiles(path.join(srcDir, 'pages'), { extensions: ['.tsx', '.ts', '.jsx', '.js'] })
.map((file) => normalizeSlashes(path.relative(targetDir, file)));
const componentFiles = walkFiles(path.join(srcDir, 'components'), { extensions: ['.tsx', '.ts', '.jsx', '.js'] })
.map((file) => normalizeSlashes(path.relative(targetDir, file)));
const files = codeFiles.map((file) => {
const relativePath = normalizeSlashes(path.relative(targetDir, file));
const content = fs.readFileSync(file, 'utf8');
const imports = parseImports(content);
return {
path: relativePath,
importCount: imports.length,
imports: imports.slice(0, 25),
containsRouteHint: /createBrowserRouter|Routes|Route|react-router/i.test(content),
usesCssVariables: /var\(--[a-z0-9-_]+\)/i.test(content),
};
});
const packageJsonPath = path.join(targetDir, 'package.json');
let dependencies = {
all: {},
toInstall: [],
excluded: ['react', 'react-dom', 'next-themes'],
};
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const allDeps = packageJson.dependencies || {};
dependencies = {
all: allDeps,
toInstall: Object.keys(allDeps).filter((dep) => {
return dep !== 'react' && dep !== 'react-dom' && dep !== 'next-themes';
}),
excluded: ['react', 'react-dom', 'next-themes'],
};
}
const viteConfigInfo = resolveOptionalPath(targetDir, ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs']);
const viteContent = viteConfigInfo.exists ? fs.readFileSync(viteConfigInfo.absolutePath, 'utf8') : '';
const versionedAliases = viteContent ? parseVersionedAliases(viteContent) : [];
const atAliasReplacement = viteContent ? detectAtAlias(viteContent) : '';
const indexCssInfo = resolveOptionalPath(targetDir, ['src/index.css']);
const globalsCssInfo = resolveOptionalPath(targetDir, ['src/styles/globals.css']);
const designGuideInfo = resolveOptionalPath(targetDir, ['src/DESIGN_SYSTEM_GUIDE.md', 'DESIGN_SYSTEM_GUIDE.md']);
const tokenReferenceInfo = resolveOptionalPath(targetDir, ['src/TOKEN_REFERENCE.md', 'TOKEN_REFERENCE.md']);
const figmaMakeAssets = collectFigmaMakeAssets(targetDir);
const indexCssContent = indexCssInfo.exists ? fs.readFileSync(indexCssInfo.absolutePath, 'utf8') : '';
const globalsCssContent = globalsCssInfo.exists ? fs.readFileSync(globalsCssInfo.absolutePath, 'utf8') : '';
return {
summary: {
totalFiles: allFiles.length,
codeFileCount: codeFiles.length,
componentCount: componentFiles.length,
pageCount: pageFiles.length,
pathAliasCount: conversionResult.pathAliases.length,
versionedImportCount: conversionResult.versionedImports.length,
versionedAliasCount: versionedAliases.length,
dependenciesToInstall: dependencies.toInstall.length,
cssVariableCount: countUniqueCssVariables(`${indexCssContent}\n${globalsCssContent}`),
designDocCount: [designGuideInfo.exists, tokenReferenceInfo.exists].filter(Boolean).length,
},
structure: {
hasSrcDir: fs.existsSync(srcDir),
hasAppTsx: fs.existsSync(path.join(srcDir, 'App.tsx')),
hasMainTsx: fs.existsSync(path.join(srcDir, 'main.tsx')),
hasPagesDir: fs.existsSync(path.join(srcDir, 'pages')),
hasComponentsDir: fs.existsSync(path.join(srcDir, 'components')),
hasGuidelinesDir: fs.existsSync(path.join(srcDir, 'guidelines')),
hasBuildDir: fs.existsSync(path.join(targetDir, 'build')),
hasViteConfig: viteConfigInfo.exists,
hasIndexHtml: fs.existsSync(path.join(targetDir, 'index.html')),
hasIndexCss: indexCssInfo.exists,
hasGlobalsCss: globalsCssInfo.exists,
},
entryFiles: {
appTsx: fs.existsSync(path.join(srcDir, 'App.tsx')) ? 'src/App.tsx' : '',
mainTsx: fs.existsSync(path.join(srcDir, 'main.tsx')) ? 'src/main.tsx' : '',
indexHtml: fs.existsSync(path.join(targetDir, 'index.html')) ? 'index.html' : '',
pagesDir: fs.existsSync(path.join(srcDir, 'pages')) ? 'src/pages' : '',
pageFiles,
},
pathAliases: conversionResult.pathAliases,
versionedImports: conversionResult.versionedImports,
versionedAliases,
dependencies,
css: {
hasIndexCss: indexCssInfo.exists,
indexCssPath: indexCssInfo.path,
indexCssSize: indexCssContent.length,
hasGlobalsCss: globalsCssInfo.exists,
globalsCssPath: globalsCssInfo.path,
globalsCssSize: globalsCssContent.length,
cssVariableCount: countUniqueCssVariables(`${indexCssContent}\n${globalsCssContent}`),
prefersGlobalsCssAsSource: globalsCssInfo.exists,
atAliasReplacement,
},
docs: {
designSystemGuide: {
exists: designGuideInfo.exists,
path: designGuideInfo.path,
},
tokenReference: {
exists: tokenReferenceInfo.exists,
path: tokenReferenceInfo.path,
},
},
figmaMakeAssets,
files,
};
}
function writeAnalysisReport(report, targetInfo) {
const reportPath = path.join(targetInfo.outputDir, targetInfo.analysisFileName);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
return reportPath;
}
function renderDocLine(docInfo) {
return docInfo.exists ? `\`${docInfo.path}\`` : '未提供';
}
function generateDefaultTasksDocument(report, targetInfo, outputName, tempDir) {
const reportPath = writeAnalysisReport(report, targetInfo);
let markdown = '# Figma Make 项目转换任务清单\n\n';
markdown += '> **重要**: 请先阅读 `/skills/figma-make-project-converter/SKILL.md` 了解转换规范\n\n';
markdown += `**名称**: ${outputName}\n`;
markdown += `**项目位置**: \`${targetInfo.relativeOutputDir}/\`\n`;
markdown += `**原始文件**: \`${tempDir}\` (仅供参考,不要修改)\n`;
markdown += `**生成时间**: ${new Date().toLocaleString()}\n\n`;
if (
report.figmaMakeAssets.hasCanvasFig
|| report.figmaMakeAssets.hasMetaJson
|| report.figmaMakeAssets.hasAiChat
|| report.figmaMakeAssets.hasThumbnail
|| report.figmaMakeAssets.hasImagesDir
|| report.figmaMakeAssets.hasCodeManifest
) {
markdown += '## ⚠️ 注意:保留 Figma Make 原始资产\n\n';
markdown += '此项目从 Figma Make 导入,以下文件必须保留,用于后续导出 `名称.fig`\n';
if (report.figmaMakeAssets.hasCanvasFig) markdown += '- `canvas.fig` — Figma 设计数据(二进制,勿修改)\n';
if (report.figmaMakeAssets.hasMetaJson) markdown += '- `meta.json` — 项目元数据\n';
if (report.figmaMakeAssets.hasAiChat) markdown += '- `ai_chat.json` — AI 聊天历史\n';
if (report.figmaMakeAssets.hasThumbnail) markdown += '- `thumbnail.png` — 项目缩略图\n';
if (report.figmaMakeAssets.hasCodeManifest) markdown += '- `canvas.code-manifest.json` — CODE_FILE 索引清单\n';
if (report.figmaMakeAssets.hasImagesDir) {
markdown += `- \`images/\` — 设计稿图片资源(当前 ${report.figmaMakeAssets.imageCount} 个文件)\n`;
}
markdown += '\n';
}
markdown += '## 📊 项目概况\n\n';
markdown += `- 总文件数: ${report.summary.totalFiles}\n`;
markdown += `- 组件数: ${report.summary.componentCount}\n`;
markdown += `- 页面数: ${report.summary.pageCount}\n`;
markdown += `- ~~路径别名 (@/)~~: ${report.summary.pathAliasCount} 处,已由脚本转换\n`;
markdown += `- ~~版本化导入~~: ${report.summary.versionedImportCount} 处,已改为裸包名\n`;
markdown += `- vite 版本化 alias: ${report.summary.versionedAliasCount} 处(仅保留为参考信息)\n`;
markdown += `- 待安装依赖: ${report.summary.dependenciesToInstall}\n`;
markdown += `- CSS 变量: ${report.summary.cssVariableCount}\n\n`;
markdown += '## 🧱 固定目录结构(必须遵守)\n\n';
markdown += '```text\n';
markdown += `${targetInfo.relativeOutputDir}/\n`;
markdown += '├── index.tsx # Axhub runtime adapter only\n';
markdown += '├── style.css # root style bridge only\n';
markdown += '└── src/\n';
markdown += ' ├── App.tsx # Figma export shell only\n';
markdown += ' ├── main.tsx # Vite mount only\n';
markdown += ' ├── index.css # Figma style bridge only\n';
markdown += ' ├── components/ # shared page implementation\n';
markdown += ' └── styles/ # shared page styles\n';
markdown += '```\n\n';
markdown += '要求:\n';
markdown += '- 页面真实视觉和交互主体优先沉淀在 `src/components/**`\n';
markdown += '- 根目录 `index.tsx` 只做 Axhub 运行时适配,不复制页面视觉实现\n';
markdown += '- `src/App.tsx` 只做 Figma 导出薄壳,不复制页面逻辑\n';
markdown += '- `style.css` / `src/index.css` 只做样式桥接,避免重复堆样式\n';
markdown += '- 在 `index.tsx`、`src/App.tsx`、`src/main.tsx` 顶部写职责注释,提醒后续维护者不要让入口漂移\n\n';
markdown += '> 若最终项目不符合这套固定结构,视为转换未完成,应继续重构后再进入后续任务。\n\n';
markdown += '## ✅ 转换任务(共 6 个)\n\n';
markdown += '### 任务 1: 确定页面入口并创建 `index.tsx`\n\n';
markdown += `**候选入口**:\n`;
if (report.entryFiles.appTsx) markdown += `- \`${targetInfo.relativeOutputDir}/${report.entryFiles.appTsx}\`\n`;
if (report.entryFiles.mainTsx) markdown += `- \`${targetInfo.relativeOutputDir}/${report.entryFiles.mainTsx}\`\n`;
if (report.entryFiles.pageFiles.length > 0) {
markdown += `- \`${targetInfo.relativeOutputDir}/src/pages/\` 下共有 ${report.entryFiles.pageFiles.length} 个页面文件\n`;
}
markdown += '\n';
markdown += '**操作**:\n';
markdown += '1. 按照 `/skills/figma-make-project-converter/SKILL.md` 的页面组件规范创建 `index.tsx`\n';
markdown += '2. 优先使用 `src/App.tsx` 作为汇总入口;若存在多页面路由,则收敛为本项目单入口组件\n';
markdown += '3. 不保留对 Figma Make 原始 `main.tsx` 挂载逻辑的依赖\n';
markdown += '4. 在 `index.tsx` 顶部写注释,明确它只是 Axhub runtime adapter\n\n';
markdown += '> 若页面后续还要重新导出 `名称.fig`,请确保导出壳子 `src/App.tsx` 最终仍能表达当前页面真实内容,避免它与根目录 `index.tsx` 漂移。\n\n';
markdown += '### 任务 2: 创建 `style.css`\n\n';
markdown += '**目标**: 按约定使用 `globals.css` 作为主样式来源\n\n';
markdown += '**操作**:\n';
markdown += '1. 创建 `style.css`,第一行固定为 `@import "tailwindcss";`\n';
if (report.css.hasGlobalsCss) {
markdown += `2. 以 \`${targetInfo.relativeOutputDir}/${report.css.globalsCssPath}\` 作为主要样式来源\n`;
} else {
markdown += '2. 未发现 `src/styles/globals.css`,需要 AI 从现有组件样式中补齐基础样式\n';
}
if (report.css.hasIndexCss) {
markdown += `3. \`${targetInfo.relativeOutputDir}/${report.css.indexCssPath}\` 仅作为视觉回归参考,不直接搬运为最终 \`style.css\`\n`;
}
markdown += '4. 在 `style.css` 与 `src/index.css` 中保留注释,明确它们只是样式桥接层\n';
markdown += '\n';
markdown += '### 任务 3: 清理 Figma Make 运行时耦合\n\n';
markdown += '**脚本已完成**:\n';
markdown += `- ~~转换 \`@/\` 路径别名~~ ✓ 已完成(${report.summary.pathAliasCount} 处)\n`;
markdown += `- ~~转换 \`package@version\` 导入~~ ✓ 已完成(${report.summary.versionedImportCount} 处)\n\n`;
markdown += '**仍需处理**:\n';
markdown += '- 不再依赖 `vite.config.ts` 中的 alias 作为运行前提\n';
markdown += '- 可保留 `vite.config.ts` 作为参考,但最终组件需独立运行\n';
markdown += '- 不保留原始 Vite 挂载入口与多页面路由壳层\n';
markdown += '- `src/App.tsx` 与 `src/main.tsx` 顶部要写职责注释,避免后续维护时误塞页面逻辑\n\n';
markdown += '### 任务 4: 收敛多页面/路由结构\n\n';
markdown += '**目标**: 将 Figma Make 的多页面应用收敛为本项目单入口页面组件\n\n';
markdown += '**操作**:\n';
markdown += '- 保留页面视觉层级与主要交互结构\n';
markdown += '- 将路由切换逻辑合并为单页面展示或局部状态切换\n';
markdown += '- 不强行保留浏览器路由壳层\n\n';
markdown += '### 任务 5: 安装依赖\n\n';
if (report.dependencies.toInstall.length > 0) {
markdown += '**执行命令**:\n';
markdown += '```bash\n';
markdown += `pnpm add ${report.dependencies.toInstall.join(' ')}\n`;
markdown += '```\n\n';
markdown += `**已排除**: ${report.dependencies.excluded.map((item) => `\`${item}\``).join('、')}\n\n`;
} else {
markdown += '✓ 无需安装额外依赖\n\n';
}
markdown += '### 任务 6: 验收测试\n\n';
markdown += '**执行命令**:\n';
markdown += '```bash\n';
markdown += `node scripts/check-app-ready.mjs ${targetInfo.checkPath}\n`;
markdown += '```\n\n';
markdown += '**验收要求**: 页面正常渲染、无控制台错误、主视觉与原项目一致\n\n';
markdown += '## 📚 可直接利用的设计资料\n\n';
markdown += `- DESIGN_SYSTEM_GUIDE.md: ${renderDocLine(report.docs.designSystemGuide)}\n`;
markdown += `- TOKEN_REFERENCE.md: ${renderDocLine(report.docs.tokenReference)}\n\n`;
markdown += '## 📎 产物索引\n\n';
markdown += `- 任务清单: \`${targetInfo.tasksFileName}\`\n`;
markdown += `- 分析报告: \`${targetInfo.analysisFileName}\`\n`;
markdown += '- 转换规范: `/skills/figma-make-project-converter/SKILL.md`\n';
const mdPath = path.join(targetInfo.outputDir, targetInfo.tasksFileName);
fs.writeFileSync(mdPath, markdown);
return { reportPath, mdPath };
}
function generateThemeTasksDocument(report, targetInfo, outputName, tempDir) {
const reportPath = writeAnalysisReport(report, targetInfo);
let markdown = '# Figma Make 主题导入任务清单\n\n';
markdown += '> **重要**: 请先阅读 `/skills/figma-make-project-converter/SKILL.md` 与主题拆分技能文档,按任务顺序执行\n\n';
markdown += `**主题 key**: ${outputName}\n`;
markdown += `**主题目录**: \`${targetInfo.relativeOutputDir}/\`\n`;
markdown += `**原始文件**: \`${tempDir}\` (仅供参考,不要修改)\n`;
markdown += `**生成时间**: ${new Date().toLocaleString()}\n\n`;
if (
report.figmaMakeAssets.hasCanvasFig
|| report.figmaMakeAssets.hasMetaJson
|| report.figmaMakeAssets.hasAiChat
|| report.figmaMakeAssets.hasThumbnail
|| report.figmaMakeAssets.hasImagesDir
|| report.figmaMakeAssets.hasCodeManifest
) {
markdown += '## ⚠️ 注意:保留 Figma Make 原始资产\n\n';
markdown += '此项目从 Figma Make 导入,以下文件必须保留,用于后续导出 `名称.fig`\n';
if (report.figmaMakeAssets.hasCanvasFig) markdown += '- `canvas.fig` — Figma 设计数据(二进制,勿修改)\n';
if (report.figmaMakeAssets.hasMetaJson) markdown += '- `meta.json` — 项目元数据\n';
if (report.figmaMakeAssets.hasAiChat) markdown += '- `ai_chat.json` — AI 聊天历史\n';
if (report.figmaMakeAssets.hasThumbnail) markdown += '- `thumbnail.png` — 项目缩略图\n';
if (report.figmaMakeAssets.hasCodeManifest) markdown += '- `canvas.code-manifest.json` — CODE_FILE 索引清单\n';
if (report.figmaMakeAssets.hasImagesDir) {
markdown += `- \`images/\` — 设计稿图片资源(当前 ${report.figmaMakeAssets.imageCount} 个文件)\n`;
}
markdown += '\n';
}
markdown += '## 📊 输入概况\n\n';
markdown += `- 总文件数: ${report.summary.totalFiles}\n`;
markdown += `- 组件数: ${report.summary.componentCount}\n`;
markdown += `- 页面数: ${report.summary.pageCount}\n`;
markdown += `- CSS 变量: ${report.summary.cssVariableCount}\n`;
markdown += `- 版本化 alias: ${report.summary.versionedAliasCount}\n`;
markdown += `- 待评估依赖: ${report.summary.dependenciesToInstall}\n\n`;
markdown += '## 📚 参考文档(必须阅读)\n\n';
markdown += '- `/skills/figma-make-project-converter/SKILL.md`\n';
THEME_SPLIT_SKILL_DOCS.forEach((docPath) => {
markdown += `- \`${docPath}\`\n`;
});
markdown += '\n';
markdown += '## 🧭 可利用的设计输入\n\n';
markdown += `- DESIGN_SYSTEM_GUIDE.md: ${renderDocLine(report.docs.designSystemGuide)}\n`;
markdown += `- TOKEN_REFERENCE.md: ${renderDocLine(report.docs.tokenReference)}\n`;
markdown += report.css.hasGlobalsCss
? `- 主题主样式参考: \`${targetInfo.relativeOutputDir}/${report.css.globalsCssPath}\`\n`
: '- 主题主样式参考: 未找到 `src/styles/globals.css`\n';
markdown += report.css.hasIndexCss
? `- 视觉回归参考: \`${targetInfo.relativeOutputDir}/${report.css.indexCssPath}\`\n\n`
: '- 视觉回归参考: 未找到 `src/index.css`\n\n';
markdown += '## ✅ 主题导入任务(共 5 个)\n\n';
markdown += '### 任务 1生成主题 token\n\n';
markdown += `**目标**:在 \`${targetInfo.relativeOutputDir}/\` 下生成 \`globals.css\`\`designToken.json\`(二选一)\n\n`;
markdown += '**要求**\n';
markdown += '- 优先利用设计文档、CSS 变量和全局样式提取颜色、字体、间距、圆角、阴影 token\n';
markdown += '- 若输出 `designToken.json`,必须包含 `name` 字段\n\n';
markdown += '### 任务 2生成 DESIGN-SPEC.md\n\n';
markdown += `**目标**:输出 \`${targetInfo.relativeOutputDir}/DESIGN-SPEC.md\`\n\n`;
markdown += '**要求**:说明设计语言、组件风格、排版层级、状态与使用建议\n\n';
markdown += '### 任务 3按需生成项目文档\n\n';
markdown += '**目标**:在 `src/docs/` 下补充主题相关文档\n\n';
markdown += '**要求**:结合输入项目的信息架构与设计文档产出高可读说明\n\n';
markdown += '### 任务 4按需生成数据模型\n\n';
markdown += '**目标**:在 `src/database/` 下补充或更新数据模型\n\n';
markdown += '**要求**:文件名英文、`tableName` 中文、`records` 数组中 `id` 唯一\n\n';
markdown += '### 任务 5生成/更新主题演示入口\n\n';
markdown += `**目标**:生成或更新 \`${targetInfo.relativeOutputDir}/index.tsx\`\n\n`;
markdown += '**要求**:明确演示 token、生效方式与关键组件外观\n\n';
markdown += '## 📎 产物索引\n\n';
markdown += `- 任务清单: \`${targetInfo.tasksFileName}\`\n`;
markdown += `- 分析报告: \`${targetInfo.analysisFileName}\`\n`;
const mdPath = path.join(targetInfo.outputDir, targetInfo.tasksFileName);
fs.writeFileSync(mdPath, markdown);
return { reportPath, mdPath };
}
function parseArgs(rawArgs) {
const args = [...rawArgs];
const help = args.length === 0 || args.includes('--help') || args.includes('-h');
if (help) {
return { help: true };
}
let projectDirArg = '';
let outputNameArg = '';
let targetType = 'prototypes';
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--target-type') {
const nextValue = args[index + 1];
if (!nextValue) {
throw new Error('参数 --target-type 缺少值');
}
targetType = String(nextValue).trim();
index += 1;
continue;
}
if (!projectDirArg) {
projectDirArg = arg;
continue;
}
if (!outputNameArg) {
outputNameArg = arg;
continue;
}
}
if (!projectDirArg) {
throw new Error('缺少 <figma-make-project-dir> 参数');
}
if (!Object.prototype.hasOwnProperty.call(TARGET_TYPE_TO_SRC_DIR, targetType)) {
throw new Error(`不支持的 targetType: ${targetType}。可选值: ${Object.keys(TARGET_TYPE_TO_SRC_DIR).join(', ')}`);
}
const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg));
if (!outputName) {
throw new Error('无法生成有效的输出名称,请显式传入 [output-name]');
}
return {
help: false,
projectDirArg,
outputName,
targetType,
};
}
function printHelp() {
console.log(`
Figma Make 项目预处理器
使用方法:
node scripts/figma-make-converter.mjs <figma-make-project-dir> [output-name] [--target-type <prototypes|components|themes>]
示例:
node scripts/figma-make-converter.mjs "temp/my-figma-make-project" my-page
node scripts/figma-make-converter.mjs "temp/my-figma-make-project" brand-theme --target-type themes
功能:
- 输入目录应来自 Figma 原始导出的 ZIP 工程包解压结果
- 完整复制 Figma Make 项目(排除 node_modules / .npm-local-cache / build
- 转换 @/ 路径别名
- 转换 package@version 导入
- 生成 AI 工作文档(默认 .figma-make-tasks.md主题模式 .figma-make-theme-tasks.md
- 生成分析报告(默认 .figma-make-analysis.json主题模式 .figma-make-theme-analysis.json
`);
}
async function main() {
let parsed;
try {
parsed = parseArgs(process.argv.slice(2));
} catch (error) {
log(`参数错误: ${error.message}`, 'error');
printHelp();
process.exit(1);
}
if (parsed.help) {
printHelp();
process.exit(0);
}
const figmaMakeDir = path.resolve(CONFIG.projectRoot, parsed.projectDirArg);
const targetInfo = getTargetInfo(parsed.targetType, parsed.outputName);
if (!fs.existsSync(figmaMakeDir)) {
log(`错误: 找不到目录 ${figmaMakeDir}`, 'error');
process.exit(1);
}
const srcDir = path.join(figmaMakeDir, 'src');
const packageJsonPath = path.join(figmaMakeDir, 'package.json');
const hasAppTsx = fs.existsSync(path.join(srcDir, 'App.tsx'));
const hasMainTsx = fs.existsSync(path.join(srcDir, 'main.tsx'));
if (!fs.existsSync(srcDir) || !fs.existsSync(packageJsonPath) || (!hasAppTsx && !hasMainTsx)) {
log('错误: 这不是一个有效的 Figma Make 项目(需要包含 src/、package.json以及 src/App.tsx 或 src/main.tsx', 'error');
process.exit(1);
}
try {
ensureDir(targetInfo.outputBaseDir);
log(`开始预处理 Figma Make 项目targetType=${parsed.targetType}...`, 'info');
log('步骤 1/4: 复制项目文件...', 'progress');
const fileCount = copyDirectory(figmaMakeDir, targetInfo.outputDir);
log(`已复制 ${fileCount} 个文件`, 'info');
log('步骤 2/4: 处理确定性转换(@/ 与 package@version...', 'progress');
const conversionResult = replaceAliasAndVersionImports(targetInfo.outputDir);
log(`已处理 ${conversionResult.processedCount} 个文件`, 'info');
log('步骤 3/4: 分析项目结构...', 'progress');
const report = analyzeProject(targetInfo.outputDir, conversionResult);
log(`发现 ${report.summary.componentCount} 个组件,${report.summary.pageCount} 个页面`, 'info');
log('步骤 4/4: 生成任务文档...', 'progress');
const { reportPath, mdPath } = parsed.targetType === 'themes'
? generateThemeTasksDocument(report, targetInfo, parsed.outputName, `temp/${path.basename(figmaMakeDir)}`)
: generateDefaultTasksDocument(report, targetInfo, parsed.outputName, `temp/${path.basename(figmaMakeDir)}`);
log('✅ 预处理完成!', 'info');
log('', 'info');
log(`📁 ${targetInfo.label}位置: ${targetInfo.relativeOutputDir}/`, 'info');
log(`📋 AI 工作文档: ${path.relative(CONFIG.projectRoot, mdPath)}`, 'info');
log(`📊 详细数据: ${path.relative(CONFIG.projectRoot, reportPath)}`, 'info');
log('', 'info');
log('📈 统计:', 'info');
log(` - 文件数: ${report.summary.totalFiles}`, 'info');
log(` - 组件数: ${report.summary.componentCount}`, 'info');
log(` - 页面数: ${report.summary.pageCount}`, 'info');
log(` - CSS 变量: ${report.summary.cssVariableCount}`, 'info');
log('', 'info');
log('🎯 下一步:', 'info');
log(`1. 查看任务文档: cat ${path.relative(CONFIG.projectRoot, mdPath)}`, 'info');
log(parsed.targetType === 'themes'
? '2. 让 AI 按任务单完成主题/文档/数据生成'
: '2. 让 AI 根据任务清单完成转换', 'info');
} catch (error) {
log(`预处理失败: ${error.message}`, 'error');
console.error(error);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* 为 dist 目录下的所有 JS 文件生成对应的 HTML 文件
* 使用 html-template.html 模板,用于构建后直接访问
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const workspaceRoot = path.resolve(__dirname, '..');
const distDir = path.resolve(workspaceRoot, 'dist');
const adminDir = path.resolve(workspaceRoot, 'admin');
const templatePath = path.join(adminDir, 'html-template.html');
let generatedCount = 0;
// 检查模板是否存在
if (!fs.existsSync(templatePath)) {
console.error('错误: html-template.html 模板不存在,请先构建 prototype-admin');
console.error('路径:', templatePath);
process.exit(1);
}
// 检查 dist 目录是否存在
if (!fs.existsSync(distDir)) {
console.log('dist 目录不存在,跳过 HTML 生成');
process.exit(0);
}
const template = fs.readFileSync(templatePath, 'utf8');
/**
* 递归遍历目录,为每个 JS 文件生成 HTML
*/
function generateHtmlForDirectory(dir, baseDir = distDir) {
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
const itemPath = path.join(dir, item.name);
if (item.isDirectory()) {
// 递归处理子目录
generateHtmlForDirectory(itemPath, baseDir);
} else if (item.name.endsWith('.js') && !item.name.includes('.worker')) {
// 为 JS 文件生成 HTML
const relativePath = path.relative(baseDir, itemPath);
const jsName = relativePath.replace('.js', '');
const htmlPath = path.join(dir, item.name.replace('.js', '.html'));
// 根据路径生成标题
let title = 'Preview';
if (jsName.startsWith('components/')) {
const name = jsName.replace('components/', '');
title = `Element: ${name}`;
} else if (jsName.startsWith('prototypes/')) {
const name = jsName.replace('prototypes/', '');
title = `Page: ${name}`;
}
// 计算 JS 文件的相对路径(从 HTML 的位置)
const jsFileName = `./${item.name}`;
// 计算 bootstrap JS 的相对路径
const relativeDir = path.relative(baseDir, dir);
const depth = relativeDir ? relativeDir.split(path.sep).length : 0;
const bootstrapPath = depth > 0
? '../'.repeat(depth) + 'assets/html-template-bootstrap.js'
: './assets/html-template-bootstrap.js';
// 替换模板变量
let html = template.replace('{{TITLE}}', title);
html = html.replace('{{ENTRY}}', jsFileName);
html = html.replace('{{BOOTSTRAP_PATH}}', bootstrapPath);
// 写入 HTML 文件
fs.writeFileSync(htmlPath, html, 'utf8');
console.log(`生成: ${relativePath.replace('.js', '.html')}`);
generatedCount++;
}
}
}
console.log('\n开始为 dist 目录生成 HTML 文件...\n');
generateHtmlForDirectory(distDir);
console.log(`\n完成!共生成 ${generatedCount} 个 HTML 文件。`);
console.log('模板文件: admin/html-template.html\n');
// 复制 bootstrap JS 到 dist/assets
const srcBootstrap = path.join(adminDir, 'assets/html-template-bootstrap.js');
const destAssetsDir = path.join(distDir, 'assets');
const destBootstrap = path.join(destAssetsDir, 'html-template-bootstrap.js');
if (fs.existsSync(srcBootstrap)) {
if (!fs.existsSync(destAssetsDir)) {
fs.mkdirSync(destAssetsDir, { recursive: true });
}
fs.copyFileSync(srcBootstrap, destBootstrap);
console.log('✓ html-template-bootstrap.js 已复制到 dist/assets');
// 同时复制其他必要的依赖(如果有的话)
const assetsFiles = fs.readdirSync(path.join(adminDir, 'assets'));
for (const file of assetsFiles) {
// 复制 bootstrap 依赖的 chunk 文件
if (file.startsWith('index-') && file.endsWith('.js')) {
const src = path.join(adminDir, 'assets', file);
const dest = path.join(destAssetsDir, file);
fs.copyFileSync(src, dest);
console.log(`${file} 已复制到 dist/assets`);
}
}
} else {
console.warn('⚠ html-template-bootstrap.js 不存在,请先构建 prototype-admin');
}

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env node
/**
* 为所有 components 和 prototypes 生成 index.html 文件
* 使用统一的模板
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const srcDir = path.resolve(__dirname, '../src');
const templatePath = path.join(srcDir, 'common/dev-template.html');
const elementsDir = path.join(srcDir, 'components');
const pagesDir = path.join(srcDir, 'prototypes');
let generatedCount = 0;
// 读取模板
if (!fs.existsSync(templatePath)) {
console.error('错误: 模板文件不存在:', templatePath);
process.exit(1);
}
const template = fs.readFileSync(templatePath, 'utf8');
function generateHtmlFiles(dir, type) {
if (!fs.existsSync(dir)) {
return;
}
const items = fs.readdirSync(dir);
for (const item of items) {
const itemPath = path.join(dir, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
const tsxPath = path.join(itemPath, 'index.tsx');
const htmlPath = path.join(itemPath, 'index.html');
// 只有存在 index.tsx 才生成 HTML
if (fs.existsSync(tsxPath)) {
// 生成标题
let title = 'Dev Preview';
if (type === 'components') {
title = `Element: ${item} - Dev Preview`;
} else if (type === 'prototypes') {
title = `Page: ${item} - Dev Preview`;
}
// 替换模板变量
let html = template.replace('{{TITLE}}', title);
html = html.replace('{{ENTRY}}', './index.tsx');
// 写入文件
fs.writeFileSync(htmlPath, html, 'utf8');
console.log(`生成: ${path.relative(srcDir, htmlPath)}`);
generatedCount++;
}
}
}
}
console.log('开始生成 HTML 文件...\n');
generateHtmlFiles(elementsDir, 'components');
generateHtmlFiles(pagesDir, 'prototypes');
console.log(`\n完成!共生成 ${generatedCount} 个 HTML 文件。`);
console.log('所有 HTML 文件都基于统一模板: src/common/dev-template.html');

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import extractZip from 'extract-zip';
function normalizePath(filePath) {
return filePath.split(path.sep).join('/');
}
function truncateName(name, maxLength = 40) {
if (!name) return name;
return name.length > maxLength ? name.slice(0, maxLength) : name;
}
function sanitizeBasename(name) {
return (name || 'axure')
.replace(/[^a-z0-9-]/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.toLowerCase();
}
function inferContentDir(extractDirAbs) {
const entries = fs
.readdirSync(extractDirAbs, { withFileTypes: true })
.filter(entry => entry.name !== '__MACOSX' && entry.name !== '.DS_Store');
if (entries.length === 1 && entries[0].isDirectory()) {
return path.join(extractDirAbs, entries[0].name);
}
return extractDirAbs;
}
async function main() {
const [zipPathArg, originalFilenameArg] = process.argv.slice(2);
if (!zipPathArg) {
console.error('Usage: local-axure-extract.mjs <zipPath> [originalFilename]');
process.exit(1);
}
const zipPath = path.resolve(process.cwd(), zipPathArg);
if (!fs.existsSync(zipPath)) {
console.error(`Zip not found: ${zipPath}`);
process.exit(1);
}
const originalFilename = originalFilenameArg || path.basename(zipPath);
const baseNameRaw = path.basename(originalFilename, path.extname(originalFilename));
const baseName = truncateName(sanitizeBasename(baseNameRaw));
const timestamp = Date.now();
const extractDirRel = normalizePath(path.join('temp', `local-axure-${baseName}-${timestamp}`));
const extractDirAbs = path.resolve(process.cwd(), extractDirRel);
fs.mkdirSync(extractDirAbs, { recursive: true });
await extractZip(zipPath, { dir: extractDirAbs });
const contentDirAbs = inferContentDir(extractDirAbs);
const contentDirRel = normalizePath(path.relative(process.cwd(), contentDirAbs));
process.stdout.write(
JSON.stringify({
extractDir: extractDirRel,
contentDir: contentDirRel,
}),
);
}
try {
await main();
} catch (error) {
console.error(error);
process.exit(1);
}

View File

@@ -0,0 +1,238 @@
#!/usr/bin/env node
/**
* 批量检查所有组件和页面
*/
import http from 'http';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const HOST = 'localhost';
const PORT = 51720;
function reviewCode(targetPath) {
return new Promise((resolve, reject) => {
const postData = JSON.stringify({ path: targetPath });
const options = {
hostname: HOST,
port: PORT,
path: '/api/code-review',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData)
},
timeout: 5000
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const result = JSON.parse(data);
resolve(result);
} catch (error) {
reject(new Error(`解析响应失败: ${error.message}`));
}
});
});
req.on('error', (error) => {
reject(error);
});
req.on('timeout', () => {
req.destroy();
reject(new Error('请求超时'));
});
req.write(postData);
req.end();
});
}
function getAllComponents() {
const srcDir = path.resolve(__dirname, '../src');
const components = [];
// 扫描 prototypes
const pagesDir = path.join(srcDir, 'prototypes');
if (fs.existsSync(pagesDir)) {
const prototypes = fs.readdirSync(pagesDir, { withFileTypes: true });
prototypes.forEach(page => {
if (page.isDirectory()) {
const indexPath = path.join(pagesDir, page.name, 'index.tsx');
if (fs.existsSync(indexPath)) {
components.push({
type: 'page',
name: page.name,
path: `prototypes/${page.name}`
});
}
}
});
}
// 扫描 components
const elementsDir = path.join(srcDir, 'components');
if (fs.existsSync(elementsDir)) {
const components = fs.readdirSync(elementsDir, { withFileTypes: true });
components.forEach(element => {
if (element.isDirectory()) {
const indexPath = path.join(elementsDir, element.name, 'index.tsx');
if (fs.existsSync(indexPath)) {
components.push({
type: 'element',
name: element.name,
path: `components/${element.name}`
});
}
}
});
}
return components;
}
function formatIssue(issue, indent = ' ') {
const icon = issue.type === 'error' ? '❌' : '⚠️';
let output = `${indent}${icon} [${issue.rule}] ${issue.message}`;
if (issue.suggestion) {
output += `\n${indent} 💡 ${issue.suggestion}`;
}
return output;
}
async function main() {
console.log('\n🔍 扫描项目中的所有组件和页面...\n');
const components = getAllComponents();
if (components.length === 0) {
console.log('❌ 未找到任何组件或页面\n');
process.exit(1);
}
console.log(`找到 ${components.length} 个组件/页面\n`);
console.log('='.repeat(70));
let totalChecked = 0;
let totalPassed = 0;
let totalErrors = 0;
let totalWarnings = 0;
const failedComponents = [];
for (const component of components) {
totalChecked++;
const displayName = `${component.type === 'page' ? '📄' : '🧩'} ${component.name}`;
try {
const result = await reviewCode(component.path);
if (result.error) {
console.log(`\n${displayName}`);
console.log(` ❌ 检查失败: ${result.error}`);
continue;
}
const errors = result.issues.filter(i => i.type === 'error');
const warnings = result.issues.filter(i => i.type === 'warning');
totalErrors += errors.length;
totalWarnings += warnings.length;
if (result.passed) {
totalPassed++;
console.log(`\n${displayName}`);
console.log(` ✅ 通过`);
} else {
failedComponents.push({
component,
result
});
console.log(`\n${displayName}`);
console.log(` ❌ 未通过 (${errors.length} 错误, ${warnings.length} 警告)`);
// 只显示错误,不显示警告(简化输出)
if (errors.length > 0) {
errors.forEach(issue => {
console.log(formatIssue(issue));
});
}
}
} catch (error) {
console.log(`\n${displayName}`);
console.log(` ❌ 检查失败: ${error.message}`);
}
}
// 总结
console.log('\n' + '='.repeat(70));
console.log('\n📊 检查总结:\n');
console.log(` 总计: ${totalChecked} 个组件/页面`);
console.log(` 通过: ${totalPassed} 个 ✅`);
console.log(` 失败: ${totalChecked - totalPassed} 个 ❌`);
console.log(` 错误: ${totalErrors}`);
console.log(` 警告: ${totalWarnings}`);
if (failedComponents.length > 0) {
console.log('\n⚠ 需要修复的组件:\n');
failedComponents.forEach(({ component }) => {
console.log(` - ${component.path}`);
});
}
console.log('\n' + '='.repeat(70) + '\n');
if (totalPassed === totalChecked) {
console.log('✅ 所有组件都符合规范!\n');
} else {
console.log('⚠️ 部分组件需要修复,请查看上面的详细信息。\n');
console.log('💡 使用以下命令查看单个组件的详细问题:');
console.log(' node scripts/code-review.js <path>\n');
process.exit(1);
}
}
// 检查服务器
function checkServer() {
return new Promise((resolve) => {
const req = http.get(`http://${HOST}:${PORT}/api/version`, (res) => {
resolve(true);
});
req.on('error', () => {
resolve(false);
});
req.setTimeout(2000, () => {
req.destroy();
resolve(false);
});
});
}
(async () => {
const serverRunning = await checkServer();
if (!serverRunning) {
console.error('\n❌ 错误:开发服务器未运行');
console.log('\n请先启动开发服务器');
console.log(' npm run dev\n');
process.exit(1);
}
await main();
})();

View File

@@ -0,0 +1,18 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { scanProjectEntries, writeEntriesManifestAtomic } from '../vite-plugins/utils/entriesManifestCore.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..');
const manifest = scanProjectEntries(projectRoot, ['components', 'prototypes', 'themes']);
const written = writeEntriesManifestAtomic(projectRoot, manifest);
console.log(
'Generated entries.json (schema v2) with',
Object.keys(written.js || {}).length,
'js entries and',
Object.keys(written.html || {}).length,
'html entries (using unified template)',
);

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env node
const baseUrl = process.argv[2] || 'http://localhost:51720';
const targets = process.argv.slice(3).length > 0
? process.argv.slice(3)
: [
'/prototypes/ref-antd-copy',
'/components/ref-button',
'/themes/antd-new',
];
let hasFailure = false;
for (const target of targets) {
const requestUrl = new URL(target, baseUrl).toString();
try {
const response = await fetch(requestUrl, {
redirect: 'follow',
headers: {
Accept: 'text/html',
},
});
const html = await response.text();
const htmlProxyMatches = Array.from(
html.matchAll(/src="([^"]*html-proxy[^"]*)"/g),
(match) => match[1],
);
const bootstrapProxy = htmlProxyMatches.find((value) => value.includes('index=0.js')) || null;
const hostProxy = htmlProxyMatches.find((value) => value.includes('index=1.js')) || null;
let bootstrapScript = '';
let hostScript = '';
if (bootstrapProxy) {
bootstrapScript = await fetch(new URL(bootstrapProxy, baseUrl)).then((res) => res.text());
}
if (hostProxy) {
hostScript = await fetch(new URL(hostProxy, baseUrl)).then((res) => res.text());
}
const ok = response.ok
&& html.includes('<div id="root"></div>')
&& htmlProxyMatches.length >= 2
&& !html.includes('waitForBootstrap')
&& bootstrapScript.includes('dev-template-bootstrap.js')
&& hostScript.includes('import PreviewComponent from')
&& hostScript.includes('import.meta.hot.accept(')
&& hostScript.includes(`window.AxhubDevComponent = CurrentComponent;`)
&& html.includes('<div id="root"></div>');
if (!ok) {
hasFailure = true;
console.error(`[preview-smoke] FAIL ${requestUrl}`);
console.error(` status=${response.status}`);
console.error(` containsRoot=${html.includes('<div id="root"></div>')}`);
console.error(` htmlProxyCount=${htmlProxyMatches.length}`);
console.error(` removedLegacyLoader=${!html.includes('waitForBootstrap')}`);
console.error(` bootstrapProxy=${Boolean(bootstrapProxy)}`);
console.error(` hostProxy=${Boolean(hostProxy)}`);
console.error(` hostImportsEntry=${hostScript.includes('import PreviewComponent from')}`);
console.error(` hostHasAcceptBoundary=${hostScript.includes('import.meta.hot.accept(')}`);
console.error(` hostSetsDebugGlobals=${hostScript.includes('window.AxhubDevComponent = CurrentComponent;')}`);
continue;
}
console.log(`[preview-smoke] OK ${requestUrl}`);
} catch (error) {
hasFailure = true;
console.error(`[preview-smoke] ERROR ${requestUrl}`);
console.error(` ${(error && error.message) || error}`);
}
}
if (hasFailure) {
process.exitCode = 1;
}

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
const baseUrl = process.argv[2] || 'http://localhost:51720';
const rootUrl = new URL('/', baseUrl).toString();
const previewUrl = new URL('/prototypes/ref-antd-copy', baseUrl).toString();
let hasFailure = false;
async function checkHtml(url, expected) {
const response = await fetch(url, {
redirect: 'follow',
headers: { Accept: 'text/html' },
});
const html = await response.text();
const result = {
ok: response.ok,
hasViteClient: html.includes('/@vite/client'),
hasReactRefreshPreamble: html.includes('/@react-refresh'),
};
const matches = expected(result);
if (!matches) {
hasFailure = true;
console.error(`[shell-boundary] FAIL ${url}`);
console.error(` status=${response.status}`);
console.error(` hasViteClient=${result.hasViteClient}`);
console.error(` hasReactRefreshPreamble=${result.hasReactRefreshPreamble}`);
return;
}
console.log(`[shell-boundary] OK ${url}`);
}
await checkHtml(rootUrl, (result) => (
result.ok
&& !result.hasViteClient
&& !result.hasReactRefreshPreamble
));
await checkHtml(previewUrl, (result) => (
result.ok
&& result.hasViteClient
&& result.hasReactRefreshPreamble
));
if (hasFailure) {
process.exitCode = 1;
}

View File

@@ -0,0 +1,832 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { assertValidGeneratedTsx } from './utils/generatedTsxValidator.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONFIG = {
projectRoot: path.resolve(__dirname, '..'),
pagesDir: path.resolve(__dirname, '../src/prototypes'),
pendingLogicFileName: '.stitch-pending.json',
pendingLogicMarker: 'STITCH_PENDING_LOGIC',
};
function log(message, type = 'info') {
const prefix = { info: '✓', warn: '⚠', error: '✗', progress: '⏳' }[type] || '';
console.log(`${prefix} ${message}`);
}
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function sanitizePageName(name) {
return String(name)
.replace(/[^a-z0-9-_]/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.toLowerCase();
}
function escapeForTemplateLiteral(value) {
return String(value)
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$\{/g, '\\${');
}
function extractTailwindConfig(html) {
const startMatch = html.match(/tailwind\.config\s*=\s*\{/);
if (!startMatch) return null;
const startIndex = startMatch.index + startMatch[0].length - 1;
let braceCount = 0;
let endIndex = startIndex;
for (let i = startIndex; i < html.length; i += 1) {
if (html[i] === '{') braceCount += 1;
else if (html[i] === '}') {
braceCount -= 1;
if (braceCount === 0) {
endIndex = i + 1;
break;
}
}
}
if (braceCount !== 0) return null;
const configStr = html.substring(startIndex, endIndex);
try {
let cleanedStr = configStr
.split('\n')
.map((line) => {
const commentIndex = line.indexOf('//');
if (commentIndex >= 0) {
return line.substring(0, commentIndex).trimEnd();
}
return line;
})
.join('\n');
cleanedStr = cleanedStr.replace(/,(\s*[}\]])/g, '$1');
return (function () {
return eval(`(${cleanedStr})`);
}());
} catch (error) {
console.error('[Stitch Converter] 解析 Tailwind 配置失败:', error.message);
return null;
}
}
function generateTailwindCSS(config) {
if (!config || !config.theme) return '';
const theme = config.theme;
const extend = theme.extend || {};
let css = '';
const processColors = (colors, prefix = '') => {
let colorCSS = '';
for (const [name, value] of Object.entries(colors)) {
if (typeof value === 'object' && !Array.isArray(value)) {
colorCSS += processColors(value, `${prefix}${name}-`);
} else {
const cssName = `${prefix}${name}`.replace(/([A-Z])/g, '-$1').toLowerCase();
colorCSS += ` --color-${cssName}: ${value};\n`;
}
}
return colorCSS;
};
if (extend.colors) {
css += '\n@theme {\n';
css += processColors(extend.colors);
css += '}\n';
}
if (extend.spacing) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.spacing)) {
css += ` --spacing-${name}: ${value};\n`;
}
css += '}\n';
}
if (extend.fontSize) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.fontSize)) {
const size = Array.isArray(value) ? value[0] : value;
css += ` --font-size-${name}: ${size};\n`;
if (Array.isArray(value) && value[1]) {
const lineHeight = typeof value[1] === 'object' ? value[1].lineHeight : value[1];
if (lineHeight) {
css += ` --line-height-${name}: ${lineHeight};\n`;
}
}
}
css += '}\n';
}
if (extend.fontFamily) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.fontFamily)) {
const family = Array.isArray(value) ? value.join(', ') : value;
css += ` --font-family-${name}: ${family};\n`;
}
css += '}\n';
}
if (extend.borderRadius) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.borderRadius)) {
const cssName = name === 'DEFAULT' ? 'default' : name;
css += ` --radius-${cssName}: ${value};\n`;
}
css += '}\n';
}
if (extend.boxShadow) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.boxShadow)) {
const cssName = name === 'DEFAULT' ? 'default' : name;
css += ` --shadow-${cssName}: ${value};\n`;
}
css += '}\n';
}
if (extend.screens) {
css += '\n/* 自定义断点 */\n';
for (const [name, value] of Object.entries(extend.screens)) {
css += `/* @screen ${name}: ${value} */\n`;
}
}
if (extend.zIndex) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.zIndex)) {
css += ` --z-index-${name}: ${value};\n`;
}
css += '}\n';
}
if (extend.opacity) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.opacity)) {
css += ` --opacity-${name}: ${value};\n`;
}
css += '}\n';
}
if (extend.keyframes) {
css += '\n/* 动画关键帧 */\n';
for (const [name, frames] of Object.entries(extend.keyframes)) {
css += `@keyframes ${name} {\n`;
for (const [percent, styles] of Object.entries(frames)) {
css += ` ${percent} {\n`;
for (const [prop, val] of Object.entries(styles)) {
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
css += ` ${cssProp}: ${val};\n`;
}
css += ' }\n';
}
css += '}\n\n';
}
}
if (extend.animation) {
css += '\n/* 动画工具类 */\n';
css += '@layer utilities {\n';
for (const [name, value] of Object.entries(extend.animation)) {
css += ` .animate-${name} {\n`;
css += ` animation: ${value};\n`;
css += ' }\n';
}
css += '}\n\n';
}
if (extend.transitionDuration) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.transitionDuration)) {
css += ` --duration-${name}: ${value};\n`;
}
css += '}\n';
}
if (extend.transitionTimingFunction) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.transitionTimingFunction)) {
css += ` --ease-${name}: ${value};\n`;
}
css += '}\n';
}
if (extend.backgroundImage) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.backgroundImage)) {
css += ` --gradient-${name}: ${value};\n`;
}
css += '}\n';
}
if (config.darkMode) {
css += `\n/* Dark Mode: ${config.darkMode} */\n`;
}
return css;
}
function isTailwindConfigScript(attrs, content) {
return /tailwind\.config/.test(content) || /id=["']tailwind-config["']/i.test(attrs);
}
function summarizeScript(script) {
const source = script.content || script.src || '';
const normalized = source.toLowerCase();
if (/setinterval|settimeout/.test(normalized) && /time|date|clock/.test(normalized)) {
return '更新时间或日期显示';
}
if (/addEventListener\s*\(\s*['"]click['"]/.test(source) || /\.onclick\s*=/.test(source)) {
return '点击交互处理';
}
if (/addEventListener\s*\(\s*['"]scroll['"]/.test(source)) {
return '滚动相关行为';
}
if (/querySelector|getElementById|textContent|innerHTML/.test(source)) {
return '页面内容或状态更新';
}
if (script.src) {
return `外部脚本:${script.src}`;
}
return '页面动态逻辑';
}
function suggestMigration(script) {
const source = script.content || script.src || '';
const normalized = source.toLowerCase();
if (/setinterval|settimeout/.test(normalized)) {
return '迁移到 React.useEffect并在需要时配合 useState 管理显示内容。';
}
if (/addEventListener|onclick|onsubmit|onchange/.test(normalized)) {
return '迁移到组件事件处理函数或 React.useEffect 的事件绑定。';
}
if (/querySelector|getElementById|textContent|innerHTML/.test(source)) {
return '改为通过组件状态、props 或 ref 更新界面,避免直接操作 DOM。';
}
if (script.src) {
return '确认该脚本的用途后,再决定是否以 npm 依赖、按需加载或替代实现接入。';
}
return '建议拆解为组件状态、效果函数或独立 helper 后接入。';
}
function createPendingScript(script, index) {
return {
id: script.id || `${script.location}-script-${index + 1}`,
location: script.location,
src: script.src || null,
summary: summarizeScript(script),
suggestedMigration: suggestMigration(script),
code: script.content || '',
};
}
function extractHeadContent(html) {
const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
if (!headMatch) return { links: [], styles: [], pendingScripts: [] };
const headContent = headMatch[1];
const links = [];
const styles = [];
const pendingScripts = [];
const scriptRegex = /<script([^>]*)>([\s\S]*?)<\/script>/gi;
let match;
while ((match = scriptRegex.exec(headContent)) !== null) {
const attrs = match[1];
const content = match[2].trim();
const srcMatch = attrs.match(/src=["']([^"']+)["']/i);
if (srcMatch) {
const src = srcMatch[1].replace(/&amp;/g, '&');
if (src.includes('tailwindcss.com')) continue;
pendingScripts.push(createPendingScript({
location: 'head',
id: attrs.match(/id=["']([^"']+)["']/i)?.[1] || null,
src,
content: '',
}, pendingScripts.length));
continue;
}
if (!content || isTailwindConfigScript(attrs, content)) {
continue;
}
pendingScripts.push(createPendingScript({
location: 'head',
id: attrs.match(/id=["']([^"']+)["']/i)?.[1] || null,
src: null,
content,
}, pendingScripts.length));
}
const linkRegex = /<link[^>]*>/gi;
while ((match = linkRegex.exec(headContent)) !== null) {
const tag = match[0];
const href = tag.match(/href=["']([^"']+)["']/i)?.[1];
if (href) {
links.push({
href: href.replace(/&amp;/g, '&'),
rel: tag.match(/rel=["']([^"']+)["']/i)?.[1] || 'stylesheet',
crossorigin: tag.includes('crossorigin'),
});
}
}
const styleRegex = /<style[^>]*>([\s\S]*?)<\/style>/gi;
while ((match = styleRegex.exec(headContent)) !== null) {
const content = match[1].trim();
if (content) {
styles.push(content);
}
}
return { links, styles, pendingScripts };
}
function convertStyleToJSX(styleStr) {
if (!styleStr.trim()) return 'style={{}}';
const styles = [];
let currentProp = '';
let inUrl = false;
for (let i = 0; i < styleStr.length; i += 1) {
const char = styleStr[i];
if (char === '(' && styleStr.substring(i - 3, i) === 'url') inUrl = true;
else if (char === ')' && inUrl) inUrl = false;
if (char === ';' && !inUrl) {
if (currentProp.trim()) styles.push(currentProp.trim());
currentProp = '';
} else {
currentProp += char;
}
}
if (currentProp.trim()) styles.push(currentProp.trim());
const jsxStyles = styles
.filter((item) => item.includes(':'))
.map((item) => {
const colonIndex = item.indexOf(':');
const key = item.substring(0, colonIndex).trim();
const value = item.substring(colonIndex + 1).trim();
if (!key || !value) return '';
const camelKey = key.replace(/-([a-z])/g, (group) => group[1].toUpperCase());
let jsxValue;
if (value.startsWith('url(') || value.includes('var(')) {
jsxValue = `'${value.replace(/'/g, "\\'")}'`;
} else if (/^-?\d+(\.\d+)?$/.test(value)) {
jsxValue = value;
} else {
jsxValue = `'${value.replace(/'/g, "\\'")}'`;
}
return `${camelKey}: ${jsxValue}`;
})
.filter(Boolean)
.join(', ');
return `style={{ ${jsxStyles} }}`;
}
function convertCommonAttributesToJSX(content) {
const replacements = [
['class', 'className'],
['for', 'htmlFor'],
['tabindex', 'tabIndex'],
['readonly', 'readOnly'],
['maxlength', 'maxLength'],
['minlength', 'minLength'],
['colspan', 'colSpan'],
['rowspan', 'rowSpan'],
['viewbox', 'viewBox'],
['preserveaspectratio', 'preserveAspectRatio'],
['clip-path', 'clipPath'],
['fill-rule', 'fillRule'],
['clip-rule', 'clipRule'],
['stroke-width', 'strokeWidth'],
['stroke-dasharray', 'strokeDasharray'],
['stroke-dashoffset', 'strokeDashoffset'],
['stroke-linecap', 'strokeLinecap'],
['stroke-linejoin', 'strokeLinejoin'],
['stroke-miterlimit', 'strokeMiterlimit'],
['stroke-opacity', 'strokeOpacity'],
['fill-opacity', 'fillOpacity'],
['stop-color', 'stopColor'],
['stop-opacity', 'stopOpacity'],
['xlink:href', 'xlinkHref'],
['xmlns:xlink', 'xmlnsXlink'],
];
let nextContent = content;
replacements.forEach(([from, to]) => {
nextContent = nextContent.replace(new RegExp(`(\\s)${from}=`, 'gi'), `$1${to}=`);
});
return nextContent;
}
function convertHtmlToJsx(content) {
return convertCommonAttributesToJSX(content)
.replace(/<!--([\s\S]*?)-->/g, '{/* $1 */}')
.replace(/(<pre[^>]*>)([\s\S]*?)(<\/pre>)/gi, (match, openTag, preContent) => {
const escapedContent = preContent
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$')
.replace(/\{/g, '\\{');
return `${openTag.slice(0, -1)} dangerouslySetInnerHTML={{ __html: \`${escapedContent}\` }} />`;
})
.replace(/style='([^']*)'/gi, (match, styleStr) => convertStyleToJSX(styleStr))
.replace(/style="([^"]*)"/gi, (match, styleStr) => convertStyleToJSX(styleStr));
}
function extractBodyContent(html) {
const bodyMatch = html.match(/(<body[^>]*>)([\s\S]*?)<\/body>/i);
if (!bodyMatch) {
return {
wrapperOpenTag: '<div data-stitch-root="true">',
jsxContent: '',
pendingScripts: [],
};
}
const openTag = convertHtmlToJsx(bodyMatch[1])
.replace(/^<body/i, '<div data-stitch-root="true"');
const innerContent = bodyMatch[2];
const pendingScripts = [];
const contentWithoutScripts = innerContent.replace(/<script([^>]*)>([\s\S]*?)<\/script>/gi, (match, attrs, content) => {
const trimmedContent = String(content || '').trim();
pendingScripts.push(createPendingScript({
location: 'body',
id: attrs.match(/id=["']([^"']+)["']/i)?.[1] || attrs.match(/data-purpose=["']([^"']+)["']/i)?.[1] || null,
src: attrs.match(/src=["']([^"']+)["']/i)?.[1]?.replace(/&amp;/g, '&') || null,
content: trimmedContent,
}, pendingScripts.length));
return '';
});
return {
wrapperOpenTag: openTag,
jsxContent: convertHtmlToJsx(contentWithoutScripts.trim()),
pendingScripts,
};
}
function buildPendingSummaryLines(pendingScripts) {
return pendingScripts.map((item) => ` // - [${item.location}] ${item.id}: ${item.summary}`);
}
function buildPendingEffectBlock(pendingScripts) {
if (pendingScripts.length === 0) return '';
const summaryLines = buildPendingSummaryLines(pendingScripts).join('\n');
return `
React.useEffect(function () {
// ${CONFIG.pendingLogicMarker}_START
// 后续可在这里继续完善页面的动态细节。
// 参考同目录下的 ${CONFIG.pendingLogicFileName}
${summaryLines}
// ${CONFIG.pendingLogicMarker}_END
}, []);
`;
}
function buildFriendlyNotice() {
return `
<div
className="pointer-events-none fixed right-4 top-4 z-[100] max-w-xs rounded-2xl border border-white/60 bg-white/88 px-4 py-3 text-sm text-slate-700 shadow-lg backdrop-blur"
style={{ boxShadow: '0 18px 45px rgba(15, 23, 42, 0.12)' }}
>
<div className="font-semibold text-slate-900">基础效果已就绪</div>
<div className="mt-1 leading-6 text-slate-600">
当前可先查看页面结构与样式效果,部分动态细节可在后续继续完善。
</div>
</div>`;
}
function generateComponent(pageName, wrapperOpenTag, bodyJsxContent, headContent, pendingScripts) {
const componentName = pageName
.split(/[-_\s]+/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
const injectionBlocks = [];
if (headContent.links.length > 0) {
injectionBlocks.push(`
React.useEffect(function () {
const injected = [];
${JSON.stringify(headContent.links)}.forEach(function (linkInfo) {
const existing = document.querySelector(\`link[href="\${linkInfo.href}"]\`);
if (!existing) {
const link = document.createElement('link');
link.rel = linkInfo.rel;
link.href = linkInfo.href;
if (linkInfo.crossorigin) link.crossOrigin = 'anonymous';
document.head.appendChild(link);
injected.push(link);
}
});
return function () {
injected.forEach(function (el) {
if (el.parentNode) el.parentNode.removeChild(el);
});
};
}, []);
`);
}
if (pendingScripts.length > 0) {
injectionBlocks.push(buildPendingEffectBlock(pendingScripts));
}
const friendlyNotice = pendingScripts.length > 0 ? buildFriendlyNotice() : '';
const wrapperJsx = `${wrapperOpenTag}
${friendlyNotice}
${bodyJsxContent}
</div>`;
return `/**
* @name ${pageName}
*
* 参考资料:
* - /rules/development-guide.md
* - /skills/default-resource-recommendations/SKILL.md
*/
import './style.css';
import React, { forwardRef, useImperativeHandle } from 'react';
import type { AxureProps, AxureHandle } from '../../common/axure-types';
const Component = forwardRef(function ${componentName}(
_props: AxureProps,
ref: React.ForwardedRef<AxureHandle>,
) {
useImperativeHandle(ref, function () {
return {
getVar: function () { return undefined; },
fireAction: function () {},
eventList: [],
actionList: [],
varList: [],
configList: [],
dataList: []
};
}, []);
${injectionBlocks.join('')}
return (
${wrapperJsx.split('\n').map((line) => ` ${line}`).join('\n')}
);
});
export default Component;
`;
}
function generateStyleCSS(headContent, tailwindConfig) {
let css = '@import "tailwindcss";\n';
if (tailwindConfig) {
css += '\n/* 从 Stitch Tailwind 配置提取的样式 */';
css += generateTailwindCSS(tailwindConfig);
}
if (headContent.styles && headContent.styles.length > 0) {
css += '\n/* 原始自定义样式 */\n';
css += headContent.styles.join('\n\n');
}
return css;
}
function buildPendingLogicPayload(pageName, outputDir, pendingScripts) {
const relativeDir = path.relative(CONFIG.projectRoot, outputDir).split(path.sep).join('/');
const relativeIndexFile = `${relativeDir}/index.tsx`;
const relativePendingFile = `${relativeDir}/${CONFIG.pendingLogicFileName}`;
return {
pageName,
generatedAt: new Date().toISOString(),
marker: CONFIG.pendingLogicMarker,
indexFile: relativeIndexFile,
pendingFile: relativePendingFile,
items: pendingScripts,
};
}
function buildPrompt(pageName, outputDir, pendingPayload) {
const relativeDir = path.relative(CONFIG.projectRoot, outputDir).split(path.sep).join('/');
const relativeHtmlFile = `${relativeDir}/code.html`;
const summaries = pendingPayload.items
.map((item) => `- [${item.location}] ${item.id}: ${item.summary}。建议:${item.suggestedMigration}`)
.join('\n');
return `请继续完善 Stitch 导入后的页面“${pageName}”。
目标目录:
- \`${relativeDir}\`
请优先阅读以下文件:
- \`${relativeDir}/index.tsx\`
- \`${relativeDir}/${CONFIG.pendingLogicFileName}\`
- \`${relativeHtmlFile}\`
当前页面已经可以静态预览,但仍有待完善的动态细节:
${summaries}
请按以下方式处理:
1. 在 \`index.tsx\` 中的 \`${CONFIG.pendingLogicMarker}_START\` / \`${CONFIG.pendingLogicMarker}_END\` 区域附近补入需要的 React 逻辑。
2. 把原始脚本改写为 React 状态、effects、事件处理函数或必要的 helper。
3. 尽量不要直接操作 DOM优先使用 state、props、ref。
4. 完成逻辑迁移后,补充生成同目录下的 \`spec.md\`,说明页面结构、动态行为和实现要点。
5. 最后运行项目内的可用验收方式,确认页面可正常预览。
`;
}
function convertPage(pagePath, outputDir, pageName) {
log(`正在转换页面: ${pageName}`, 'progress');
const htmlPath = path.join(pagePath, 'code.html');
const html = fs.readFileSync(htmlPath, 'utf8');
const tailwindConfig = extractTailwindConfig(html);
const headContent = extractHeadContent(html);
const bodyContent = extractBodyContent(html);
const pendingScripts = [...headContent.pendingScripts, ...bodyContent.pendingScripts];
ensureDir(outputDir);
const componentCode = generateComponent(
pageName,
bodyContent.wrapperOpenTag,
bodyContent.jsxContent,
headContent,
pendingScripts,
);
const styleCSS = generateStyleCSS(headContent, tailwindConfig);
const outputTsxPath = path.join(outputDir, 'index.tsx');
assertValidGeneratedTsx(componentCode, outputTsxPath);
fs.writeFileSync(outputTsxPath, componentCode, 'utf8');
fs.writeFileSync(path.join(outputDir, 'style.css'), styleCSS, 'utf8');
const pendingPayload = buildPendingLogicPayload(pageName, outputDir, pendingScripts);
const pendingFilePath = path.join(outputDir, CONFIG.pendingLogicFileName);
if (pendingScripts.length > 0) {
fs.writeFileSync(pendingFilePath, `${JSON.stringify(pendingPayload, null, 2)}\n`, 'utf8');
log(` ✓ 已整理 ${pendingScripts.length} 段待完善逻辑`, 'info');
} else if (fs.existsSync(pendingFilePath)) {
fs.unlinkSync(pendingFilePath);
}
log(`页面转换完成: ${pageName}`, 'info');
return {
pageName,
outputDir,
pendingScripts,
pendingFile: pendingScripts.length > 0 ? pendingFilePath : null,
prompt: pendingScripts.length > 0 ? buildPrompt(pageName, outputDir, pendingPayload) : null,
};
}
function detectProjectType(stitchDir) {
const items = fs.readdirSync(stitchDir);
if (items.includes('code.html')) {
return { type: 'single', prototypes: [{ name: 'index', path: stitchDir }] };
}
const prototypes = [];
for (const item of items) {
const itemPath = path.join(stitchDir, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory() && fs.existsSync(path.join(itemPath, 'code.html'))) {
prototypes.push({ name: item, path: itemPath });
}
}
if (prototypes.length > 0) return { type: 'multi', prototypes };
throw new Error('未找到有效的 Stitch 项目结构');
}
function printResult(result) {
console.log(JSON.stringify(result));
}
async function main() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help') {
console.log(`
使用方法:
node scripts/stitch-converter.mjs <stitch-dir> [output-name]
`);
process.exit(0);
}
const stitchDirArg = args[0];
const outputName = sanitizePageName(args[1] || path.basename(stitchDirArg));
const stitchDir = path.resolve(CONFIG.projectRoot, stitchDirArg);
const outputDir = path.join(CONFIG.pagesDir, outputName);
if (!fs.existsSync(stitchDir)) {
log(`错误: 找不到目录 ${stitchDir}`, 'error');
process.exit(1);
}
try {
log('开始转换 Stitch 项目...', 'info');
const { type, prototypes } = detectProjectType(stitchDir);
log(`项目类型: ${type === 'single' ? '单页面' : '多页面'}`, 'info');
if (type === 'single') {
const pageResult = convertPage(prototypes[0].path, outputDir, outputName);
const result = {
success: true,
type,
mode: pageResult.pendingScripts.length > 0 ? 'ai_handoff' : 'safe_component',
pageName: outputName,
outputDir,
pendingLogicCount: pageResult.pendingScripts.length,
requiresAi: pageResult.pendingScripts.length > 0,
prompt: pageResult.prompt,
reasons: pageResult.pendingScripts.map((item) => `${item.summary}${item.location}`),
};
log('✅ 转换完成!', 'info');
printResult(result);
return;
}
const convertedPages = [];
for (const page of prototypes) {
const pageFolderName = sanitizePageName(`${outputName}-${page.name}`);
const pageOutputDir = path.join(CONFIG.pagesDir, pageFolderName);
const pageResult = convertPage(page.path, pageOutputDir, page.name);
convertedPages.push({
pageName: page.name,
folderName: pageFolderName,
outputDir: pageOutputDir,
pendingLogicCount: pageResult.pendingScripts.length,
requiresAi: pageResult.pendingScripts.length > 0,
prompt: pageResult.prompt,
reasons: pageResult.pendingScripts.map((item) => `${item.summary}${item.location}`),
});
}
log('✅ 转换完成!', 'info');
printResult({
success: true,
type,
mode: convertedPages.some((item) => item.requiresAi) ? 'ai_handoff' : 'safe_component',
pageName: outputName,
outputDir,
pages: convertedPages,
pendingLogicCount: convertedPages.reduce((sum, item) => sum + item.pendingLogicCount, 0),
requiresAi: convertedPages.some((item) => item.requiresAi),
prompt: convertedPages.find((item) => item.prompt)?.prompt || null,
reasons: convertedPages.flatMap((item) => item.reasons),
});
} catch (error) {
log(`转换失败: ${error.message}`, 'error');
process.exit(1);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main();
}
export {
generateComponent,
};

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env node
/**
* switch-agent.mjs — Switch the active cc-connect agent.
*
* Usage:
* node scripts/switch-agent.mjs <agent> # Switch to agent (codex, claudecode, gemini)
* node scripts/switch-agent.mjs --status # Show current active agent
*
* Strategy:
* 1. Try HTTP API (dev server has write permissions to ~/.cc-connect/)
* 2. Fall back to direct file manipulation if HTTP fails
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
import { execSync } from 'child_process';
const SUPPORTED_AGENTS = ['codex', 'claudecode', 'gemini'];
const ALIASES = { claude: 'claudecode', cc: 'claudecode', gem: 'gemini', cx: 'codex' };
// Read dev server info to get the port
function getDevServerPort() {
const candidates = [
path.join(process.cwd(), '.axhub', 'make', '.dev-server-info.json'),
path.resolve(import.meta.dirname, '..', '.axhub', 'make', '.dev-server-info.json'),
];
for (const p of candidates) {
try {
const info = JSON.parse(fs.readFileSync(p, 'utf8'));
if (info?.port) return info.port;
} catch { /* ignore */ }
}
return 51720; // default
}
// ---------------------------------------------------------------------------
// HTTP-based switching (preferred — dev server has write permissions)
// ---------------------------------------------------------------------------
async function httpSwitch(target) {
const port = getDevServerPort();
const url = `http://127.0.0.1:${port}/api/cc-connect/switch-agent`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent: target }),
signal: AbortSignal.timeout(15000),
});
const data = await res.json();
if (data.success) {
return { ok: true, detail: data.message || `已切换到 ${target}` };
}
return { ok: false, detail: data.error || '切换失败' };
}
async function httpStatus() {
const port = getDevServerPort();
const url = `http://127.0.0.1:${port}/api/cc-connect/active-agent`;
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
return await res.json();
}
// ---------------------------------------------------------------------------
// File-based switching (fallback — requires write permissions to ~/.cc-connect/)
// ---------------------------------------------------------------------------
const CC_CONNECT_DIR = path.join(os.homedir(), '.cc-connect');
const STATE_PATH = path.join(CC_CONNECT_DIR, 'axhub-weixin-state.json');
const CONFIG_PATH = path.join(CC_CONNECT_DIR, 'config.toml');
function readState() {
if (!fs.existsSync(STATE_PATH)) return null;
try {
const parsed = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
if (parsed?.version === 1 && parsed?.activeAgent && parsed?.weixinOptions?.token) {
return parsed;
}
} catch { /* ignore */ }
return null;
}
function escapeToml(value) {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
}
function fileSwitch(target) {
const state = readState();
if (!state) {
return { ok: false, detail: '未找到微信绑定状态,请先在管理后台扫码绑定' };
}
if (state.activeAgent === target) {
return { ok: true, detail: `当前已是 ${target},无需切换` };
}
state.activeAgent = target;
fs.mkdirSync(CC_CONNECT_DIR, { recursive: true });
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), 'utf8');
const optionLines = Object.entries(state.weixinOptions)
.map(([k, v]) => `${k} = "${escapeToml(String(v))}"`)
.join('\n');
const config = `# Auto-generated by Axhub Make
# language = "zh"
[log]
level = "info"
[[projects]]
name = "axhub-${escapeToml(target)}"
[projects.agent]
type = "${escapeToml(target)}"
[projects.agent.options]
work_dir = "${escapeToml(state.workDir)}"
mode = "yolo"
[[projects.platforms]]
type = "weixin"
[projects.platforms.options]
${optionLines}
`;
fs.writeFileSync(CONFIG_PATH, config, 'utf8');
const ccCommand = process.platform === 'win32' ? 'cc-connect.cmd' : 'cc-connect';
try { execSync(`${ccCommand} daemon stop`, { stdio: 'ignore', timeout: 10000 }); } catch { /* */ }
try { execSync(`${ccCommand} daemon uninstall`, { stdio: 'ignore', timeout: 10000 }); } catch { /* */ }
try {
execSync(`${ccCommand} daemon install --config "${CONFIG_PATH}"`, { stdio: 'inherit', timeout: 20000 });
return { ok: true, detail: `已切换到 ${target}` };
} catch (error) {
return { ok: false, detail: `配置已更新,但 daemon 重启失败: ${error.message}` };
}
}
function fileStatus() {
const state = readState();
if (state) {
return { activeAgent: state.activeAgent, configuredAgents: state.configuredAgents };
}
return null;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help') {
console.log('用法: node scripts/switch-agent.mjs <agent>');
console.log(`可用: ${SUPPORTED_AGENTS.join(', ')}`);
console.log('别名: claude→claudecode, gem→gemini, cx→codex');
console.log(' node scripts/switch-agent.mjs --status');
process.exit(0);
}
if (args[0] === '--status') {
const data = fileStatus();
if (data) {
console.log(JSON.stringify(data, null, 2));
} else {
try {
const httpData = await httpStatus();
console.log(JSON.stringify(httpData, null, 2));
} catch {
console.log('未找到微信绑定状态');
}
}
process.exit(0);
}
let target = args[0].toLowerCase();
if (ALIASES[target]) target = ALIASES[target];
if (!SUPPORTED_AGENTS.includes(target)) {
console.error(`❌ 不支持的 agent: ${target}`);
console.error(`可用: ${SUPPORTED_AGENTS.join(', ')}`);
process.exit(1);
}
// Strategy: try file-based first, fall back to HTTP API
try {
const result = fileSwitch(target);
if (result.ok) {
console.log(`${result.detail}`);
process.exit(0);
} else {
console.error(`❌ 本地切换失败: ${result.detail}`);
console.log('尝试 HTTP API 切换...');
}
} catch (fileError) {
console.log(`本地文件操作受限 (${fileError.message}),尝试 HTTP API 切换...`);
}
// Fallback to HTTP API
try {
const result = await httpSwitch(target);
if (result.ok) {
console.log(`${result.detail}`);
} else {
console.error(`${result.detail}`);
process.exit(1);
}
} catch (httpError) {
console.error(`❌ HTTP API 也不可达: ${httpError.message}`);
console.error('请在管理后台的微信连接对话框中进行切换');
process.exit(1);
}

View File

@@ -0,0 +1,125 @@
import { mkdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..');
const LEGACY_DOC_SKILLS_SEGMENT = '/doc-skills/';
const LEGACY_DOC_SKILLS_DIR = path.resolve(projectRoot, 'skills/third-party/doc-skills');
const SKILL_SOURCES = [
{
target: 'skills/third-party/frontend-design/SKILL.md',
url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/frontend-design/SKILL.md',
},
{
target: 'skills/third-party/interface-design/SKILL.md',
url: 'https://raw.githubusercontent.com/Dammyjay93/interface-design/main/.claude/skills/interface-design/SKILL.md',
},
{
target: 'skills/ui-ux-pro-max/SKILL.md',
url: 'https://raw.githubusercontent.com/nextlevelbuilder/ui-ux-pro-max-skill/main/.claude/skills/ui-ux-pro-max/SKILL.md',
},
{
target: 'skills/third-party/implement-design/SKILL.md',
url: 'https://raw.githubusercontent.com/figma/mcp-server-guide/main/skills/implement-design/SKILL.md',
},
{
target: 'skills/third-party/baoyu-image-gen/SKILL.md',
url: 'https://raw.githubusercontent.com/jimliu/baoyu-skills/main/skills/baoyu-image-gen/SKILL.md',
},
{
target: 'skills/third-party/shadcn-ui/SKILL.md',
url: 'https://raw.githubusercontent.com/giuseppe-trisciuoglio/developer-kit/main/plugins/developer-kit-typescript/skills/shadcn-ui/SKILL.md',
},
{
target: 'skills/third-party/ant-design/SKILL.md',
url: 'https://raw.githubusercontent.com/ant-design/antd-skill/main/skills/ant-design/SKILL.md',
},
{
target: 'skills/third-party/stitch-skills/design-md/SKILL.md',
url: 'https://raw.githubusercontent.com/google-labs-code/stitch-skills/main/skills/design-md/SKILL.md',
},
{
target: 'skills/third-party/stitch-skills/react-components/SKILL.md',
url: 'https://raw.githubusercontent.com/google-labs-code/stitch-skills/main/skills/react-components/SKILL.md',
},
{
target: 'skills/third-party/stitch-skills/stitch-loop/SKILL.md',
url: 'https://raw.githubusercontent.com/google-labs-code/stitch-skills/main/skills/stitch-loop/SKILL.md',
},
{
target: 'skills/third-party/brainstorming/SKILL.md',
url: 'https://raw.githubusercontent.com/obra/superpowers/main/skills/brainstorming/SKILL.md',
},
{
target: 'skills/third-party/deep-research/SKILL.md',
url: 'https://raw.githubusercontent.com/199-biotechnologies/claude-deep-research-skill/main/SKILL.md',
},
{
target: 'skills/third-party/anything-to-notebooklm/SKILL.md',
url: 'https://raw.githubusercontent.com/joeseesun/anything-to-notebooklm/main/SKILL.md',
},
{
target: 'skills/third-party/prd/SKILL.md',
url: 'https://raw.githubusercontent.com/github/awesome-copilot/main/skills/prd/SKILL.md',
},
{
target: 'skills/third-party/product-requirements/SKILL.md',
url: 'https://raw.githubusercontent.com/stellarlinkco/myclaude/master/skills/product-requirements/SKILL.md',
},
{
target: 'skills/third-party/research/SKILL.md',
url: 'https://raw.githubusercontent.com/tavily-ai/skills/main/skills/tavily/research/SKILL.md',
},
{
target: 'skills/third-party/user-story-writing/SKILL.md',
url: 'https://raw.githubusercontent.com/aj-geddes/useful-ai-prompts/main/skills/user-story-writing/SKILL.md',
},
];
async function syncSkill({ target, url }) {
if (target.includes(LEGACY_DOC_SKILLS_SEGMENT)) {
throw new Error(`Legacy nested target is not allowed: ${target}`);
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
const content = await response.text();
const outputPath = path.resolve(projectRoot, target);
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, content, 'utf8');
}
async function main() {
let hasErrors = false;
await rm(LEGACY_DOC_SKILLS_DIR, { recursive: true, force: true });
for (const source of SKILL_SOURCES) {
try {
await syncSkill(source);
console.log(`[OK] ${source.target}`);
} catch (error) {
hasErrors = true;
const message = error instanceof Error ? error.message : String(error);
console.error(`[FAIL] ${source.target} - ${message}`);
}
}
if (hasErrors) {
process.exitCode = 1;
return;
}
console.log(`Synced ${SKILL_SOURCES.length} third-party skills.`);
}
await main();

View File

@@ -0,0 +1,593 @@
{
"command": "inspect",
"generatedAt": "2026-04-01T10:21:53.845Z",
"figPath": "/Users/jianzhoulin/rd/Axhub Runtime/apps/axhub-make/scripts/templates/empty-canvas.fig",
"archive": {
"prelude": "fig-make",
"version": 101,
"parts": 2
},
"sourceRoot": "src",
"summary": {
"totalCodeFiles": 63,
"pathCounts": {
"(root)": 2,
"components": 5,
"components/figma": 1,
"components/mockups": 5,
"components/ui": 48,
"guidelines": 1,
"styles": 1
},
"duplicateGroups": []
},
"entries": [
{
"nodeChangeIndex": 7,
"name": "App.tsx",
"codeFilePath": null,
"logicalPath": "App.tsx",
"sourceCodeSha1": "08dbcc7fa8dfe3f4d85267eaee09a1b86ce3ab3e",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 9,
"name": "accordion.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/accordion.tsx",
"sourceCodeSha1": "96e2ea4a76d985018b49b3f26c18bd060992ba1c",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 10,
"name": "alert-dialog.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/alert-dialog.tsx",
"sourceCodeSha1": "4499b93fee3f76e8a2cfee3e04ad9acc14e914f8",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 11,
"name": "alert.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/alert.tsx",
"sourceCodeSha1": "c4c43c93ca441a0cc96160fcbddc367b42bd0785",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 12,
"name": "aspect-ratio.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/aspect-ratio.tsx",
"sourceCodeSha1": "da69e8483774b6cb06c9f1109f6c5481846fa373",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 13,
"name": "avatar.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/avatar.tsx",
"sourceCodeSha1": "acca40a2a8a7b9e8b16d56e34722622875ce9279",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 14,
"name": "badge.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/badge.tsx",
"sourceCodeSha1": "2bed0297b71935fb53c36665a93713017b4efba0",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 15,
"name": "breadcrumb.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/breadcrumb.tsx",
"sourceCodeSha1": "2607a19685b58acec5df7cf79cf3f319860f10d0",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 16,
"name": "button.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/button.tsx",
"sourceCodeSha1": "8cc739da9862b0c46a941f2c2d3c04e88ed14f6e",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 17,
"name": "calendar.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/calendar.tsx",
"sourceCodeSha1": "dcf6f206c549acd3d3d93938af700040cf4736b3",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 18,
"name": "card.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/card.tsx",
"sourceCodeSha1": "368b7c30660e2357f967712258eb04a947d105e6",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 19,
"name": "carousel.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/carousel.tsx",
"sourceCodeSha1": "b18b2ad961ae8df8504cd08af7049da628cfcc84",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 20,
"name": "chart.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/chart.tsx",
"sourceCodeSha1": "e2fa2b6b71b5b989b07d655d0311b0b656370868",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 21,
"name": "checkbox.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/checkbox.tsx",
"sourceCodeSha1": "031623c953f24511c2b364736dc0c29bbd72b0da",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 22,
"name": "collapsible.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/collapsible.tsx",
"sourceCodeSha1": "c4a50eb76c7aa18863f1be0fcfb77a39c555dea5",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 23,
"name": "command.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/command.tsx",
"sourceCodeSha1": "e2bacd22ead0ead7c35ae4ea81cc063648be5551",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 24,
"name": "context-menu.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/context-menu.tsx",
"sourceCodeSha1": "dcc0f2c734146df4fdda300fb88fc4b778a8e943",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 25,
"name": "dialog.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/dialog.tsx",
"sourceCodeSha1": "d2e2d438b7ec8f4655cb363bff0b51c362403679",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 26,
"name": "drawer.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/drawer.tsx",
"sourceCodeSha1": "77956c0b7663cbe3de836e37395bc58bd441e662",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 27,
"name": "dropdown-menu.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/dropdown-menu.tsx",
"sourceCodeSha1": "f261713be8c0469a202d6571e667dced43443dc1",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 28,
"name": "form.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/form.tsx",
"sourceCodeSha1": "b35ebd81d98a9f3a1ab62fba289883715f1d1d21",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 29,
"name": "hover-card.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/hover-card.tsx",
"sourceCodeSha1": "b72c3f65947cd7593c1a89a354fa1d6c338f36e9",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 30,
"name": "input-otp.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/input-otp.tsx",
"sourceCodeSha1": "71c2917cc4fe8bb4c80b11323909cf710a93d0c6",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 31,
"name": "input.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/input.tsx",
"sourceCodeSha1": "e9f0dfef25dba4b04ad97609243106533bc078dc",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 32,
"name": "label.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/label.tsx",
"sourceCodeSha1": "8c069a29a97703512a9c930d5c4014476834cc96",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 33,
"name": "menubar.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/menubar.tsx",
"sourceCodeSha1": "ea4c2aa51da161eeb2298dfbc5e70308022214e7",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 34,
"name": "navigation-menu.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/navigation-menu.tsx",
"sourceCodeSha1": "0d7f19cd8946a82063e8d98d3709903326db0aad",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 35,
"name": "pagination.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/pagination.tsx",
"sourceCodeSha1": "a259b6b07136d542c95ba8bd37d9337376acf65b",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 36,
"name": "popover.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/popover.tsx",
"sourceCodeSha1": "762244296d854ac1ba4015cdb7fcbd9db5ef1754",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 37,
"name": "progress.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/progress.tsx",
"sourceCodeSha1": "1a41de261cc00b1eb94b36077854ad9ed3c18804",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 38,
"name": "radio-group.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/radio-group.tsx",
"sourceCodeSha1": "650cb3abc42c9a1664e27e7b47eb09b76c6a4e0d",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 39,
"name": "resizable.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/resizable.tsx",
"sourceCodeSha1": "588fb45baae2adbcec07d32a0e59476878621ca7",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 40,
"name": "scroll-area.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/scroll-area.tsx",
"sourceCodeSha1": "a86380f1aed911b34917005ba4f9949642910dbf",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 41,
"name": "select.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/select.tsx",
"sourceCodeSha1": "37fa9c4ceda7950664c385c9ef27ba1bbee47bde",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 42,
"name": "separator.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/separator.tsx",
"sourceCodeSha1": "979129951eb27258e96545382eaa9e37b9de1209",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 43,
"name": "sheet.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/sheet.tsx",
"sourceCodeSha1": "4f25f42b08ca91c04c4510cc16eb020a9c7f9587",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 44,
"name": "sidebar.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/sidebar.tsx",
"sourceCodeSha1": "f79dfb3dbeef2d211510e47f9ac70f5cf33f37cd",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 45,
"name": "skeleton.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/skeleton.tsx",
"sourceCodeSha1": "0662ecc7ab4b6c1746d91e009ef88d219005448d",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 46,
"name": "slider.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/slider.tsx",
"sourceCodeSha1": "23b80e2e9a0da874dcf78e56feb54346fe2bd0bb",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 47,
"name": "sonner.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/sonner.tsx",
"sourceCodeSha1": "72058ff9b056b79f72f79dfbbcf2db164a79e4ab",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 48,
"name": "switch.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/switch.tsx",
"sourceCodeSha1": "f83deec5e477bd7ca727eea401354d83ff22f6b5",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 49,
"name": "table.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/table.tsx",
"sourceCodeSha1": "ee1282461b1b247aa28520bb5cd6ce716ac1b468",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 50,
"name": "tabs.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/tabs.tsx",
"sourceCodeSha1": "40469be2dacfc3fc86909433bafd1d92bf7616d6",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 51,
"name": "textarea.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/textarea.tsx",
"sourceCodeSha1": "d608dfd62677493c7361908d510ef7d42a49c212",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 52,
"name": "toggle-group.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/toggle-group.tsx",
"sourceCodeSha1": "7b49c924ad02e9ffc94a0be4fab7f92caab54552",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 53,
"name": "toggle.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/toggle.tsx",
"sourceCodeSha1": "82d3925cf6ee63d4092d366e01ad478ad7d2f325",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 54,
"name": "tooltip.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/tooltip.tsx",
"sourceCodeSha1": "8e78e17fad045f62a35dabc021fb7477849ac93a",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 55,
"name": "use-mobile.ts",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/use-mobile.ts",
"sourceCodeSha1": "b1102c4af2fef644861e5df108eb5b133dc71805",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 56,
"name": "utils.ts",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/utils.ts",
"sourceCodeSha1": "f095b349a6d6cbcbf7bcabdb2f51b3095e7403f0",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 57,
"name": "ImageWithFallback.tsx",
"codeFilePath": "components/figma",
"logicalPath": "components/figma/ImageWithFallback.tsx",
"sourceCodeSha1": "e93040f24666348383e937031a95b102570d13ec",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 58,
"name": "globals.css",
"codeFilePath": "styles",
"logicalPath": "styles/globals.css",
"sourceCodeSha1": "70abb71fd97a750fb4ba7c1b892f2b70d8d611d5",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 59,
"name": "Guidelines.md",
"codeFilePath": "guidelines",
"logicalPath": "guidelines/Guidelines.md",
"sourceCodeSha1": "11f6b0e620fa939a50999dada1539096198d9a3b",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 60,
"name": "Dashboard.tsx",
"codeFilePath": "components",
"logicalPath": "components/Dashboard.tsx",
"sourceCodeSha1": "9f8d3c852c0752919d2f1725be3c902902b20971",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 61,
"name": "AddExpense.tsx",
"codeFilePath": "components",
"logicalPath": "components/AddExpense.tsx",
"sourceCodeSha1": "dcc8ea2bcab75e0c954784d40e8cbfb2e2ee74b7",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 62,
"name": "Analytics.tsx",
"codeFilePath": "components",
"logicalPath": "components/Analytics.tsx",
"sourceCodeSha1": "961d99d46e2e7c2b475a77783a098bd48efc27b8",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 63,
"name": "SearchFilter.tsx",
"codeFilePath": "components",
"logicalPath": "components/SearchFilter.tsx",
"sourceCodeSha1": "7dd9d547b69d19e3390564e6387039bc067891af",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 64,
"name": "SettingsProfile.tsx",
"codeFilePath": "components",
"logicalPath": "components/SettingsProfile.tsx",
"sourceCodeSha1": "806456c50af5a3f7d302b75a21d9d784f59b4f7d",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 65,
"name": "Attributions.md",
"codeFilePath": null,
"logicalPath": "Attributions.md",
"sourceCodeSha1": "4f53c7de5eb7d707eea56961544b22433e1a86a3",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 67,
"name": "DashboardMockup.tsx",
"codeFilePath": "components/mockups",
"logicalPath": "components/mockups/DashboardMockup.tsx",
"sourceCodeSha1": "61b29d59129500144886e0cc403f491d49eac3e5",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 68,
"name": "AddExpenseMockup.tsx",
"codeFilePath": "components/mockups",
"logicalPath": "components/mockups/AddExpenseMockup.tsx",
"sourceCodeSha1": "e5d430c751a626680cf8bca5411234bb9dfd8adb",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 69,
"name": "AnalyticsMockup.tsx",
"codeFilePath": "components/mockups",
"logicalPath": "components/mockups/AnalyticsMockup.tsx",
"sourceCodeSha1": "f94a844fbe540becde9c6314c04867d13b94461c",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 70,
"name": "SearchMockup.tsx",
"codeFilePath": "components/mockups",
"logicalPath": "components/mockups/SearchMockup.tsx",
"sourceCodeSha1": "05442b617000ddd07d81961858d1bcd2b13c7509",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 71,
"name": "SettingsMockup.tsx",
"codeFilePath": "components/mockups",
"logicalPath": "components/mockups/SettingsMockup.tsx",
"sourceCodeSha1": "7a92a34c001a2c6be42031acf36eb657ce3810de",
"isDuplicate": false,
"duplicateCount": 1
}
]
}

Binary file not shown.

View File

@@ -0,0 +1,63 @@
export type DecodeOutputOptions = {
platform?: NodeJS.Platform;
};
export type RunCommandOptions = {
command: string;
args?: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
detached?: boolean;
capture?: boolean;
stdio?: any;
};
export type RunCommandResult = {
command: string;
args: string[];
spawnCommand: string;
spawnArgs: string[];
code: number | null;
signal: NodeJS.Signals | null;
stdoutBuffer: Buffer;
stderrBuffer: Buffer;
stdout: string;
stderr: string;
};
export type RunCommandSyncOptions = {
command: string;
args?: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
maxBuffer?: number;
};
export type RunCommandSyncResult = {
command: string;
args: string[];
spawnCommand: string;
spawnArgs: string[];
status: number | null;
signal: NodeJS.Signals | null;
error: Error | null;
stdoutBuffer: Buffer;
stderrBuffer: Buffer;
stdout: string;
stderr: string;
};
export function decodeOutput(value: unknown, options?: DecodeOutputOptions): string;
export function runCommand(options: RunCommandOptions): Promise<RunCommandResult>;
export function runCommandSync(options: RunCommandSyncOptions): RunCommandSyncResult;
export function commandExists(command: string): boolean;
export function getPreferredNpmCommand(): string;
export function getPreferredNpxCommand(): string;
export function getSpawnCommandSpec(command: string, args?: string[], platform?: NodeJS.Platform): {
command: string;
args: string[];
windowsHide: boolean;
};
export function __resetWindowsCodePageCacheForTests(): void;

View File

@@ -0,0 +1,340 @@
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import iconv from 'iconv-lite';
const WINDOWS_CODEPAGE_TIMEOUT_MS = 1200;
let cachedWindowsCodePage = null;
function getPlatform(overridePlatform) {
return overridePlatform || process.platform;
}
function quoteForCmdExec(value) {
if (!value) return '""';
if (!/[\s"&^|<>]/.test(value)) return value;
const escaped = String(value)
.replace(/(\\*)"/g, '$1$1\\"')
.replace(/(\\+)$/g, '$1$1');
return `"${escaped}"`;
}
function buildWindowsCommandLine(command, args) {
return [command, ...args].map((part) => quoteForCmdExec(String(part))).join(' ');
}
function getEnvValue(env, key) {
if (!env) return undefined;
const direct = env[key];
if (typeof direct === 'string' && direct.length > 0) {
return direct;
}
const matchedKey = Object.keys(env).find((candidate) => candidate.toLowerCase() === key.toLowerCase());
if (!matchedKey) return undefined;
const value = env[matchedKey];
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function getWindowsPathExtList(env) {
const pathExt = getEnvValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD';
return pathExt
.split(';')
.map((ext) => ext.trim())
.filter(Boolean)
.map((ext) => (ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}`));
}
function resolveWindowsCommand(command, env) {
if (!command || typeof command !== 'string') return command;
const trimmed = command.trim();
if (!trimmed) return trimmed;
const hasPathSeparator = /[\\/]/.test(trimmed);
const ext = path.extname(trimmed);
const pathExts = ext ? [''] : getWindowsPathExtList(env);
const candidateDirs = hasPathSeparator
? ['']
: (getEnvValue(env, 'PATH') || '')
.split(';')
.map((entry) => entry.trim())
.filter(Boolean);
const baseCandidates = hasPathSeparator ? [trimmed] : candidateDirs.map((dir) => path.join(dir, trimmed));
for (const baseCandidate of baseCandidates) {
const suffixes = ext ? [''] : pathExts;
for (const suffix of suffixes) {
const fullPath = suffix ? `${baseCandidate}${suffix}` : baseCandidate;
if (fs.existsSync(fullPath)) {
return fullPath;
}
}
}
return trimmed;
}
function shouldUseWindowsCmdWrapper(platform, command) {
if (platform !== 'win32') return false;
return /\.(cmd|bat)$/i.test(command) || !/\.(exe|com)$/i.test(command);
}
function getSpawnSpec(command, args, platform = process.platform, env = process.env) {
const resolvedCommand = platform === 'win32' ? resolveWindowsCommand(command, env) : command;
if (!shouldUseWindowsCmdWrapper(platform, resolvedCommand)) {
return {
command: resolvedCommand,
args,
windowsHide: platform === 'win32',
};
}
const commandLine = buildWindowsCommandLine(resolvedCommand, args);
return {
command: 'cmd.exe',
args: ['/d', '/s', '/c', commandLine],
windowsHide: true,
};
}
function mapCodePageToEncoding(codePage) {
if (codePage === 65001) return 'utf8';
if (codePage === 936) return 'gbk';
if (codePage === 54936) return 'gb18030';
return 'gb18030';
}
function parseWindowsCodePage(text) {
if (!text) return null;
const match = String(text).match(/(\d{3,5})/);
if (!match) return null;
const codePage = Number(match[1]);
return Number.isFinite(codePage) ? codePage : null;
}
function readWindowsCodePageSync() {
if (cachedWindowsCodePage !== null) {
return cachedWindowsCodePage;
}
try {
const result = spawnSync('cmd.exe', ['/d', '/s', '/c', 'chcp'], {
windowsHide: true,
encoding: 'utf8',
timeout: WINDOWS_CODEPAGE_TIMEOUT_MS,
});
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
cachedWindowsCodePage = parseWindowsCodePage(output);
} catch {
cachedWindowsCodePage = null;
}
return cachedWindowsCodePage;
}
function toBuffer(value) {
if (!value) return Buffer.alloc(0);
if (Buffer.isBuffer(value)) return value;
if (typeof value === 'string') return Buffer.from(value);
if (value instanceof Uint8Array) return Buffer.from(value);
return Buffer.from(String(value));
}
export function decodeOutput(value, options = {}) {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
const platform = getPlatform(options.platform);
const buffer = toBuffer(value);
if (buffer.length === 0) return '';
try {
const strictUtf8 = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
return strictUtf8;
} catch {
// Fall through to platform fallback decoder.
}
if (platform === 'win32') {
const activeCodePage = readWindowsCodePageSync();
const preferredEncoding = mapCodePageToEncoding(activeCodePage);
try {
return iconv.decode(buffer, preferredEncoding);
} catch {
// Fall through to generic fallback.
}
try {
return iconv.decode(buffer, 'gb18030');
} catch {
// Fall through to latin1 fallback.
}
}
try {
return buffer.toString('utf8');
} catch {
return buffer.toString('latin1');
}
}
export function runCommandSync(options) {
const {
command,
args = [],
cwd,
env,
timeoutMs,
maxBuffer,
} = options;
const platform = process.platform;
const mergedEnv = env ? { ...process.env, ...env } : process.env;
const spawnSpec = getSpawnSpec(command, args, platform, mergedEnv);
const result = spawnSync(spawnSpec.command, spawnSpec.args, {
cwd,
env: mergedEnv,
timeout: timeoutMs,
maxBuffer,
windowsHide: spawnSpec.windowsHide,
encoding: null,
});
const stdoutBuffer = toBuffer(result.stdout);
const stderrBuffer = toBuffer(result.stderr);
return {
command,
args,
spawnCommand: spawnSpec.command,
spawnArgs: spawnSpec.args,
status: typeof result.status === 'number' ? result.status : null,
signal: result.signal || null,
error: result.error || null,
stdoutBuffer,
stderrBuffer,
stdout: decodeOutput(stdoutBuffer, { platform }),
stderr: decodeOutput(stderrBuffer, { platform }),
};
}
export function runCommand(options) {
const {
command,
args = [],
cwd,
env,
timeoutMs,
detached = false,
capture = true,
stdio,
} = options;
return new Promise((resolve, reject) => {
const platform = process.platform;
const mergedEnv = env ? { ...process.env, ...env } : process.env;
const spawnSpec = getSpawnSpec(command, args, platform, mergedEnv);
const child = spawn(spawnSpec.command, spawnSpec.args, {
cwd,
env: mergedEnv,
detached,
stdio: stdio || (capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'),
windowsHide: spawnSpec.windowsHide,
});
const stdoutChunks = [];
const stderrChunks = [];
if (capture && child.stdout) {
child.stdout.on('data', (chunk) => {
stdoutChunks.push(toBuffer(chunk));
});
}
if (capture && child.stderr) {
child.stderr.on('data', (chunk) => {
stderrChunks.push(toBuffer(chunk));
});
}
let timeoutId = null;
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
timeoutId = setTimeout(() => {
child.kill('SIGTERM');
}, timeoutMs);
}
child.once('error', (error) => {
if (timeoutId) clearTimeout(timeoutId);
reject(error);
});
child.once('close', (code, signal) => {
if (timeoutId) clearTimeout(timeoutId);
const stdoutBuffer = Buffer.concat(stdoutChunks);
const stderrBuffer = Buffer.concat(stderrChunks);
resolve({
command,
args,
spawnCommand: spawnSpec.command,
spawnArgs: spawnSpec.args,
code: typeof code === 'number' ? code : null,
signal: signal || null,
stdoutBuffer,
stderrBuffer,
stdout: decodeOutput(stdoutBuffer, { platform }),
stderr: decodeOutput(stderrBuffer, { platform }),
});
});
});
}
export function commandExists(command) {
if (!command || typeof command !== 'string') {
return false;
}
const checker = process.platform === 'win32' ? 'where' : 'which';
const result = runCommandSync({
command: checker,
args: [command],
timeoutMs: 2000,
});
return result.status === 0;
}
export function getPreferredNpmCommand() {
if (process.platform !== 'win32') {
return 'npm';
}
return commandExists('npm.cmd') ? 'npm.cmd' : 'npm';
}
export function getPreferredNpxCommand() {
if (process.platform !== 'win32') {
return 'npx';
}
return commandExists('npx.cmd') ? 'npx.cmd' : 'npx';
}
export function getSpawnCommandSpec(command, args = [], platform = process.platform) {
return getSpawnSpec(command, args, platform);
}
export function __resetWindowsCodePageCacheForTests() {
cachedWindowsCodePage = null;
}

View File

@@ -0,0 +1,41 @@
import ts from 'typescript';
function formatDiagnostic(diagnostic) {
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
if (!diagnostic.file || typeof diagnostic.start !== 'number') {
return message;
}
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
const sourceLine = diagnostic.file.text.split(/\r?\n/u)[line] || '';
return `${diagnostic.file.fileName}:${line + 1}:${character + 1} - ${message}\n${sourceLine}`;
}
export function validateGeneratedTsx(source, filePath = 'generated.tsx') {
const result = ts.transpileModule(source, {
fileName: filePath,
reportDiagnostics: true,
compilerOptions: {
jsx: ts.JsxEmit.ReactJSX,
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ESNext,
},
});
const diagnostics = (result.diagnostics || []).filter(
(diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
);
return {
ok: diagnostics.length === 0,
diagnostics,
formatted: diagnostics.map(formatDiagnostic).join('\n\n'),
};
}
export function assertValidGeneratedTsx(source, filePath = 'generated.tsx') {
const validation = validateGeneratedTsx(source, filePath);
if (!validation.ok) {
throw new Error(`生成的 TSX 语法校验失败:\n${validation.formatted}`);
}
}

View File

@@ -0,0 +1,602 @@
#!/usr/bin/env node
/**
* V0 项目预处理器(最小化处理模式)
*
* 只做 100% 有把握的操作:
* 1. 完整复制项目
* 2. 分析项目结构
* 3. 生成任务文档
*
* 不做任何代码修改,全部留给 AI 处理
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TARGET_TYPE_TO_SRC_DIR = {
prototypes: 'src/prototypes',
components: 'src/components',
themes: 'src/themes',
};
const THEME_SPLIT_SKILL_DOCS = [
'/skills/axure-prototype-workflow/theme-generation.md',
'/skills/axure-prototype-workflow/doc-generation.md',
'/skills/axure-prototype-workflow/data-generation.md',
'/skills/web-page-workflow/theme-generation.md',
'/skills/web-page-workflow/doc-generation.md',
'/skills/web-page-workflow/data-generation.md',
];
const CONFIG = {
projectRoot: path.resolve(__dirname, '..'),
tempDir: path.resolve(__dirname, '../temp'),
};
function log(message, type = 'info') {
const prefix = { info: '✓', warn: '⚠', error: '✗', progress: '⏳' }[type] || '';
console.log(`${prefix} ${message}`);
}
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function sanitizeName(rawName) {
return String(rawName || '')
.replace(/[^a-z0-9-]/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.toLowerCase();
}
function getTargetInfo(targetType, outputName) {
const srcDir = TARGET_TYPE_TO_SRC_DIR[targetType];
const outputBaseDir = path.resolve(CONFIG.projectRoot, srcDir);
const outputDir = path.join(outputBaseDir, outputName);
const relativeOutputDir = `${srcDir}/${outputName}`;
if (targetType === 'themes') {
return {
targetType,
srcDir,
outputBaseDir,
outputDir,
relativeOutputDir,
tasksFileName: '.v0-theme-tasks.md',
analysisFileName: '.v0-theme-analysis.json',
checkPath: `/themes/${outputName}`,
label: '主题',
};
}
return {
targetType,
srcDir,
outputBaseDir,
outputDir,
relativeOutputDir,
tasksFileName: '.v0-tasks.md',
analysisFileName: '.v0-analysis.json',
checkPath: `/${targetType}/${outputName}`,
label: targetType === 'components' ? '组件' : '页面',
};
}
// 递归查找所有 .tsx/.ts 文件
function findFiles(dir, extensions = ['.tsx', '.ts']) {
const results = [];
if (!fs.existsSync(dir)) return results;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// 跳过 node_modules 和 .next
if (entry.name === 'node_modules' || entry.name === '.next') continue;
results.push(...findFiles(fullPath, extensions));
} else {
const ext = path.extname(entry.name);
if (extensions.includes(ext)) {
results.push(fullPath);
}
}
}
return results;
}
function copyDirectory(src, dest) {
if (!fs.existsSync(src)) return 0;
ensureDir(dest);
const entries = fs.readdirSync(src, { withFileTypes: true });
let count = 0;
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === '.next') continue;
count += copyDirectory(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
count++;
}
}
return count;
}
console.log('V0 Converter - Minimal Processing Mode\n');
/**
* 批量处理文件:删除 "use client" 和转换路径别名
* 这些是 100% 确定需要做的转换
*/
function processFiles(targetDir) {
const files = findFiles(targetDir, ['.tsx', '.ts', '.jsx', '.js']);
let processedCount = 0;
files.forEach(file => {
let content = fs.readFileSync(file, 'utf8');
let modified = false;
// 1. 删除 "use client" 指令100% 确定需要删除)
const newContent1 = content.replace(/['"]use client['"]\s*;?\s*\n?/g, '');
if (newContent1 !== content) {
content = newContent1;
modified = true;
}
// 2. 转换路径别名 @/ 为相对路径100% 确定需要转换)
if (content.includes('@/')) {
const fileDir = path.dirname(file);
const relativePath = path.relative(fileDir, targetDir);
// 替换 from '@/...' 和 from "@/..."
content = content.replace(
/from\s+(['"])@\//g,
`from $1${relativePath}/`
);
// 替换 import type ... from '@/...' 和 import type ... from "@/..."
content = content.replace(
/import\s+type\s+(.*from\s+)(['"])@\//g,
`import type $1$2${relativePath}/`
);
modified = true;
}
if (modified) {
fs.writeFileSync(file, content);
processedCount++;
}
});
return processedCount;
}
function analyzeProject(targetDir) {
const analysis = { files: [], pathAliases: [], nextjsImports: [], dependencies: {}, structure: {} };
const files = findFiles(targetDir, ['.tsx', '.ts']);
files.forEach(file => {
const relativePath = path.relative(targetDir, file);
const content = fs.readFileSync(file, 'utf8');
const fileInfo = {
path: relativePath,
hasUseClient: content.includes('"use client"') || content.includes("'use client'"),
pathAliases: [],
nextjsImports: []
};
const aliasMatches = content.matchAll(/from\s+['"]@\/([^'"]+)['"]/g);
for (const match of aliasMatches) {
fileInfo.pathAliases.push({
original: `@/${match[1]}`,
relative: path.relative(path.dirname(file), path.join(targetDir, match[1]))
});
}
const nextImports = content.matchAll(/import\s+.*from\s+['"]next\/([^'"]+)['"]/g);
for (const match of nextImports) {
fileInfo.nextjsImports.push(`next/${match[1]}`);
}
const vercelImports = content.matchAll(/import\s+.*from\s+['"]@vercel\/([^'"]+)['"]/g);
for (const match of vercelImports) {
fileInfo.nextjsImports.push(`@vercel/${match[1]}`);
}
analysis.files.push(fileInfo);
if (fileInfo.pathAliases.length > 0) {
analysis.pathAliases.push(...fileInfo.pathAliases.map(a => ({ file: relativePath, ...a })));
}
if (fileInfo.nextjsImports.length > 0) {
analysis.nextjsImports.push(...fileInfo.nextjsImports.map(imp => ({ file: relativePath, import: imp })));
}
});
const packageJsonPath = path.join(targetDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const deps = packageJson.dependencies || {};
analysis.dependencies = {
all: deps,
toInstall: Object.keys(deps).filter(dep => {
if (dep === 'next' || dep.startsWith('next-')) return false;
if (dep.startsWith('@vercel/')) return false;
if (dep === 'react' || dep === 'react-dom') return false;
return true;
}),
excluded: Object.keys(deps).filter(dep => {
if (dep === 'next' || dep.startsWith('next-')) return true;
if (dep.startsWith('@vercel/')) return true;
if (dep === 'react' || dep === 'react-dom') return true;
return false;
})
};
}
analysis.structure = {
hasAppDir: fs.existsSync(path.join(targetDir, 'app')),
hasPageTsx: fs.existsSync(path.join(targetDir, 'app/page.tsx')),
hasLayoutTsx: fs.existsSync(path.join(targetDir, 'app/layout.tsx')),
hasGlobalsCss: fs.existsSync(path.join(targetDir, 'app/globals.css')),
hasComponentsDir: fs.existsSync(path.join(targetDir, 'components')),
hasHooksDir: fs.existsSync(path.join(targetDir, 'hooks')),
hasLibDir: fs.existsSync(path.join(targetDir, 'lib')),
hasPublicDir: fs.existsSync(path.join(targetDir, 'public'))
};
return analysis;
}
function buildAnalysisReport(analysis) {
return {
summary: {
totalFiles: analysis.files.length,
filesWithUseClient: analysis.files.filter(f => f.hasUseClient).length,
pathAliasCount: analysis.pathAliases.length,
nextjsImportCount: analysis.nextjsImports.length,
dependenciesToInstall: analysis.dependencies.toInstall?.length || 0
},
structure: analysis.structure,
pathAliases: analysis.pathAliases,
nextjsImports: analysis.nextjsImports,
dependencies: analysis.dependencies,
files: analysis.files
};
}
function generateDefaultTasksDocument(report, targetInfo, outputName, tempDir) {
const reportPath = path.join(targetInfo.outputDir, targetInfo.analysisFileName);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
let markdown = `# V0 项目转换任务清单\n\n`;
markdown += `> **重要**: 请先阅读 \`/skills/v0-project-converter/SKILL.md\` 了解转换规范\n\n`;
markdown += `**名称**: ${outputName}\n`;
markdown += `**项目位置**: \`${targetInfo.relativeOutputDir}/\`\n`;
markdown += `**原始文件**: \`${tempDir}\` (仅供参考,不要修改)\n`;
markdown += `**生成时间**: ${new Date().toLocaleString()}\n\n`;
markdown += `## 📊 项目概况\n\n`;
markdown += `- 总文件数: ${report.summary.totalFiles}\n`;
markdown += `- ~~包含 'use client': ${report.summary.filesWithUseClient} 个文件~~ ✓ 已由脚本删除\n`;
markdown += `- ~~路径别名 (@/): ${report.summary.pathAliasCount} 处~~ ✓ 已由脚本转换\n`;
markdown += `- Next.js imports: ${report.summary.nextjsImportCount} 处(需要处理)\n`;
markdown += `- 需要安装的依赖: ${report.summary.dependenciesToInstall}\n\n`;
markdown += `## ✅ 转换任务\n\n`;
markdown += `### 任务 1: 创建 index.tsx\n\n`;
markdown += `**目标**: 将 \`app/page.tsx\` 包装为本项目组件\n\n`;
markdown += `**参考文件**: \`${targetInfo.relativeOutputDir}/app/page.tsx\`\n\n`;
markdown += `**操作**: 按照 \`/skills/v0-project-converter/SKILL.md\` 中的本项目组件规范创建 \`index.tsx\`\n\n`;
markdown += `### 任务 2: 创建 style.css\n\n`;
markdown += `**目标**: 基于 \`app/globals.css\` 创建样式文件\n\n`;
if (report.structure.hasGlobalsCss) {
markdown += `**参考文件**: \`${targetInfo.relativeOutputDir}/app/globals.css\`\n\n`;
markdown += `**操作**: 复制内容,确保开头有 \`@import "tailwindcss";\`\n\n`;
} else {
markdown += `**操作**: 创建基础样式文件,内容为 \`@import "tailwindcss";\`\n\n`;
}
markdown += `### 任务 3: 清理 Next.js 代码\n\n`;
markdown += `**目标**: 移除 Next.js 特定的 imports 和组件\n\n`;
markdown += `**需要处理**:\n`;
markdown += `- ~~删除 \`"use client"\` 指令~~ ✓ 已由脚本处理\n`;
markdown += `- ~~转换路径别名 \`@/\`~~ ✓ 已由脚本处理\n`;
markdown += `- 移除 Next.js imports (${report.nextjsImports.length} 处)\n`;
markdown += `- 替换组件: \`<Image>\`\`<img>\`, \`<Link>\`\`<a>\`\n`;
markdown += `- 删除 \`Metadata\` 类型声明\n\n`;
if (report.nextjsImports.length > 0) {
markdown += `**Next.js imports 需要移除**:\n`;
const grouped = {};
report.nextjsImports.forEach(item => {
if (!grouped[item.import]) grouped[item.import] = [];
grouped[item.import].push(item.file);
});
Object.entries(grouped).slice(0, 5).forEach(([imp, files]) => {
markdown += `- \`${imp}\` (${files.length} 个文件)\n`;
});
if (Object.keys(grouped).length > 5) {
markdown += `- *...还有 ${Object.keys(grouped).length - 5} 种 imports*\n`;
}
markdown += `\n`;
}
markdown += `### 任务 4: 安装依赖\n\n`;
if (report.dependencies.toInstall && report.dependencies.toInstall.length > 0) {
markdown += `**执行命令**:\n`;
markdown += `\`\`\`bash\n`;
markdown += `pnpm add ${report.dependencies.toInstall.join(' ')}\n`;
markdown += `\`\`\`\n\n`;
} else {
markdown += `✓ 无需安装额外依赖\n\n`;
}
markdown += `### 任务 5: 验收测试\n\n`;
markdown += `**执行命令**:\n`;
markdown += `\`\`\`bash\n`;
markdown += `node scripts/check-app-ready.mjs ${targetInfo.checkPath}\n`;
markdown += `\`\`\`\n\n`;
markdown += `**验收标准**: 状态为 READY页面正常渲染无控制台错误\n\n`;
markdown += `## 📚 参考资料\n\n`;
markdown += `- **转换规范**: \`/skills/v0-project-converter/SKILL.md\`\n`;
markdown += `- **原始项目**: \`${tempDir}\` (仅供参考)\n`;
markdown += `- **详细数据**: \`${targetInfo.analysisFileName}\`\n\n`;
markdown += `## 💡 注意事项\n\n`;
markdown += `1. ~~**"use client"**: Next.js 指令,必须删除~~ ✓ 已由脚本处理\n`;
markdown += `2. ~~**路径别名**: \`@/\` 需转换为相对路径~~ ✓ 已由脚本处理\n`;
markdown += `3. **原始文件**: \`${tempDir}\` 目录保留作为参考,不要修改\n`;
markdown += `4. **验证**: 完成后务必运行验收脚本确认\n`;
const mdPath = path.join(targetInfo.outputDir, targetInfo.tasksFileName);
fs.writeFileSync(mdPath, markdown);
return { reportPath, mdPath };
}
function generateThemeTasksDocument(report, targetInfo, outputName, tempDir) {
const reportPath = path.join(targetInfo.outputDir, targetInfo.analysisFileName);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
let markdown = `# V0 主题导入任务清单\n\n`;
markdown += `> **重要**: 请先阅读 \`/skills/v0-project-converter/SKILL.md\` 与主题拆分技能文档,按任务顺序执行\n\n`;
markdown += `**主题 key**: ${outputName}\n`;
markdown += `**主题目录**: \`${targetInfo.relativeOutputDir}/\`\n`;
markdown += `**原始文件**: \`${tempDir}\` (仅供参考,不要修改)\n`;
markdown += `**生成时间**: ${new Date().toLocaleString()}\n\n`;
markdown += `## 📊 输入概况\n\n`;
markdown += `- 总文件数: ${report.summary.totalFiles}\n`;
markdown += `- 路径别名 (@/): ${report.summary.pathAliasCount} 处(已转换)\n`;
markdown += `- Next.js imports: ${report.summary.nextjsImportCount}\n`;
markdown += `- 依赖待评估: ${report.summary.dependenciesToInstall}\n\n`;
markdown += `## 📚 参考文档(必须阅读)\n\n`;
markdown += `- \`/skills/v0-project-converter/SKILL.md\`\n`;
THEME_SPLIT_SKILL_DOCS.forEach((docPath) => {
markdown += `- \`${docPath}\`\n`;
});
markdown += `\n`;
markdown += `## ✅ 主题导入任务(共 5 个)\n\n`;
markdown += `### 任务 1生成主题 token\n\n`;
markdown += `**目标**:在 \`${targetInfo.relativeOutputDir}/\` 下生成 \`globals.css\`\`designToken.json\`(二选一)\n\n`;
markdown += `**要求**\n`;
markdown += `- 若输出 \`designToken.json\`,必须包含 \`name\` 字段\n`;
markdown += `- 覆盖颜色、字体、间距、圆角、阴影等核心 token\n`;
markdown += `- 与输入项目视觉风格一致\n\n`;
markdown += `### 任务 2生成 DESIGN-SPEC.md\n\n`;
markdown += `**目标**:输出 \`${targetInfo.relativeOutputDir}/DESIGN-SPEC.md\`\n\n`;
markdown += `**要求**:说明设计语言、组件风格、排版层级、状态规范与使用建议\n\n`;
markdown += `### 任务 3按需生成项目文档\n\n`;
markdown += `**目标**:在 \`src/docs/\` 下补充主题相关项目文档(如页面地图/项目概览)\n\n`;
markdown += `**要求**:文档需与主题风格、信息结构和业务语境保持一致\n\n`;
markdown += `### 任务 4按需生成数据模型\n\n`;
markdown += `**目标**:在 \`src/database/\` 下补充或更新数据模型\n\n`;
markdown += `**要求**\n`;
markdown += `- 文件名英文、\`tableName\` 中文\n`;
markdown += `- 每个表包含 \`records\` 数组,记录 id 唯一\n\n`;
markdown += `### 任务 5生成/更新主题演示入口\n\n`;
markdown += `**目标**:生成或更新 \`${targetInfo.relativeOutputDir}/index.tsx\`\n\n`;
markdown += `**要求**\n`;
markdown += `- 演示主题 token 的核心效果\n`;
markdown += `- 若使用 \`designToken.json\`,演示中需体现 token 注入方式\n\n`;
markdown += `## 🔍 验收建议\n\n`;
markdown += `- 目录检查:\`${targetInfo.relativeOutputDir}/\` 是否包含 token 文件、\`DESIGN-SPEC.md\`\`index.tsx\`\n`;
markdown += `- 文档检查:\`src/docs/\` 是否按需补充\n`;
markdown += `- 数据检查:\`src/database/\` JSON 结构是否满足约束\n\n`;
markdown += `## 📎 产物索引\n\n`;
markdown += `- 任务清单:\`${targetInfo.tasksFileName}\`\n`;
markdown += `- 分析报告:\`${targetInfo.analysisFileName}\`\n`;
const mdPath = path.join(targetInfo.outputDir, targetInfo.tasksFileName);
fs.writeFileSync(mdPath, markdown);
return { reportPath, mdPath };
}
function parseArgs(rawArgs) {
const args = [...rawArgs];
const help = args.length === 0 || args.includes('--help') || args.includes('-h');
if (help) {
return { help: true };
}
let projectDirArg = '';
let outputNameArg = '';
let targetType = 'prototypes';
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--target-type') {
const nextValue = args[index + 1];
if (!nextValue) {
throw new Error('参数 --target-type 缺少值');
}
targetType = String(nextValue).trim();
index += 1;
continue;
}
if (!projectDirArg) {
projectDirArg = arg;
continue;
}
if (!outputNameArg) {
outputNameArg = arg;
continue;
}
}
if (!projectDirArg) {
throw new Error('缺少 <v0-project-dir> 参数');
}
if (!Object.prototype.hasOwnProperty.call(TARGET_TYPE_TO_SRC_DIR, targetType)) {
throw new Error(`不支持的 targetType: ${targetType}。可选值: ${Object.keys(TARGET_TYPE_TO_SRC_DIR).join(', ')}`);
}
const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg));
if (!outputName) {
throw new Error('无法生成有效的输出名称,请显式传入 [output-name]');
}
return {
help: false,
projectDirArg,
outputName,
targetType,
};
}
function printHelp() {
console.log(`
V0 项目预处理器
使用方法:
node scripts/v0-converter.mjs <v0-project-dir> [output-name] [--target-type <prototypes|components|themes>]
示例:
node scripts/v0-converter.mjs "temp/my-v0-project" my-page
node scripts/v0-converter.mjs "temp/my-v0-project" brand-theme --target-type themes
功能:
- 完整复制 V0 项目(不修改代码)
- 生成 AI 工作文档(默认 .v0-tasks.md主题模式 .v0-theme-tasks.md
- 生成分析报告(默认 .v0-analysis.json主题模式 .v0-theme-analysis.json
`);
}
async function main() {
let parsed;
try {
parsed = parseArgs(process.argv.slice(2));
} catch (error) {
log(`参数错误: ${error.message}`, 'error');
printHelp();
process.exit(1);
}
if (parsed.help) {
printHelp();
process.exit(0);
}
const v0Dir = path.resolve(CONFIG.projectRoot, parsed.projectDirArg);
const targetInfo = getTargetInfo(parsed.targetType, parsed.outputName);
const outputDir = targetInfo.outputDir;
if (!fs.existsSync(v0Dir)) {
log(`错误: 找不到目录 ${v0Dir}`, 'error');
process.exit(1);
}
const appDir = path.join(v0Dir, 'app');
if (!fs.existsSync(appDir)) {
log('错误: 这不是一个有效的 V0 项目(缺少 app/ 目录)', 'error');
process.exit(1);
}
try {
ensureDir(targetInfo.outputBaseDir);
log(`开始预处理 V0 项目targetType=${parsed.targetType}...`, 'info');
log('步骤 1/4: 复制项目文件...', 'progress');
const fileCount = copyDirectory(v0Dir, outputDir);
log(`已复制 ${fileCount} 个文件`, 'info');
log('步骤 2/4: 复制 public/images 到目标目录...', 'progress');
const publicImagesDir = path.join(v0Dir, 'public/images');
const outputImagesDir = path.join(outputDir, 'images');
let imageCount = 0;
if (fs.existsSync(publicImagesDir)) {
imageCount = copyDirectory(publicImagesDir, outputImagesDir);
log(`已复制 ${imageCount} 个图片文件到 ${targetInfo.relativeOutputDir}/images/`, 'info');
} else {
log('未找到 public/images 目录,跳过', 'info');
}
log('步骤 3/4: 处理确定性转换(删除 "use client",转换路径别名)...', 'progress');
const processedCount = processFiles(outputDir);
log(`已处理 ${processedCount} 个文件`, 'info');
log('步骤 4/4: 分析项目并生成任务文档...', 'progress');
const analysis = analyzeProject(outputDir);
const report = buildAnalysisReport(analysis);
const { reportPath, mdPath } = parsed.targetType === 'themes'
? generateThemeTasksDocument(report, targetInfo, parsed.outputName, `temp/${path.basename(v0Dir)}`)
: generateDefaultTasksDocument(report, targetInfo, parsed.outputName, `temp/${path.basename(v0Dir)}`);
log('✅ 预处理完成!', 'info');
log('', 'info');
log(`📁 ${targetInfo.label}位置: ${targetInfo.relativeOutputDir}/`, 'info');
log(`📋 AI 工作文档: ${path.relative(CONFIG.projectRoot, mdPath)}`, 'info');
log(`📊 详细数据: ${path.relative(CONFIG.projectRoot, reportPath)}`, 'info');
log('', 'info');
log('📈 统计:', 'info');
log(` - 文件数: ${analysis.files.length}`, 'info');
log(` - 路径别名: ${analysis.pathAliases.length}`, 'info');
log(` - Next.js imports: ${analysis.nextjsImports.length}`, 'info');
log(` - 依赖: ${analysis.dependencies.toInstall?.length || 0}`, 'info');
log('', 'info');
log('🎯 下一步:', 'info');
log(`1. 查看任务文档: cat ${path.relative(CONFIG.projectRoot, mdPath)}`, 'info');
log(parsed.targetType === 'themes'
? '2. 让 AI 按任务单完成主题/文档/数据生成'
: '2. 让 AI 根据任务清单完成转换', 'info');
} catch (error) {
log(`预处理失败: ${error.message}`, 'error');
console.error(error);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,288 @@
---
name: ai-studio-project-converter
description: 将 Google AI Studio 生成的 React 项目转换为本项目页面组件的流程规范;在处理 Import Map、样式迁移、依赖安装、环境变量与验收时使用。
---
# AI Studio 项目转换规范
将 Google AI Studio 生成的零配置 React 应用转换为本项目页面组件,保持视觉效果和功能,符合本项目开发规范。
## 核心目标
- 保持页面视觉一致性
- 移除 AI Studio 特定入口与 HTML 模板
- 产出可在本项目中运行的页面组件
## 使用方式
### 步骤 1运行预处理脚本
```bash
node scripts/ai-studio-converter.mjs <ai-studio-project-dir> [output-name]
# 示例
node scripts/ai-studio-converter.mjs "temp/my-ai-studio-project" my-page
```
**脚本会自动完成**
- 完整复制 AI Studio 项目到 `src/prototypes/[页面名]/`
- 分析项目结构Import Map、自定义样式、依赖等
- 生成 AI 工作文档(`.ai-studio-tasks.md`
- 生成详细数据(`.ai-studio-analysis.json`
- **不修改任何代码**100% 安全)
### 步骤 2按任务清单完成转换
脚本会生成 `.ai-studio-tasks.md` 文件,包含:
- 项目概况统计
- 5 个具体任务清单
- Import Map 依赖映射
- 环境变量配置提示
- 验收测试步骤
按任务清单与本规范示例执行转换。
## 转换要点
### AI Studio 项目特征
**典型目录结构**
```
ai-studio-project/
├── assets/ # 静态资源(可选)
├── components/ # UI 组件
├── App.tsx # 主应用组件
├── index.tsx # React 挂载入口
├── index.html # HTML 模板Import Map + Tailwind CDN
├── constants.ts # 常量定义(可选)
├── types.ts # 类型定义(可选)
├── vite.config.ts # Vite 配置(可选)
└── metadata.json # 项目元数据(可选)
```
**技术栈**
- **框架**React 19Function Components + Hooks
- **语言**TypeScript
- **模块**Native ESMImport Map通常是 esm.sh CDN
- **样式**Tailwind CSSCDN Runtime Mode
- **图标**Lucide React
- **配置**Vite如果有 vite.config.ts
### 关键文件特征
**index.html**
```html
<script src="https://cdn.tailwindcss.com"></script>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19",
"lucide-react": "https://esm.sh/lucide-react"
}
}
</script>
<style>/* 可能包含自定义样式 */</style>
<link href="https://fonts.googleapis.com/..."> <!-- 可能包含外部字体 -->
```
### 本项目页面组件规范
默认先转换为普通 React 页面组件。只有在需求明确要求接入 Axhub / Axure 运行时能力时,才引入 `forwardRef<AxureHandle, AxureProps>``useImperativeHandle``axure-types`
**默认格式(推荐)**
```typescript
/**
* @name 页面名称
*
* 参考资料:
* - /rules/development-guide.md
* - /skills/default-resource-recommendations/SKILL.md
*/
import './style.css';
import React from 'react';
export default function PageName() {
// 组件逻辑
return (
// JSX 内容
);
}
```
**仅在以下场景才接入 Axure API**
- 页面需要被 Axhub / Axure 接管
- 需要配置面板、外部数据源、事件回调或动作触发
- 用户明确要求保持与现有 Axure 组件一致的接口形式
此时再参考 `/rules/axure-api-guide.md`,使用如下包装形式:
```typescript
import React, { forwardRef, useImperativeHandle } from 'react';
import type { AxureProps, AxureHandle } from '../../common/axure-types';
const Component = forwardRef<AxureHandle, AxureProps>(function PageName(innerProps, ref) {
useImperativeHandle(ref, function () {
return {
getVar: function () { return undefined; },
fireAction: function () {},
eventList: [],
actionList: [],
varList: [],
configList: [],
dataList: []
};
}, []);
return (
// JSX 内容
);
});
export default Component;
```
### 转换主应用组件
**AI Studio 原始代码**
```typescript
// App.tsx
import { useState } from 'react';
import Header from './components/Header';
export default function App() {
const [count, setCount] = useState(0);
return <div><Header /></div>;
}
```
**转换为本项目默认规范**
```typescript
/**
* @name 页面名称
*
* 参考资料:
* - /rules/development-guide.md
* - /skills/default-resource-recommendations/SKILL.md
*/
import './style.css';
import React, { useState } from 'react';
import Header from './components/Header';
export default function PageName() {
const [count, setCount] = useState(0);
return <div><Header /></div>;
}
```
**关键转换点**
1. 添加文件头部注释(`@name` 和参考资料)
2. 默认保持普通 React 组件写法,优先最小改造
3. 仅在明确需要 Axhub / Axure 接管时,才增加 `forwardRef<AxureHandle, AxureProps>``useImperativeHandle`
4. 保持原有的 JSX、Hooks 和 Tailwind 类名不变
5. 若补接 Axure API需同步参考 `/rules/axure-api-guide.md`
### 处理样式
`index.html` 提取样式信息,创建 `style.css`
```css
@import "tailwindcss";
/* 提取 <style> 标签中的自定义样式 */
/* 例如:自定义动画、字体、选择器样式等 */
/* 如果有外部字体,添加 @import */
@import url('https://fonts.googleapis.com/css2?family=...');
```
### 依赖管理
`index.html` 的 Import Map 提取依赖:
```bash
# 常见依赖lucide-react, framer-motion 等
# 排除react, react-dom本项目已有
pnpm add [识别到的依赖列表]
```
**CDN 到 npm 包映射**
- `https://esm.sh/lucide-react``lucide-react`
- `https://esm.sh/framer-motion``framer-motion`
- `https://esm.sh/@google/genai``@google/generative-ai`
### 环境变量处理
`process.env.*` 转换为 `import.meta.env.VITE_*`
- 检查 `vite.config.ts` 中的 `define` 配置
- 告知用户需要配置的环境变量
- 提供 `.env.local` 示例
### 移除 AI Studio 特定文件
**必须移除**
- `index.html`(提取信息后删除)
- `index.tsx`(本项目有自己的入口)
- `metadata.json`(可选保留作为参考)
## 验收标准
转换完成后运行验收脚本:
```bash
node scripts/check-app-ready.mjs /prototypes/[页面名]
```
**验收要求**
- 状态为 READY
- 页面能正常渲染
- 无控制台错误
- 交互功能正常
- 样式显示正确
## 常见问题
### 依赖缺失
```bash
# 根据报告中的依赖列表安装
pnpm add [依赖名称]
```
### Import Map 转换
- 检查 `.ai-studio-analysis.json` 中的 CDN 依赖映射
- 确保所有 CDN 依赖已转换为 npm 包
### 样式问题
- 确认 `style.css` 包含 `@import "tailwindcss"`
- 检查 index.html 的 `<style>` 标签是否已提取
### 环境变量
-`process.env.*` 改为 `import.meta.env.VITE_*`
- 配置 `.env.local` 文件
## 参考资源
- **开发规范**`/rules/development-guide.md`
- **调试指南**`/rules/debugging-guide.md`
- **Tailwind CSS**`/skills/default-resource-recommendations/SKILL.md`
## 详细转换流程(供参考)
### 步骤 1分析项目结构
脚本会自动扫描识别:
- 主应用:`App.tsx`
- 入口文件:`index.tsx`(需移除)
- HTML 模板:`index.html`(提取依赖和样式信息)
- 组件文件:`components/**/*.tsx`
- 配置文件:`vite.config.ts`(提取路径别名)
- 常量/类型:`constants.ts`, `types.ts`(如果存在)
- 静态资源:`assets/**`
### 步骤 2调试验收
运行验收脚本,根据结果修复问题。

Some files were not shown because too many files have changed in this diff Show More