218 lines
6.7 KiB
JavaScript
218 lines
6.7 KiB
JavaScript
import color from "picocolors";
|
||
import { initKeypress, onKey, stopKeypress } from "../keyboard.js";
|
||
import { clearScreen } from "./terminal.js";
|
||
|
||
export function createStepUI(options) {
|
||
const { title, getSteps, onStepChange, validate } = options;
|
||
|
||
let steps = [];
|
||
let results = [];
|
||
let completed = new Set();
|
||
let currentStep = 0;
|
||
let currentOption = 0;
|
||
let resolved = false;
|
||
|
||
function initResults() {
|
||
steps = typeof getSteps === "function" ? getSteps() : getSteps;
|
||
results = steps.map(s => s.type === "multiselect" ? [...(s.default || [])] : s.default);
|
||
completed = new Set();
|
||
// 有默认值的步骤自动标记为已完成
|
||
steps.forEach((s, i) => {
|
||
if (s.default && s.default !== "none" && (!Array.isArray(s.default) || s.default.length > 0)) {
|
||
completed.add(i);
|
||
}
|
||
});
|
||
currentStep = 0;
|
||
currentOption = 0;
|
||
resolved = false;
|
||
}
|
||
|
||
function updateSteps(newSteps) {
|
||
steps = newSteps;
|
||
// 保留第一个步骤的结果,重置其他步骤为默认值
|
||
const firstResult = results[0];
|
||
results = steps.map((s, i) => {
|
||
if (i === 0) return firstResult;
|
||
return s.type === "multiselect" ? [...(s.default || [])] : s.default;
|
||
});
|
||
if (currentStep >= steps.length) currentStep = steps.length - 1;
|
||
}
|
||
|
||
function renderNav() {
|
||
const nav = steps.map((step, i) => {
|
||
if (step.disabled) return color.dim("□ " + step.name);
|
||
if (i === currentStep) return color.bgCyan(color.black(" " + step.name + " "));
|
||
return color.dim(step.name);
|
||
});
|
||
return "← " + nav.join(" ") + " " + color.green("✓Submit") + " →";
|
||
}
|
||
|
||
function renderOptions() {
|
||
const step = steps[currentStep];
|
||
const lines = [color.cyan(step.message), ""];
|
||
if (!step.options || !step.options.length) {
|
||
lines.push(color.dim(step.emptyMessage || "无可用选项"));
|
||
return lines.join("\n");
|
||
}
|
||
|
||
const termHeight = process.stdout.rows || 24;
|
||
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));
|
||
}
|
||
const end = Math.min(start + maxVisible, total);
|
||
|
||
if (start > 0) lines.push(color.dim(" ↑ 更多选项..."));
|
||
|
||
for (let i = start; i < end; i++) {
|
||
const opt = step.options[i];
|
||
const isCurrent = i === currentOption;
|
||
const isSelected = step.type === "multiselect"
|
||
? results[currentStep]?.includes(opt.value)
|
||
: results[currentStep] === opt.value;
|
||
const prefix = step.type === "multiselect"
|
||
? (isSelected ? color.green("◉ ") : "○ ")
|
||
: (isSelected ? color.green("● ") : "○ ");
|
||
const cursor = isCurrent ? color.cyan("❯ ") : " ";
|
||
const label = isCurrent ? color.cyan(opt.label) : opt.label;
|
||
const check = isSelected ? color.green(" ✓") : "";
|
||
lines.push(cursor + prefix + label + check);
|
||
}
|
||
|
||
if (end < total) lines.push(color.dim(" ↓ 更多选项..."));
|
||
|
||
return lines.join("\n");
|
||
}
|
||
|
||
function render() {
|
||
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 findPrevStep(from) {
|
||
for (let i = from - 1; i >= 0; i--) {
|
||
if (!steps[i].disabled) return i;
|
||
}
|
||
return from;
|
||
}
|
||
|
||
function findNextStep(from) {
|
||
for (let i = from + 1; i < steps.length; i++) {
|
||
if (!steps[i].disabled) return i;
|
||
}
|
||
return from;
|
||
}
|
||
|
||
function handleKey(key, resolve) {
|
||
if (!key || resolved) return;
|
||
|
||
const step = steps[currentStep];
|
||
const optCount = step.options?.length || 0;
|
||
|
||
switch (key.name) {
|
||
case "left":
|
||
const prevStep = findPrevStep(currentStep);
|
||
if (prevStep !== currentStep) { currentStep = prevStep; currentOption = 0; }
|
||
render();
|
||
break;
|
||
case "right":
|
||
const nextStep = findNextStep(currentStep);
|
||
if (nextStep !== currentStep) { currentStep = nextStep; currentOption = 0; }
|
||
render();
|
||
break;
|
||
case "up":
|
||
if (optCount) {
|
||
currentOption = (currentOption - 1 + optCount) % optCount;
|
||
render();
|
||
}
|
||
break;
|
||
case "down":
|
||
if (optCount) {
|
||
currentOption = (currentOption + 1) % optCount;
|
||
render();
|
||
}
|
||
break;
|
||
case "space":
|
||
if (optCount) {
|
||
const opt = step.options[currentOption];
|
||
if (step.type === "multiselect") {
|
||
const list = results[currentStep];
|
||
const idx = list.indexOf(opt.value);
|
||
if (idx >= 0) list.splice(idx, 1);
|
||
else list.push(opt.value);
|
||
} else {
|
||
results[currentStep] = opt.value;
|
||
}
|
||
completed.add(currentStep);
|
||
if (onStepChange && currentStep === 0) {
|
||
const newSteps = onStepChange(results[0]);
|
||
if (newSteps) updateSteps(newSteps);
|
||
}
|
||
render();
|
||
}
|
||
break;
|
||
case "return":
|
||
if (optCount && step.type === "select") {
|
||
results[currentStep] = step.options[currentOption].value;
|
||
completed.add(currentStep);
|
||
if (onStepChange && currentStep === 0) {
|
||
const newSteps = onStepChange(results[0]);
|
||
if (newSteps) updateSteps(newSteps);
|
||
}
|
||
const next = findNextStep(currentStep);
|
||
if (next !== currentStep) { currentStep = next; currentOption = 0; }
|
||
render();
|
||
}
|
||
break;
|
||
case "tab":
|
||
// 如果提供了验证函数,先验证
|
||
if (validate && !validate(results)) {
|
||
// 验证失败,忽略此次提交
|
||
break;
|
||
}
|
||
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) {
|
||
clearScreen();
|
||
console.log(color.bgCyan(color.black(` ${title} `)));
|
||
console.log("\n" + color.green("配置完成!当前设置:"));
|
||
lines.forEach(line => console.log(" " + line));
|
||
console.log();
|
||
}
|
||
|
||
return { runInteractive, showSummary, initResults };
|
||
}
|