From ad60f8c3ec710f18c74fc522bf367fd294763f0d Mon Sep 17 00:00:00 2001 From: yinsx Date: Sat, 20 Dec 2025 14:13:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8D=A1=E6=AD=BB=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/grid.js | 116 +++++++++++++++--------------- lib/keyboard.js | 31 +++++--- lib/menu.js | 1 + lib/scaffold/config.js | 158 +++++++++++++++++++++++++++++++++++++++++ lib/scaffold/index.js | 39 ++++++++++ lib/scaffold/ui.js | 152 +++++++++++++++++++++++++++++++++++++++ lib/utils/stepui.js | 106 +++++++++++++++++---------- 7 files changed, 495 insertions(+), 108 deletions(-) create mode 100644 lib/scaffold/config.js create mode 100644 lib/scaffold/index.js create mode 100644 lib/scaffold/ui.js diff --git a/lib/grid.js b/lib/grid.js index 810dc11..0d99a73 100644 --- a/lib/grid.js +++ b/lib/grid.js @@ -1,17 +1,10 @@ import color from "picocolors"; -import { initKeypress, onKey } from "./keyboard.js"; +import { initKeypress, onKey, stopKeypress } from "./keyboard.js"; function clearScreen() { - if (process.stdout.isTTY) { - process.stdout.write("\x1b[2J"); // clear screen - process.stdout.write("\x1b[3J"); // clear scrollback - process.stdout.write("\x1b[H"); // move cursor home - } else { - console.clear(); - } + process.stdout.write('\x1Bc'); } -// 计算字符串显示宽度 function strWidth(str) { let width = 0; for (const char of str) { @@ -20,25 +13,21 @@ function strWidth(str) { return width; } -// 右填充到指定宽度 function padEnd(str, width) { - const diff = width - strWidth(str); - return str + " ".repeat(Math.max(0, diff)); + return str + " ".repeat(Math.max(0, width - strWidth(str))); } -/** - * 网格选择菜单 - */ export async function gridSelect(options) { - const { - items, - cols = 3, + const { + items, + cols = 3, colWidth = 24, title = "", - renderHeader = null + renderHeader = null } = options; let current = 0; + let resolved = false; const rows = Math.ceil(items.length / cols); const termWidth = process.stdout.columns || 80; const totalWidth = cols * colWidth; @@ -46,83 +35,90 @@ export async function gridSelect(options) { function render() { clearScreen(); - + if (renderHeader) { console.log(renderHeader()); } - + console.log(""); - + if (title) { const titlePad = " ".repeat(Math.max(0, Math.floor((termWidth - title.length - 4) / 2))); console.log(titlePad + color.bgMagenta(color.white(` ${title} `))); } - + console.log(""); console.log(pad + color.dim("↑ ↓ ← → 选择 | Enter 确认 | Esc 退出")); - console.log(""); - console.log(""); + console.log("\n"); for (let row = 0; row < rows; row++) { let line = ""; + let descLine = ""; + for (let col = 0; col < cols; col++) { const idx = row * cols + col; if (idx < items.length) { const item = items[idx]; if (idx === current) { line += color.cyan("[" + item.name + "]"); - line += " ".repeat(colWidth - strWidth(item.name) - 2); } else { line += " " + item.name + " "; - line += " ".repeat(colWidth - strWidth(item.name) - 2); } + line += " ".repeat(colWidth - strWidth(item.name) - 2); + descLine += padEnd(item.desc || "", colWidth); } } + console.log(pad + line); - - let descLine = ""; - for (let col = 0; col < cols; col++) { - const idx = row * cols + col; - if (idx < items.length) { - descLine += padEnd(items[idx].desc || "", colWidth); - } - } console.log(pad + color.dim(descLine)); - console.log(""); - console.log(""); + console.log("\n"); } } - initKeypress(); - - return new Promise((resolve) => { + return new Promise(resolve => { + initKeypress(); render(); onKey((str, key) => { - if (!key) return; + if (!key || resolved) return; const row = Math.floor(current / cols); const col = current % cols; - if (key.name === "up" && row > 0) { - current -= cols; - render(); - } else if (key.name === "down" && row < rows - 1 && current + cols < items.length) { - current += cols; - render(); - } else if (key.name === "left" && col > 0) { - current--; - render(); - } else if (key.name === "right" && col < cols - 1 && current < items.length - 1) { - current++; - render(); - } else if (key.name === "return") { - setImmediate(() => resolve(items[current])); - } else if (key.name === "escape" || (key.ctrl && key.name === "c")) { - process.stdin.setRawMode(false); - clearScreen(); - console.log(color.yellow("👋 再见!")); - process.exit(0); + switch (key.name) { + case "up": + if (row > 0) { current -= cols; render(); } + break; + case "down": + if (row < rows - 1 && current + cols < items.length) { current += cols; render(); } + break; + case "left": + if (col > 0) { current--; render(); } + break; + case "right": + if (col < cols - 1 && current < items.length - 1) { current++; render(); } + break; + case "return": + resolved = true; + stopKeypress(); + setImmediate(() => resolve(items[current])); + break; + case "escape": + resolved = true; + stopKeypress(); + clearScreen(); + console.log(color.yellow("再见!")); + process.exit(0); + break; + case "c": + if (key.ctrl) { + resolved = true; + stopKeypress(); + clearScreen(); + console.log(color.yellow("再见!")); + process.exit(0); + } + break; } }); }); diff --git a/lib/keyboard.js b/lib/keyboard.js index 2675a64..7cbe6ac 100644 --- a/lib/keyboard.js +++ b/lib/keyboard.js @@ -1,37 +1,48 @@ import readline from "readline"; let initialized = false; +let currentHandler = null; + +function ensureRawMode(enabled) { + if (process.stdin.isTTY) { + process.stdin.setRawMode(enabled); + } +} export function initKeypress() { + // 清理所有keypress监听器 + process.stdin.removeAllListeners("keypress"); + currentHandler = null; + + // 初始化readline(只需一次) if (!initialized) { readline.emitKeypressEvents(process.stdin); initialized = true; } - process.stdin.removeAllListeners("keypress"); - if (process.stdin.isTTY) { - process.stdin.setRawMode(true); - } + + ensureRawMode(true); } export function onKey(handler) { + // 清理旧的监听器 + process.stdin.removeAllListeners("keypress"); + currentHandler = handler; process.stdin.on("keypress", handler); } export function stopKeypress() { process.stdin.removeAllListeners("keypress"); - if (process.stdin.isTTY) { - process.stdin.setRawMode(false); - } + currentHandler = null; + ensureRawMode(false); } export function waitForKey(message = "按任意键返回...") { return new Promise(resolve => { console.log("\n" + message); initKeypress(); - const handler = () => { + onKey(() => { stopKeypress(); resolve(); - }; - process.stdin.once("keypress", handler); + }); }); } diff --git a/lib/menu.js b/lib/menu.js index b84f9b2..f5f30a5 100644 --- a/lib/menu.js +++ b/lib/menu.js @@ -26,6 +26,7 @@ const tools = [ { name: "Sprite图集", desc: "合并精灵图集", module: "./lib/sprite/index.js" }, { name: "LOD生成器", desc: "生成多级细节", module: "./lib/lod/index.js" }, { name: "音频压缩", desc: "压缩音频文件", module: "./lib/audio/index.js" }, + { name: "项目脚手架", desc: "快速创建项目模板", module: "./lib/scaffold/index.js" }, ]; export function setPoem(lines, perLine = 2) { diff --git a/lib/scaffold/config.js b/lib/scaffold/config.js new file mode 100644 index 0000000..83087fb --- /dev/null +++ b/lib/scaffold/config.js @@ -0,0 +1,158 @@ +// 二级菜单 - 项目类型 +export const projectTypes = [ + { name: "前端", desc: "React/Vue项目" }, + { name: "后端", desc: "Node.js服务端" }, + { name: "全栈", desc: "前后端一体化" }, +]; + +// 三级菜单 - 具体框架 +export const frameworks = { + 前端: [ + { name: "React", desc: "Vite + React", value: "react-vite" }, + { name: "Vue", desc: "Vite + Vue", value: "vue-vite" }, + ], + 后端: [ + { name: "Bun", desc: "Bun运行时", value: "bun" }, + { name: "NestJS", desc: "企业级框架", value: "nestjs" }, + { name: "Express", desc: "Express Generator", value: "express" }, + { name: "Koa", desc: "Koa Generator", value: "koa" }, + { name: "Egg", desc: "阿里Egg.js", value: "egg" }, + { name: "Midway", desc: "阿里Midway", value: "midway" }, + { name: "Fastify", desc: "高性能框架", value: "fastify" }, + { name: "AdonisJS", desc: "全功能MVC", value: "adonisjs" }, + ], + 全栈: [ + { name: "Next.js", desc: "React全栈框架", value: "nextjs" }, + { name: "Nuxt", desc: "Vue全栈框架", value: "nuxt" }, + ], +}; + +// 前端组件配置步骤 +export const frontendSteps = [ + { + name: "路由", + type: "select", + message: "选择路由方案", + options: [ + { value: "none", label: "不需要路由" }, + { value: "react-router", label: "React Router(React项目)" }, + { value: "vue-router", label: "Vue Router(Vue项目)" }, + ], + default: "none" + }, + { + name: "状态管理", + type: "select", + message: "选择状态管理方案", + options: [ + { value: "none", label: "不需要状态管理" }, + { value: "zustand", label: "Zustand(轻量级,React推荐)" }, + { value: "pinia", label: "Pinia(Vue官方推荐)" }, + { value: "redux", label: "Redux Toolkit(大型项目)" }, + { value: "mobx", label: "MobX(响应式状态管理)" }, + ], + default: "none" + }, + { + name: "HTTP请求", + type: "select", + message: "选择HTTP请求库", + options: [ + { value: "none", label: "不需要HTTP库" }, + { value: "axios", label: "Axios(功能全面,拦截器支持)" }, + { value: "ky", label: "Ky(轻量级,基于Fetch)" }, + { value: "ofetch", label: "ofetch(Nuxt团队出品)" }, + ], + default: "none" + }, + { + name: "UI组件库", + type: "select", + message: "选择UI组件库", + options: [ + { value: "none", label: "不需要UI库" }, + { value: "antd", label: "Ant Design(企业级,React)" }, + { value: "element-plus", label: "Element Plus(饿了么,Vue)" }, + { value: "arco", label: "Arco Design(字节跳动)" }, + { value: "naive-ui", label: "Naive UI(Vue3原生)" }, + { value: "shadcn", label: "shadcn/ui(可定制,React)" }, + ], + default: "none" + }, + { + name: "CSS方案", + type: "select", + message: "选择CSS方案", + options: [ + { value: "none", label: "原生CSS" }, + { value: "tailwind", label: "Tailwind CSS(原子化CSS)" }, + { value: "unocss", label: "UnoCSS(即时原子化)" }, + { value: "sass", label: "Sass/SCSS(预处理器)" }, + { value: "less", label: "Less(预处理器)" }, + { value: "styled", label: "Styled Components(CSS-in-JS)" }, + ], + default: "none" + }, + { + name: "工具库", + type: "multiselect", + message: "选择常用工具库", + options: [ + { value: "lodash", label: "Lodash(工具函数库)" }, + { value: "dayjs", label: "Day.js(日期处理)" }, + { value: "iconify", label: "Iconify(图标库)" }, + { value: "vueuse", label: "VueUse(Vue组合式工具)" }, + { value: "ahooks", label: "ahooks(React Hooks库)" }, + ], + default: [] + }, + { + name: "表单验证", + type: "select", + message: "选择表单验证方案", + options: [ + { value: "none", label: "不需要表单验证" }, + { value: "zod", label: "Zod(TypeScript优先)" }, + { value: "yup", label: "Yup(声明式验证)" }, + { value: "vee-validate", label: "VeeValidate(Vue专用)" }, + { value: "react-hook-form", label: "React Hook Form" }, + ], + default: "none" + }, + { + name: "国际化", + type: "select", + message: "选择国际化方案", + options: [ + { value: "none", label: "不需要国际化" }, + { value: "i18next", label: "i18next(通用方案)" }, + { value: "vue-i18n", label: "Vue I18n(Vue专用)" }, + ], + default: "none" + }, + { + name: "代码规范", + type: "multiselect", + message: "选择代码规范工具", + options: [ + { value: "eslint", label: "ESLint(代码检查)" }, + { value: "prettier", label: "Prettier(代码格式化)" }, + { value: "husky", label: "Husky(Git Hooks)" }, + { value: "lint-staged", label: "lint-staged(暂存区检查)" }, + { value: "commitlint", label: "Commitlint(提交信息规范)" }, + ], + default: [] + }, + { + name: "其他", + type: "multiselect", + message: "选择其他功能", + options: [ + { value: "mock", label: "Mock.js(模拟数据)" }, + { value: "pwa", label: "PWA支持" }, + { value: "storage", label: "持久化存储封装" }, + { value: "env", label: "环境变量配置" }, + ], + default: [] + } +]; diff --git a/lib/scaffold/index.js b/lib/scaffold/index.js new file mode 100644 index 0000000..4bcb0ac --- /dev/null +++ b/lib/scaffold/index.js @@ -0,0 +1,39 @@ +import color from "picocolors"; +import { projectTypes, frameworks } from "./config.js"; +import { gridSelect, createComponentUI, formatResults, waitKey, showPlaceholder } from "./ui.js"; + +export async function run() { + while (true) { + // 二级菜单 - 项目类型 + const typeResult = await gridSelect(projectTypes, "项目脚手架 - 选择类型"); + if (typeResult.action === "back") return "back"; + + const projectType = typeResult.item.name; + const frameworkList = frameworks[projectType]; + + while (true) { + // 三级菜单 - 框架选择 + const frameworkResult = await gridSelect(frameworkList, `${projectType}项目 - 选择框架`); + if (frameworkResult.action === "back") break; + + const framework = frameworkResult.item; + + if (projectType === "前端" || projectType === "全栈") { + // 组件配置 + const ui = createComponentUI(framework.name); + const result = await ui.runInteractive(); + + if (result) { + const summary = formatResults(result.results); + ui.showSummary(summary); + console.log(color.yellow("功能开发中,敬请期待...")); + await waitKey(); + } + } else { + // 后端暂无组件配置 + showPlaceholder(framework); + await waitKey(); + } + } + } +} diff --git a/lib/scaffold/ui.js b/lib/scaffold/ui.js new file mode 100644 index 0000000..3c63809 --- /dev/null +++ b/lib/scaffold/ui.js @@ -0,0 +1,152 @@ +import color from "picocolors"; +import { initKeypress, onKey, stopKeypress } from "../keyboard.js"; +import { createStepUI } from "../utils/stepui.js"; +import { frontendSteps } from "./config.js"; + +function clearScreen() { + process.stdout.write('\x1Bc'); +} + +function strWidth(str) { + let width = 0; + for (const char of str) { + width += char.charCodeAt(0) > 127 ? 2 : 1; + } + return width; +} + +function padEnd(str, width) { + return str + " ".repeat(Math.max(0, width - strWidth(str))); +} + +// 网格选择器 +export async function gridSelect(items, title) { + let current = 0; + let resolved = false; + const termWidth = process.stdout.columns || 80; + const cols = Math.min(3, items.length); + const colWidth = 20; + const rows = Math.ceil(items.length / cols); + const totalWidth = cols * colWidth; + const pad = " ".repeat(Math.max(0, Math.floor((termWidth - totalWidth) / 2))); + + function render() { + clearScreen(); + console.log(""); + const titlePad = " ".repeat(Math.max(0, Math.floor((termWidth - strWidth(title) - 4) / 2))); + console.log(titlePad + color.bgMagenta(color.white(` ${title} `))); + console.log(""); + console.log(pad + color.dim("↑ ↓ ← → 选择 | Enter 确认 | Esc 返回")); + console.log("\n"); + + for (let row = 0; row < rows; row++) { + let line = ""; + let descLine = ""; + + for (let col = 0; col < cols; col++) { + const idx = row * cols + col; + if (idx < items.length) { + const item = items[idx]; + const isSelected = idx === current; + + if (isSelected) { + line += color.cyan("[" + item.name + "]"); + } else { + line += " " + item.name + " "; + } + line += " ".repeat(colWidth - strWidth(item.name) - 2); + descLine += padEnd(item.desc || "", colWidth); + } + } + + console.log(pad + line); + console.log(pad + color.dim(descLine)); + console.log("\n"); + } + } + + return new Promise(resolve => { + initKeypress(); + render(); + + onKey((str, key) => { + if (!key || resolved) return; + + const row = Math.floor(current / cols); + const col = current % cols; + + switch (key.name) { + case "up": + if (row > 0) { current -= cols; render(); } + break; + case "down": + if (row < rows - 1 && current + cols < items.length) { current += cols; render(); } + break; + case "left": + if (col > 0) { current--; render(); } + break; + case "right": + if (col < cols - 1 && current < items.length - 1) { current++; render(); } + break; + case "return": + resolved = true; + stopKeypress(); + setImmediate(() => resolve({ action: "select", item: items[current] })); + break; + case "escape": + resolved = true; + stopKeypress(); + setImmediate(() => resolve({ action: "back" })); + break; + } + }); + }); +} + +// 创建组件配置UI +export function createComponentUI(frameworkName) { + return createStepUI({ + title: `${frameworkName} - 组件配置`, + getSteps: () => frontendSteps + }); +} + +// 解析配置结果 +export function formatResults(results) { + const stepNames = frontendSteps.map(s => s.name); + const summary = []; + + results.forEach((val, i) => { + if (Array.isArray(val) && val.length > 0) { + summary.push(`${stepNames[i]}: ${val.join(", ")}`); + } else if (val && val !== "none") { + summary.push(`${stepNames[i]}: ${val}`); + } + }); + + return summary.length ? summary : ["未选择任何组件"]; +} + +// 等待按键 +export async function waitKey(message = "按任意键返回") { + console.log(color.dim(`\n${message}`)); + + return new Promise(resolve => { + initKeypress(); + onKey(() => { + stopKeypress(); + resolve(); + }); + }); +} + +// 显示占位信息 +export function showPlaceholder(framework) { + clearScreen(); + console.log(""); + console.log(color.bgGreen(color.black(" 配置完成 "))); + console.log(""); + console.log(color.cyan("框架: ") + framework.name); + console.log(""); + console.log(color.yellow("功能开发中,敬请期待...")); +} diff --git a/lib/utils/stepui.js b/lib/utils/stepui.js index 0295d51..a0779b5 100644 --- a/lib/utils/stepui.js +++ b/lib/utils/stepui.js @@ -1,5 +1,9 @@ import color from "picocolors"; -import { initKeypress, onKey } from "../keyboard.js"; +import { initKeypress, onKey, stopKeypress } from "../keyboard.js"; + +function clearScreen() { + process.stdout.write('\x1Bc'); +} export function createStepUI(options) { const { title, getSteps } = options; @@ -9,6 +13,7 @@ export function createStepUI(options) { let completed = new Set(); let currentStep = 0; let currentOption = 0; + let resolved = false; function initResults() { steps = typeof getSteps === "function" ? getSteps() : getSteps; @@ -16,6 +21,7 @@ export function createStepUI(options) { completed = new Set(); currentStep = 0; currentOption = 0; + resolved = false; } function renderNav() { @@ -35,13 +41,10 @@ export function createStepUI(options) { return lines.join("\n"); } - // 计算可显示的行数(终端高度 - 标题/导航/提示占用的行数) const termHeight = process.stdout.rows || 24; - const reservedLines = 8; - const maxVisible = Math.max(5, termHeight - reservedLines); + const maxVisible = Math.max(5, termHeight - 8); const total = step.options.length; - // 计算滚动窗口 let start = 0; if (total > maxVisible) { start = Math.max(0, Math.min(currentOption - Math.floor(maxVisible / 2), total - maxVisible)); @@ -71,38 +74,42 @@ export function createStepUI(options) { } function render() { - process.stdout.write('\x1Bc'); + clearScreen(); console.log(color.bgCyan(color.black(` ${title} `))); console.log("\n" + renderNav()); console.log(color.dim("\n← → 切换步骤 | ↑ ↓ 选择 | Space 选中 | Tab 提交 | Esc 返回\n")); console.log(renderOptions()); } - function runInteractive() { - initResults(); - initKeypress(); - return new Promise(resolve => { - render(); - onKey((str, key) => { - if (!key) return; - const step = steps[currentStep]; - const optCount = step.options?.length || 0; - if (key.name === "left") { - if (currentStep > 0) { currentStep--; currentOption = 0; } - render(); - } else if (key.name === "right") { - if (currentStep < steps.length - 1) { currentStep++; currentOption = 0; } - render(); - } else if (key.name === "up") { - if (!optCount) return; + function handleKey(key, resolve) { + if (!key || resolved) return; + + const step = steps[currentStep]; + const optCount = step.options?.length || 0; + + switch (key.name) { + case "left": + if (currentStep > 0) { currentStep--; currentOption = 0; } + render(); + break; + case "right": + if (currentStep < steps.length - 1) { currentStep++; currentOption = 0; } + render(); + break; + case "up": + if (optCount) { currentOption = (currentOption - 1 + optCount) % optCount; render(); - } else if (key.name === "down") { - if (!optCount) return; + } + break; + case "down": + if (optCount) { currentOption = (currentOption + 1) % optCount; render(); - } else if (key.name === "space") { - if (!optCount) return; + } + break; + case "space": + if (optCount) { const opt = step.options[currentOption]; if (step.type === "multiselect") { const list = results[currentStep]; @@ -114,25 +121,48 @@ export function createStepUI(options) { } completed.add(currentStep); render(); - } else if (key.name === "return") { - if (!optCount) return; - if (step.type === "select") { - results[currentStep] = step.options[currentOption].value; - } + } + break; + case "return": + if (optCount && step.type === "select") { + results[currentStep] = step.options[currentOption].value; completed.add(currentStep); if (currentStep < steps.length - 1) { currentStep++; currentOption = 0; } render(); - } else if (key.name === "tab") { - resolve({ steps, results }); - } else if (key.name === "escape" || (key.ctrl && key.name === "c")) { - resolve(null); } - }); + break; + case "tab": + resolved = true; + stopKeypress(); + setImmediate(() => resolve({ steps, results })); + break; + case "escape": + resolved = true; + stopKeypress(); + setImmediate(() => resolve(null)); + break; + case "c": + if (key.ctrl) { + resolved = true; + stopKeypress(); + setImmediate(() => resolve(null)); + } + break; + } + } + + function runInteractive() { + initResults(); + initKeypress(); + + return new Promise(resolve => { + render(); + onKey((str, key) => handleKey(key, resolve)); }); } function showSummary(lines) { - console.clear(); + clearScreen(); console.log(color.bgCyan(color.black(` ${title} `))); console.log("\n" + color.green("配置完成!当前设置:")); lines.forEach(line => console.log(" " + line));