Files
yinx-cli/lib/utils/stepui.js
2026-01-15 11:03:06 +08:00

218 lines
6.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 };
}