增加转格式
This commit is contained in:
35
lib/convert/config.js
Normal file
35
lib/convert/config.js
Normal file
@ -0,0 +1,35 @@
|
||||
import fs from "fs";
|
||||
import { getImportExtensions, getExportFormats } from "./converters.js";
|
||||
import { sortByUsage } from "../stats.js";
|
||||
|
||||
export function listConvertibleFiles() {
|
||||
const cwd = process.cwd();
|
||||
const exts = getImportExtensions();
|
||||
return fs.readdirSync(cwd).filter(file => {
|
||||
const ext = file.slice(file.lastIndexOf(".")).toLowerCase();
|
||||
return exts.includes(ext);
|
||||
});
|
||||
}
|
||||
|
||||
export function getSteps() {
|
||||
const files = listConvertibleFiles();
|
||||
const formats = sortByUsage("convert_format", getExportFormats());
|
||||
|
||||
return [
|
||||
{
|
||||
name: "源文件",
|
||||
type: "multiselect",
|
||||
message: files.length ? "选择要转换的模型文件" : "当前目录未找到可转换的模型文件",
|
||||
options: files.map(file => ({ value: file, label: file })),
|
||||
default: [],
|
||||
emptyMessage: "请按 Esc 返回并放入模型文件后重试"
|
||||
},
|
||||
{
|
||||
name: "输出格式",
|
||||
type: "select",
|
||||
message: "选择目标格式",
|
||||
options: formats.map(f => ({ value: f, label: f })),
|
||||
default: formats[0]
|
||||
}
|
||||
];
|
||||
}
|
||||
31
lib/convert/converters.js
Normal file
31
lib/convert/converters.js
Normal file
@ -0,0 +1,31 @@
|
||||
import { spawnSync } from "child_process";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ASSIMP_PATH = path.join(__dirname, "../../bin/assimp.exe");
|
||||
|
||||
let importExts = null;
|
||||
let exportFormats = null;
|
||||
|
||||
export function getImportExtensions() {
|
||||
if (!importExts) {
|
||||
const r = spawnSync(ASSIMP_PATH, ["listext"], { encoding: "utf8" });
|
||||
importExts = r.stdout.trim().split(";").map(e => e.replace("*", "").toLowerCase());
|
||||
}
|
||||
return importExts;
|
||||
}
|
||||
|
||||
export function getExportFormats() {
|
||||
if (!exportFormats) {
|
||||
const r = spawnSync(ASSIMP_PATH, ["listexport"], { encoding: "utf8" });
|
||||
exportFormats = r.stdout.trim().split(/\r?\n/).filter(Boolean);
|
||||
}
|
||||
return exportFormats;
|
||||
}
|
||||
|
||||
export async function convert(inputFile, outputFile, format, cwd = process.cwd()) {
|
||||
const r = spawnSync(ASSIMP_PATH, ["export", inputFile, outputFile, `-f${format}`], { encoding: "utf8", cwd });
|
||||
if (r.status !== 0) throw new Error(r.stderr || r.stdout || "转换失败");
|
||||
return outputFile;
|
||||
}
|
||||
108
lib/convert/index.js
Normal file
108
lib/convert/index.js
Normal file
@ -0,0 +1,108 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import color from "picocolors";
|
||||
import { convert } from "./converters.js";
|
||||
import { runInteractive, showSummary } from "./ui.js";
|
||||
import { stopKeypress, initKeypress, onKey } from "../keyboard.js";
|
||||
import { record } from "../stats.js";
|
||||
|
||||
const FORMAT_EXT = {
|
||||
collada: ".dae", x: ".x", stp: ".stp", obj: ".obj", objnomtl: ".obj",
|
||||
stl: ".stl", stlb: ".stl", ply: ".ply", plyb: ".ply", "3ds": ".3ds",
|
||||
gltf2: ".gltf", glb2: ".glb", gltf: ".gltf", glb: ".glb",
|
||||
assbin: ".assbin", assxml: ".assxml", x3d: ".x3d",
|
||||
fbx: ".fbx", fbxa: ".fbx", "3mf": ".3mf", pbrt: ".pbrt", assjson: ".json"
|
||||
};
|
||||
|
||||
function getOutputExt(format) {
|
||||
return FORMAT_EXT[format] || "." + format;
|
||||
}
|
||||
|
||||
async function processFile(file, config) {
|
||||
const cwd = process.cwd();
|
||||
const baseName = file.slice(0, file.lastIndexOf("."));
|
||||
const outputExt = getOutputExt(config.outputFormat);
|
||||
const outputFile = baseName + outputExt;
|
||||
|
||||
if (!fs.existsSync(path.join(cwd, file))) {
|
||||
return { ok: false, file, reason: "文件不存在" };
|
||||
}
|
||||
|
||||
await convert(file, outputFile, config.outputFormat, cwd);
|
||||
return { ok: true, file, output: outputFile };
|
||||
}
|
||||
|
||||
async function waitForEsc() {
|
||||
initKeypress();
|
||||
return new Promise(resolve => {
|
||||
onKey((str, key) => {
|
||||
if (key?.name === "escape" || (key?.ctrl && key?.name === "c")) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function run() {
|
||||
const result = await runInteractive();
|
||||
if (!result) return "back";
|
||||
|
||||
const { steps, results } = result;
|
||||
const config = {
|
||||
files: results[0] || [],
|
||||
outputFormat: results[1] || "glb2"
|
||||
};
|
||||
|
||||
stopKeypress();
|
||||
showSummary([
|
||||
"源文件: " + (config.files.length ? config.files.join(", ") : "未选择"),
|
||||
"目标格式: " + config.outputFormat.toUpperCase()
|
||||
]);
|
||||
|
||||
if (!config.files.length) {
|
||||
console.log(color.yellow("未选择任何模型文件"));
|
||||
console.log(color.dim("\n按 Esc 返回"));
|
||||
await waitForEsc();
|
||||
return "back";
|
||||
}
|
||||
|
||||
const total = config.files.length;
|
||||
let success = 0;
|
||||
const failed = [];
|
||||
|
||||
console.log(color.cyan(`开始转换 ${total} 个文件...\n`));
|
||||
|
||||
for (let i = 0; i < config.files.length; i++) {
|
||||
const file = config.files[i];
|
||||
const progress = `[${i + 1}/${total}]`;
|
||||
console.log(color.dim(`${progress} 处理中: ${file}`));
|
||||
|
||||
try {
|
||||
const result = await processFile(file, config);
|
||||
if (result.ok) {
|
||||
success++;
|
||||
record("convert_format", config.outputFormat);
|
||||
console.log(color.green(`${progress} ✓ ${file} → ${path.basename(result.output)}`));
|
||||
} else {
|
||||
failed.push({ file, reason: result.reason });
|
||||
console.log(color.yellow(`${progress} ⊘ 跳过: ${file} (${result.reason})`));
|
||||
}
|
||||
} catch (err) {
|
||||
failed.push({ file, reason: err?.message || String(err) });
|
||||
console.log(color.red(`${progress} ✖ 失败: ${file}`));
|
||||
console.log(color.dim(" " + String(err?.message || err)));
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n" + color.bgGreen(color.black(" 转换完成 ")));
|
||||
if (success) {
|
||||
console.log(color.green(`成功: ${success} 个`));
|
||||
}
|
||||
if (failed.length) {
|
||||
console.log(color.yellow(`失败: ${failed.length} 个`));
|
||||
}
|
||||
|
||||
console.log(color.dim("\n按 Esc 返回"));
|
||||
await waitForEsc();
|
||||
return "back";
|
||||
}
|
||||
9
lib/convert/ui.js
Normal file
9
lib/convert/ui.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { createStepUI } from "../utils/stepui.js";
|
||||
import { getSteps } from "./config.js";
|
||||
|
||||
const ui = createStepUI({
|
||||
title: "格式转换工具",
|
||||
getSteps
|
||||
});
|
||||
|
||||
export const { runInteractive, showSummary } = ui;
|
||||
27
lib/gltf/config.js
Normal file
27
lib/gltf/config.js
Normal file
@ -0,0 +1,27 @@
|
||||
import { listGltfFiles } from "../utils/gltf.js";
|
||||
|
||||
const extensionStep = {
|
||||
name: "扩展选项",
|
||||
type: "multiselect",
|
||||
message: "请选择要添加的glTF扩展",
|
||||
options: [
|
||||
{ value: "textureBasisu", label: "KHR_texture_basisu(纹理自动扩展)", hint: "自动添加KTX2纹理扩展支持" },
|
||||
{ value: "placeholder1", label: "预留选项1", hint: "功能开发中..." },
|
||||
{ value: "placeholder2", label: "预留选项2", hint: "功能开发中..." },
|
||||
{ value: "placeholder3", label: "预留选项3", hint: "功能开发中..." }
|
||||
],
|
||||
default: ["textureBasisu"]
|
||||
};
|
||||
|
||||
export function getSteps() {
|
||||
const files = listGltfFiles();
|
||||
const fileStep = {
|
||||
name: "文件选择",
|
||||
type: "multiselect",
|
||||
message: files.length ? "请选择要处理的glTF文件" : "当前目录未找到glTF文件",
|
||||
options: files.map(file => ({ value: file, label: file })),
|
||||
default: [...files]
|
||||
};
|
||||
|
||||
return [fileStep, extensionStep];
|
||||
}
|
||||
73
lib/gltf/index.js
Normal file
73
lib/gltf/index.js
Normal file
@ -0,0 +1,73 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import color from "picocolors";
|
||||
import { checkRequiredFiles, modifyGltfContent, listGltfFiles, BACKUP_SUFFIX } from "../utils/gltf.js";
|
||||
import { runInteractive, showSummary } from "./ui.js";
|
||||
import { stopKeypress, waitForKey } from "../keyboard.js";
|
||||
|
||||
export function runGltfExtension(config = { files: [], extensions: ["textureBasisu"] }) {
|
||||
const cwd = process.cwd();
|
||||
const { files = [], extensions = ["textureBasisu"] } = config;
|
||||
const selectedExtensions = extensions.length ? extensions : ["textureBasisu"];
|
||||
|
||||
const { ok, missing } = checkRequiredFiles();
|
||||
if (!ok) {
|
||||
console.log(color.red("\n✖ 缺少必要文件: " + missing.join(", ")));
|
||||
console.log(color.dim("请确保当前目录包含 .ktx2、.gltf 和 .bin 文件\n"));
|
||||
return { success: false, count: 0 };
|
||||
}
|
||||
|
||||
const fallbackFiles = listGltfFiles();
|
||||
const gltfFiles = (files.length ? files : fallbackFiles).filter(f => f.toLowerCase().endsWith(".gltf"));
|
||||
if (!gltfFiles.length) {
|
||||
console.log(color.yellow("未选择可处理的 glTF 文件"));
|
||||
return { success: false, count: 0 };
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
|
||||
for (const file of gltfFiles) {
|
||||
const fullPath = path.join(cwd, file);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.log(color.yellow("跳过缺失的文件: " + file));
|
||||
continue;
|
||||
}
|
||||
const baseName = file.replace(/\.gltf$/i, "");
|
||||
const backupName = baseName + BACKUP_SUFFIX + ".gltf";
|
||||
const backupPath = path.join(cwd, backupName);
|
||||
|
||||
fs.copyFileSync(fullPath, backupPath);
|
||||
const modified = modifyGltfContent(fullPath, selectedExtensions);
|
||||
fs.writeFileSync(fullPath, JSON.stringify(modified, null, 2), "utf-8");
|
||||
console.log(color.green("✓ " + file + " (备份: " + backupName + ")"));
|
||||
count++;
|
||||
}
|
||||
|
||||
return { success: count > 0, count };
|
||||
}
|
||||
|
||||
export async function run() {
|
||||
const result = await runInteractive();
|
||||
if (!result) return "back";
|
||||
|
||||
stopKeypress();
|
||||
|
||||
const { results } = result;
|
||||
const config = {
|
||||
files: results[0] || [],
|
||||
extensions: results[1] || []
|
||||
};
|
||||
|
||||
showSummary([
|
||||
"处理文件: " + (config.files.length ? config.files.join(", ") : "未选择"),
|
||||
"扩展选项: " + (config.extensions.length ? config.extensions.join(", ") : "未选择")
|
||||
]);
|
||||
|
||||
const { success, count } = runGltfExtension(config);
|
||||
if (success) {
|
||||
console.log(color.green("\n✓ 已修改 " + count + " 个 glTF 文件"));
|
||||
}
|
||||
|
||||
await waitForKey();
|
||||
return "back";
|
||||
}
|
||||
9
lib/gltf/ui.js
Normal file
9
lib/gltf/ui.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { createStepUI } from "../utils/stepui.js";
|
||||
import { getSteps } from "./config.js";
|
||||
|
||||
const ui = createStepUI({
|
||||
title: "glTF 扩展工具",
|
||||
getSteps
|
||||
});
|
||||
|
||||
export const { runInteractive, showSummary } = ui;
|
||||
14
lib/grid.js
14
lib/grid.js
@ -1,6 +1,16 @@
|
||||
import color from "picocolors";
|
||||
import { initKeypress, onKey } 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();
|
||||
}
|
||||
}
|
||||
|
||||
// 计算字符串显示宽度
|
||||
function strWidth(str) {
|
||||
let width = 0;
|
||||
@ -35,7 +45,7 @@ export async function gridSelect(options) {
|
||||
const pad = " ".repeat(Math.max(0, Math.floor((termWidth - totalWidth) / 2)));
|
||||
|
||||
function render() {
|
||||
console.clear();
|
||||
clearScreen();
|
||||
|
||||
if (renderHeader) {
|
||||
console.log(renderHeader());
|
||||
@ -110,7 +120,7 @@ export async function gridSelect(options) {
|
||||
setImmediate(() => resolve(items[current]));
|
||||
} else if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
||||
process.stdin.setRawMode(false);
|
||||
console.clear();
|
||||
clearScreen();
|
||||
console.log(color.yellow("👋 再见!"));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@ -16,3 +16,22 @@ export function initKeypress() {
|
||||
export function onKey(handler) {
|
||||
process.stdin.on("keypress", handler);
|
||||
}
|
||||
|
||||
export function stopKeypress() {
|
||||
process.stdin.removeAllListeners("keypress");
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function waitForKey(message = "按任意键返回...") {
|
||||
return new Promise(resolve => {
|
||||
console.log("\n" + message);
|
||||
initKeypress();
|
||||
const handler = () => {
|
||||
stopKeypress();
|
||||
resolve();
|
||||
};
|
||||
process.stdin.once("keypress", handler);
|
||||
});
|
||||
}
|
||||
|
||||
@ -55,7 +55,8 @@ export const steps = [
|
||||
{ value: "overwrite", label: "覆盖已存在文件(自动替换同名文件)" },
|
||||
{ value: "keepOriginal", label: "保留原文件(压缩后不删除源文件)" },
|
||||
{ value: "report", label: "生成压缩报告(输出详细的压缩统计信息)" },
|
||||
{ value: "silent", label: "静默模式(减少控制台输出信息)" }
|
||||
{ value: "silent", label: "静默模式(减少控制台输出信息)" },
|
||||
{ value: "gltfExtension", label: "修改glTF扩展(添加KHR_texture_basisu)", dynamic: true }
|
||||
],
|
||||
default: ["overwrite", "keepOriginal"]
|
||||
}
|
||||
|
||||
3
lib/ktx2/gltf.js
Normal file
3
lib/ktx2/gltf.js
Normal file
@ -0,0 +1,3 @@
|
||||
// 从 utils 导出,保持兼容
|
||||
export { hasGltfFile, checkRequiredFiles, modifyGltfContent } from "../utils/gltf.js";
|
||||
export { runGltfExtension } from "../gltf/index.js";
|
||||
@ -1,40 +1,49 @@
|
||||
import color from "picocolors";
|
||||
import { checkToktx, scanImages, compressAll } from "./compressor.js";
|
||||
import { runInteractive, showSummary } from "./ui.js";
|
||||
import { runGltfExtension } from "./gltf.js";
|
||||
import { stopKeypress, waitForKey } from "../keyboard.js";
|
||||
|
||||
export async function run() {
|
||||
// 检查 toktx
|
||||
checkToktx();
|
||||
|
||||
// 运行交互界面
|
||||
const result = await runInteractive();
|
||||
|
||||
// ESC 返回主菜单
|
||||
if (!result) return "back";
|
||||
|
||||
const [exts, quality, encoding, mipmap, outputOpts] = result;
|
||||
stopKeypress();
|
||||
|
||||
const { results } = result;
|
||||
const [exts, quality, encoding, mipmap, outputOpts] = results;
|
||||
const config = { exts, quality, encoding, mipmap, outputOpts };
|
||||
showSummary([
|
||||
"文件格式: " + config.exts.join(", "),
|
||||
"压缩程度: " + config.quality,
|
||||
"编码格式: " + config.encoding,
|
||||
"Mipmap: " + config.mipmap,
|
||||
"输出选项: " + config.outputOpts.join(", ")
|
||||
]);
|
||||
|
||||
// 显示配置摘要
|
||||
showSummary(config);
|
||||
|
||||
// 扫描文件
|
||||
const { images, cwd } = scanImages(exts);
|
||||
|
||||
if (images.length === 0) {
|
||||
console.log(color.yellow("当前目录没有匹配的图片"));
|
||||
return;
|
||||
await waitForKey();
|
||||
return "back";
|
||||
}
|
||||
|
||||
console.log(`📁 找到 ${color.cyan(images.length)} 个待转换文件\n`);
|
||||
|
||||
// 执行压缩
|
||||
console.log("📁 找到 " + color.cyan(images.length) + " 个待转换文件\n");
|
||||
const { total, failed } = await compressAll(images, config, cwd);
|
||||
|
||||
// 显示结果
|
||||
if (failed > 0) {
|
||||
console.log(color.yellow(`\n⚠️ 完成,但有 ${failed} 个文件失败`));
|
||||
console.log(color.yellow("\n⚠️ 完成,但有 " + failed + " 个文件失败"));
|
||||
} else {
|
||||
console.log(color.green("\n🎉 全部文件压缩完成!"));
|
||||
}
|
||||
|
||||
if (outputOpts.includes("gltfExtension")) {
|
||||
console.log(color.cyan("\n正在修改 glTF 文件..."));
|
||||
const { success, count } = runGltfExtension();
|
||||
if (success) console.log(color.green("✓ 已修改 " + count + " 个 glTF 文件"));
|
||||
}
|
||||
|
||||
await waitForKey();
|
||||
return "back";
|
||||
}
|
||||
|
||||
159
lib/ktx2/ui.js
159
lib/ktx2/ui.js
@ -1,151 +1,20 @@
|
||||
import color from "picocolors";
|
||||
import { createStepUI } from "../utils/stepui.js";
|
||||
import { steps } from "./config.js";
|
||||
import { initKeypress, onKey } from "../keyboard.js";
|
||||
import { hasGltfFile } from "./gltf.js";
|
||||
|
||||
// 存储结果和状态
|
||||
let results = [];
|
||||
let completed = new Set();
|
||||
let currentStep = 0;
|
||||
let currentOption = 0;
|
||||
|
||||
// 初始化结果
|
||||
export function initResults() {
|
||||
results = steps.map(s => s.type === "multiselect" ? [...s.default] : s.default);
|
||||
completed = new Set();
|
||||
currentStep = 0;
|
||||
currentOption = 0;
|
||||
}
|
||||
|
||||
// 渲染导航栏
|
||||
function renderNav() {
|
||||
const nav = steps.map((step, i) => {
|
||||
if (completed.has(i)) {
|
||||
return color.green(`☑ ${step.name}`);
|
||||
} else if (i === currentStep) {
|
||||
return color.bgCyan(color.black(` ${step.name} `));
|
||||
} else {
|
||||
return color.dim(`□ ${step.name}`);
|
||||
function getFilteredSteps() {
|
||||
const hasGltf = hasGltfFile();
|
||||
return steps.map(step => {
|
||||
if (step.name === "输出选项") {
|
||||
return { ...step, options: step.options.filter(opt => !opt.dynamic || hasGltf) };
|
||||
}
|
||||
});
|
||||
return `← ${nav.join(" ")} ${color.green("✓Submit")} →`;
|
||||
}
|
||||
|
||||
// 渲染选项列表
|
||||
function renderOptions() {
|
||||
const step = steps[currentStep];
|
||||
const lines = [];
|
||||
|
||||
lines.push(color.cyan(step.message));
|
||||
lines.push("");
|
||||
|
||||
step.options.forEach((opt, i) => {
|
||||
const isCurrent = i === currentOption;
|
||||
const isSelected = step.type === "multiselect"
|
||||
? results[currentStep].includes(opt.value)
|
||||
: results[currentStep] === opt.value;
|
||||
|
||||
let prefix;
|
||||
if (step.type === "multiselect") {
|
||||
prefix = isSelected ? color.green("◉ ") : "○ ";
|
||||
} else {
|
||||
prefix = 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 (opt.hint) {
|
||||
lines.push(` ${color.dim(opt.hint)}`);
|
||||
}
|
||||
});
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// 渲染整个界面
|
||||
function render() {
|
||||
console.clear();
|
||||
console.log(color.bgCyan(color.black(" KTX2 纹理压缩工具 ")));
|
||||
console.log("\n" + renderNav());
|
||||
console.log(color.dim("\n← → 切换步骤 | ↑ ↓ 选择 | Space 选中 | Tab 提交 | Esc 返回\n"));
|
||||
console.log(renderOptions());
|
||||
}
|
||||
|
||||
// 主交互循环
|
||||
export async function runInteractive() {
|
||||
initResults();
|
||||
initKeypress();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
render();
|
||||
|
||||
onKey((str, key) => {
|
||||
if (!key) return;
|
||||
|
||||
const step = steps[currentStep];
|
||||
const optCount = step.options.length;
|
||||
|
||||
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") {
|
||||
currentOption = (currentOption - 1 + optCount) % optCount;
|
||||
render();
|
||||
} else if (key.name === "down") {
|
||||
currentOption = (currentOption + 1) % optCount;
|
||||
render();
|
||||
} else if (key.name === "space") {
|
||||
const opt = step.options[currentOption];
|
||||
if (step.type === "multiselect") {
|
||||
const idx = results[currentStep].indexOf(opt.value);
|
||||
if (idx >= 0) {
|
||||
results[currentStep].splice(idx, 1);
|
||||
} else {
|
||||
results[currentStep].push(opt.value);
|
||||
}
|
||||
} else {
|
||||
results[currentStep] = opt.value;
|
||||
}
|
||||
completed.add(currentStep);
|
||||
render();
|
||||
} else if (key.name === "return") {
|
||||
if (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(results);
|
||||
} else if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
return step;
|
||||
});
|
||||
}
|
||||
|
||||
// 显示配置摘要
|
||||
export function showSummary(config) {
|
||||
console.clear();
|
||||
console.log(color.bgCyan(color.black(" KTX2 纹理压缩工具 ")));
|
||||
console.log("\n" + color.green("配置完成!当前设置:"));
|
||||
console.log(` 文件格式: ${config.exts.join(", ")}`);
|
||||
console.log(` 压缩程度: ${config.quality}`);
|
||||
console.log(` 编码格式: ${config.encoding}`);
|
||||
console.log(` Mipmap: ${config.mipmap}`);
|
||||
console.log(` 输出选项: ${config.outputOpts.join(", ")}\n`);
|
||||
}
|
||||
const ui = createStepUI({
|
||||
title: "KTX2 纹理压缩工具",
|
||||
getSteps: getFilteredSteps
|
||||
});
|
||||
|
||||
export const { runInteractive, showSummary } = ui;
|
||||
|
||||
12
lib/menu.js
12
lib/menu.js
@ -3,7 +3,6 @@ import boxen from "boxen";
|
||||
import figlet from "figlet";
|
||||
import { gridSelect } from "./grid.js";
|
||||
|
||||
// 古诗配置
|
||||
let poemConfig = {
|
||||
lines: ["你我皆牛马", "生在人世间", "终日奔波苦", "一刻不得闲"],
|
||||
perLine: 2,
|
||||
@ -12,16 +11,16 @@ let poemConfig = {
|
||||
borderColor: "cyan",
|
||||
};
|
||||
|
||||
// 标题配置
|
||||
let titleConfig = {
|
||||
text: "Zguiy Tool Box",
|
||||
font: "Standard",
|
||||
color: "magenta",
|
||||
};
|
||||
|
||||
// 工具列表
|
||||
const tools = [
|
||||
{ name: "格式转换", desc: "OBJ/FBX转glTF", module: "./lib/convert/index.js" },
|
||||
{ name: "KTX2 纹理压缩", desc: "图片转KTX2格式", module: "./lib/ktx2/index.js" },
|
||||
{ name: "glTF扩展", desc: "添加KHR_texture_basisu", module: "./lib/gltf/index.js" },
|
||||
{ name: "模型压缩", desc: "压缩glTF/GLB模型", module: "./lib/model/index.js" },
|
||||
{ name: "图片批量处理", desc: "裁剪/缩放/转换", module: "./lib/image/index.js" },
|
||||
{ name: "Sprite图集", desc: "合并精灵图集", module: "./lib/sprite/index.js" },
|
||||
@ -29,25 +28,21 @@ const tools = [
|
||||
{ name: "音频压缩", desc: "压缩音频文件", module: "./lib/audio/index.js" },
|
||||
];
|
||||
|
||||
// 设置古诗
|
||||
export function setPoem(lines, perLine = 2) {
|
||||
poemConfig.lines = lines;
|
||||
poemConfig.perLine = perLine;
|
||||
}
|
||||
|
||||
// 设置古诗框样式
|
||||
export function setPoemStyle(style) {
|
||||
Object.assign(poemConfig, style);
|
||||
}
|
||||
|
||||
// 设置标题
|
||||
export function setTitle(text, font = "Standard", titleColor = "magenta") {
|
||||
titleConfig.text = text;
|
||||
titleConfig.font = font;
|
||||
titleConfig.color = titleColor;
|
||||
}
|
||||
|
||||
// 渲染古诗
|
||||
function renderPoem() {
|
||||
const merged = [];
|
||||
for (let i = 0; i < poemConfig.lines.length; i += poemConfig.perLine) {
|
||||
@ -62,7 +57,6 @@ function renderPoem() {
|
||||
});
|
||||
}
|
||||
|
||||
// 渲染标题
|
||||
function renderTitle() {
|
||||
const art = figlet.textSync(titleConfig.text, { font: titleConfig.font });
|
||||
const termWidth = process.stdout.columns || 80;
|
||||
@ -72,12 +66,10 @@ function renderTitle() {
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
// 渲染头部
|
||||
function renderHeader() {
|
||||
return renderPoem() + "\n\n" + renderTitle();
|
||||
}
|
||||
|
||||
// 主菜单
|
||||
export async function showMainMenu() {
|
||||
return gridSelect({
|
||||
items: tools,
|
||||
|
||||
71
lib/model/config.js
Normal file
71
lib/model/config.js
Normal file
@ -0,0 +1,71 @@
|
||||
import { listAllModelFiles } from "../utils/gltf.js";
|
||||
|
||||
const transformOptions = [
|
||||
{ value: "dedup", label: "dedup(去重)", hint: "删除重复的访问器、材质、网格" },
|
||||
{ value: "prune", label: "prune(清理无用节点)", hint: "移除未被引用的节点、材质、动画" },
|
||||
{ value: "resample", label: "resample(动画重采样)", hint: "统一动画关键帧间隔,减少多余数据" },
|
||||
{ value: "weld", label: "weld(合并顶点)", hint: "合并共享位置的顶点以减少面数" },
|
||||
{ value: "quantize", label: "quantize(量化顶点数据)", hint: "降低顶点属性精度减小模型体积" }
|
||||
];
|
||||
|
||||
const quantizePresets = [
|
||||
{ value: "high", label: "高质量(位置16位,法线12位,UV14位)", hint: "尽量保证细节,适合高保真场景" },
|
||||
{ value: "balanced", label: "均衡(位置14位,法线10位,UV12位)", hint: "默认推荐,兼顾体积与质量" },
|
||||
{ value: "aggressive", label: "极限压缩(位置12位,法线8位,UV10位)", hint: "最小体积,但可能损失细节" },
|
||||
{ value: "light", label: "轻量(位置10位,法线8位,UV10位)", hint: "适合移动端、卡通等对精度不敏感场景" }
|
||||
];
|
||||
|
||||
const outputFormats = [
|
||||
{ value: "auto", label: "保持原格式", hint: "glTF/GLB保持原格式,OBJ/FBX转为GLB" },
|
||||
{ value: "glb", label: "统一导出为 GLB(二进制)", hint: "单文件发布更方便" },
|
||||
{ value: "gltf", label: "统一导出为 glTF(JSON)", hint: "可读性好,调试方便" }
|
||||
];
|
||||
|
||||
const outputOptions = [
|
||||
{ value: "overwrite", label: "覆盖原文件", hint: "直接把结果写回源文件" },
|
||||
{ value: "backup", label: "保留备份 (_备份)", hint: "覆盖前在同目录生成 _备份 副本" },
|
||||
{ value: "copy", label: "输出副本 (_compressed)", hint: "保留原文件不动,结果写入新文件" }
|
||||
];
|
||||
|
||||
export function getSteps() {
|
||||
const files = listAllModelFiles();
|
||||
const fileStep = {
|
||||
name: "模型选择",
|
||||
type: "multiselect",
|
||||
message: files.length ? "选择要处理的模型(支持 glTF/GLB/OBJ/FBX)" : "当前目录未找到模型文件",
|
||||
options: files.map(file => ({ value: file, label: file })),
|
||||
default: [...files]
|
||||
};
|
||||
|
||||
return [
|
||||
fileStep,
|
||||
{
|
||||
name: "压缩命令",
|
||||
type: "multiselect",
|
||||
message: "请选择要执行的 gltf-transform 操作",
|
||||
options: transformOptions,
|
||||
default: ["dedup", "prune", "weld", "quantize"]
|
||||
},
|
||||
{
|
||||
name: "量化级别",
|
||||
type: "select",
|
||||
message: "量化可显著减小体积(如未启用 quantize 可直接 Tab)",
|
||||
options: quantizePresets,
|
||||
default: "balanced"
|
||||
},
|
||||
{
|
||||
name: "输出格式",
|
||||
type: "select",
|
||||
message: "选择最终模型格式",
|
||||
options: outputFormats,
|
||||
default: "auto"
|
||||
},
|
||||
{
|
||||
name: "输出选项",
|
||||
type: "multiselect",
|
||||
message: "请选择输出行为",
|
||||
options: outputOptions,
|
||||
default: ["overwrite", "backup"]
|
||||
}
|
||||
];
|
||||
}
|
||||
195
lib/model/index.js
Normal file
195
lib/model/index.js
Normal file
@ -0,0 +1,195 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import color from "picocolors";
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { dedup, prune, resample, weld, quantize } from "@gltf-transform/functions";
|
||||
import { BACKUP_SUFFIX, needsConversion } from "../utils/gltf.js";
|
||||
import { convert } from "../convert/converters.js";
|
||||
import { runInteractive, showSummary } from "./ui.js";
|
||||
import { stopKeypress, waitForKey } from "../keyboard.js";
|
||||
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
|
||||
const QUANTIZE_PRESETS = {
|
||||
high: { position: 16, normal: 12, texcoord: 14, color: 10, generic: 12 },
|
||||
balanced: { position: 14, normal: 10, texcoord: 12, color: 8, generic: 12 },
|
||||
aggressive: { position: 12, normal: 8, texcoord: 10, color: 8, generic: 10 },
|
||||
light: { position: 10, normal: 8, texcoord: 10, color: 8, generic: 10 }
|
||||
};
|
||||
|
||||
function buildTransforms(config) {
|
||||
const transforms = [];
|
||||
const selected = new Set(config.commands);
|
||||
if (selected.has("dedup")) transforms.push(dedup());
|
||||
if (selected.has("prune")) transforms.push(prune());
|
||||
if (selected.has("resample")) transforms.push(resample());
|
||||
if (selected.has("weld")) transforms.push(weld());
|
||||
if (selected.has("quantize")) {
|
||||
const preset = QUANTIZE_PRESETS[config.quantizePreset] || QUANTIZE_PRESETS.balanced;
|
||||
transforms.push(quantize({
|
||||
quantizePosition: preset.position,
|
||||
quantizeNormal: preset.normal,
|
||||
quantizeTexcoord: preset.texcoord,
|
||||
quantizeColor: preset.color,
|
||||
quantizeGeneric: preset.generic
|
||||
}));
|
||||
}
|
||||
return transforms;
|
||||
}
|
||||
|
||||
function resolveOutput(file, config) {
|
||||
const cwd = process.cwd();
|
||||
const originalExt = path.extname(file).toLowerCase() || ".gltf";
|
||||
const baseName = originalExt ? file.slice(0, -originalExt.length) : file;
|
||||
const isConvertible = needsConversion(file);
|
||||
|
||||
// OBJ/FBX 默认输出 GLB,除非指定了 gltf
|
||||
const targetExt = config.outputFormat === "gltf" ? ".gltf"
|
||||
: (config.outputFormat === "glb" || isConvertible) ? ".glb"
|
||||
: originalExt;
|
||||
|
||||
const wantsCopy = config.outputOptions.includes("copy");
|
||||
const wantsOverwrite = config.outputOptions.includes("overwrite");
|
||||
const mode = wantsCopy ? "copy" : (wantsOverwrite ? "overwrite" : "copy");
|
||||
const backup = config.outputOptions.includes("backup") && mode === "overwrite";
|
||||
|
||||
const targetName = mode === "copy"
|
||||
? `${baseName}_compressed${targetExt}`
|
||||
: `${baseName}${targetExt}`;
|
||||
|
||||
return {
|
||||
cwd,
|
||||
mode,
|
||||
backup,
|
||||
sourcePath: path.join(cwd, file),
|
||||
sourceExt: originalExt,
|
||||
targetExt,
|
||||
targetPath: path.join(cwd, targetName),
|
||||
baseName,
|
||||
needsConversion: isConvertible
|
||||
};
|
||||
}
|
||||
|
||||
async function processFile(file, config, transforms) {
|
||||
const output = resolveOutput(file, config);
|
||||
if (!fs.existsSync(output.sourcePath)) {
|
||||
console.log(color.yellow("跳过,文件不存在: " + file));
|
||||
return { ok: false, file, reason: "missing" };
|
||||
}
|
||||
|
||||
if (!transforms.length) {
|
||||
console.log(color.yellow("未选择任何 gltf-transform 操作,已中止"));
|
||||
return { ok: false, file, reason: "no-transform" };
|
||||
}
|
||||
|
||||
if (output.backup) {
|
||||
const backupName = `${output.baseName}${BACKUP_SUFFIX}${output.sourceExt}`;
|
||||
const backupPath = path.join(output.cwd, backupName);
|
||||
fs.copyFileSync(output.sourcePath, backupPath);
|
||||
}
|
||||
|
||||
let inputPath = output.sourcePath;
|
||||
|
||||
// 如果是 OBJ/FBX,先转换为临时 GLB
|
||||
if (output.needsConversion) {
|
||||
const tempPath = path.join(output.cwd, `${output.baseName}_temp.glb`);
|
||||
console.log(color.dim("转换中: " + file + " → GLB"));
|
||||
await convert(output.sourcePath, tempPath, { binary: true });
|
||||
inputPath = tempPath;
|
||||
}
|
||||
|
||||
const document = io.read(inputPath);
|
||||
await document.transform(...transforms);
|
||||
io.write(output.targetPath, document);
|
||||
|
||||
// 清理临时文件
|
||||
if (output.needsConversion) {
|
||||
fs.rmSync(inputPath, { force: true });
|
||||
}
|
||||
|
||||
if (output.mode === "overwrite" && output.targetPath !== output.sourcePath) {
|
||||
fs.rmSync(output.sourcePath, { force: true });
|
||||
}
|
||||
|
||||
if (output.mode === "overwrite" && output.targetPath !== output.sourcePath) {
|
||||
console.log(color.dim("已生成新文件: " + path.basename(output.targetPath)));
|
||||
}
|
||||
|
||||
return { ok: true, file, output: output.targetPath };
|
||||
}
|
||||
|
||||
export async function run() {
|
||||
const result = await runInteractive();
|
||||
if (!result) return "back";
|
||||
|
||||
stopKeypress();
|
||||
|
||||
const { results } = result;
|
||||
const config = {
|
||||
files: results[0] || [],
|
||||
commands: results[1] || [],
|
||||
quantizePreset: results[2] || "balanced",
|
||||
outputFormat: results[3] || "auto",
|
||||
outputOptions: results[4] || []
|
||||
};
|
||||
|
||||
showSummary([
|
||||
"模型文件: " + (config.files.length ? config.files.join(", ") : "未选择"),
|
||||
"gltf-transform 命令: " + (config.commands.length ? config.commands.join(", ") : "无"),
|
||||
"量化级别: " + config.quantizePreset,
|
||||
"输出格式: " + config.outputFormat,
|
||||
"输出选项: " + (config.outputOptions.length ? config.outputOptions.join(", ") : "默认")
|
||||
]);
|
||||
|
||||
if (!config.files.length) {
|
||||
console.log(color.yellow("未选择任何模型文件"));
|
||||
await waitForKey();
|
||||
return "back";
|
||||
}
|
||||
|
||||
const transforms = buildTransforms(config);
|
||||
if (!transforms.length) {
|
||||
console.log(color.yellow("未配置任何压缩命令,请至少选择一项 gltf-transform 操作"));
|
||||
await waitForKey();
|
||||
return "back";
|
||||
}
|
||||
|
||||
const total = config.files.length;
|
||||
let success = 0;
|
||||
const failed = [];
|
||||
|
||||
console.log(color.cyan(`开始压缩 ${total} 个文件...\n`));
|
||||
|
||||
for (let i = 0; i < config.files.length; i++) {
|
||||
const file = config.files[i];
|
||||
const progress = `[${i + 1}/${total}]`;
|
||||
console.log(color.dim(`${progress} 处理中: ${file}`));
|
||||
|
||||
try {
|
||||
const result = await processFile(file, config, transforms);
|
||||
if (result.ok) {
|
||||
success++;
|
||||
console.log(color.green(`${progress} ✓ ${file} → ${path.basename(result.output)}`));
|
||||
} else {
|
||||
failed.push(file);
|
||||
console.log(color.yellow(`${progress} ⊘ 跳过: ${file}`));
|
||||
}
|
||||
} catch (err) {
|
||||
failed.push(file);
|
||||
console.log(color.red(`${progress} ✖ 失败: ${file}`));
|
||||
console.log(color.dim(" " + String(err?.message || err)));
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n" + color.bgGreen(color.black(" 压缩完成 ")));
|
||||
if (success) {
|
||||
console.log(color.green(`成功: ${success} 个`));
|
||||
}
|
||||
if (failed.length) {
|
||||
console.log(color.yellow(`失败: ${failed.length} 个 (${failed.join(", ")})`));
|
||||
}
|
||||
|
||||
await waitForKey();
|
||||
return "back";
|
||||
}
|
||||
9
lib/model/ui.js
Normal file
9
lib/model/ui.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { createStepUI } from "../utils/stepui.js";
|
||||
import { getSteps } from "./config.js";
|
||||
|
||||
const ui = createStepUI({
|
||||
title: "模型压缩工具",
|
||||
getSteps
|
||||
});
|
||||
|
||||
export const { runInteractive, showSummary } = ui;
|
||||
@ -28,4 +28,4 @@ export function showPoem(lines, perLine = 2) {
|
||||
}
|
||||
|
||||
// 默认古诗
|
||||
export const defaultPoem = ["你我皆牛马", "生在人世间", "终日奔波苦", "一刻不得闲"];
|
||||
export const defaultPoem = ["你我皆牛马1", "生在人世间", "终日奔波苦", "一刻不得闲"];
|
||||
|
||||
35
lib/stats.js
Normal file
35
lib/stats.js
Normal file
@ -0,0 +1,35 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const STATS_FILE = path.join(__dirname, "../.stats.json");
|
||||
|
||||
let data = null;
|
||||
|
||||
function load() {
|
||||
if (data) return data;
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(STATS_FILE, "utf8"));
|
||||
} catch {
|
||||
data = {};
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function save() {
|
||||
fs.writeFileSync(STATS_FILE, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
export function record(category, key) {
|
||||
load();
|
||||
if (!data[category]) data[category] = {};
|
||||
data[category][key] = (data[category][key] || 0) + 1;
|
||||
save();
|
||||
}
|
||||
|
||||
export function sortByUsage(category, items, getKey = v => v) {
|
||||
load();
|
||||
const counts = data[category] || {};
|
||||
return [...items].sort((a, b) => (counts[getKey(b)] || 0) - (counts[getKey(a)] || 0));
|
||||
}
|
||||
82
lib/utils/gltf.js
Normal file
82
lib/utils/gltf.js
Normal file
@ -0,0 +1,82 @@
|
||||
import fs from "fs";
|
||||
|
||||
export const BACKUP_SUFFIX = "_备份";
|
||||
|
||||
// 检查当前目录是否有 gltf 文件
|
||||
export function hasGltfFile() {
|
||||
return listGltfFiles().length > 0;
|
||||
}
|
||||
|
||||
// 获取当前目录下可用的 gltf 文件列表
|
||||
export function listGltfFiles() {
|
||||
const cwd = process.cwd();
|
||||
return fs.readdirSync(cwd).filter(f => f.toLowerCase().endsWith(".gltf") && !f.includes(BACKUP_SUFFIX));
|
||||
}
|
||||
|
||||
// 获取 glTF / GLB 模型文件
|
||||
export function listModelFiles() {
|
||||
const cwd = process.cwd();
|
||||
return fs
|
||||
.readdirSync(cwd)
|
||||
.filter(file => /\.(gltf|glb)$/i.test(file) && !file.includes(BACKUP_SUFFIX));
|
||||
}
|
||||
|
||||
// 获取所有支持的模型文件(包括需要转换的格式)
|
||||
export function listAllModelFiles() {
|
||||
const cwd = process.cwd();
|
||||
return fs
|
||||
.readdirSync(cwd)
|
||||
.filter(file => /\.(gltf|glb|obj|fbx)$/i.test(file) && !file.includes(BACKUP_SUFFIX));
|
||||
}
|
||||
|
||||
// 判断是否需要先转换
|
||||
export function needsConversion(file) {
|
||||
return /\.(obj|fbx)$/i.test(file);
|
||||
}
|
||||
|
||||
// 检查是否满足执行条件(有 ktx2、gltf、bin 文件)
|
||||
export function checkRequiredFiles() {
|
||||
const cwd = process.cwd();
|
||||
const files = fs.readdirSync(cwd);
|
||||
const hasKtx2 = files.some(f => f.toLowerCase().endsWith(".ktx2"));
|
||||
const hasGltf = files.some(f => f.toLowerCase().endsWith(".gltf"));
|
||||
const hasBin = files.some(f => f.toLowerCase().endsWith(".bin"));
|
||||
|
||||
const missing = [];
|
||||
if (!hasKtx2) missing.push("ktx2");
|
||||
if (!hasGltf) missing.push("gltf");
|
||||
if (!hasBin) missing.push("bin");
|
||||
|
||||
return { ok: missing.length === 0, missing };
|
||||
}
|
||||
|
||||
// 修改 gltf 文件,根据选项添加扩展
|
||||
export function modifyGltfContent(gltfPath, options = ["textureBasisu"]) {
|
||||
const content = fs.readFileSync(gltfPath, "utf-8");
|
||||
const gltf = JSON.parse(content);
|
||||
|
||||
if (options.includes("textureBasisu")) {
|
||||
if (!gltf.extensionsUsed) gltf.extensionsUsed = [];
|
||||
if (!gltf.extensionsUsed.includes("KHR_texture_basisu")) {
|
||||
gltf.extensionsUsed.push("KHR_texture_basisu");
|
||||
}
|
||||
|
||||
if (gltf.textures) {
|
||||
gltf.textures = gltf.textures.map(tex => ({
|
||||
extensions: { KHR_texture_basisu: { source: tex.source } }
|
||||
}));
|
||||
}
|
||||
|
||||
if (gltf.images) {
|
||||
gltf.images = gltf.images.map(img => {
|
||||
const uri = img.uri || "";
|
||||
const newUri = uri.replace(/\.(png|jpg|jpeg|webp|tga)$/i, ".ktx2");
|
||||
return { uri: newUri, mimeType: "image/ktx2" };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// placeholder1, placeholder2, placeholder3 预留扩展点
|
||||
|
||||
return gltf;
|
||||
}
|
||||
124
lib/utils/stepui.js
Normal file
124
lib/utils/stepui.js
Normal file
@ -0,0 +1,124 @@
|
||||
import color from "picocolors";
|
||||
import { initKeypress, onKey } from "../keyboard.js";
|
||||
|
||||
export function createStepUI(options) {
|
||||
const { title, getSteps } = options;
|
||||
|
||||
let steps = [];
|
||||
let results = [];
|
||||
let completed = new Set();
|
||||
let currentStep = 0;
|
||||
let currentOption = 0;
|
||||
|
||||
function initResults() {
|
||||
steps = typeof getSteps === "function" ? getSteps() : getSteps;
|
||||
results = steps.map(s => s.type === "multiselect" ? [...(s.default || [])] : s.default);
|
||||
completed = new Set();
|
||||
currentStep = 0;
|
||||
currentOption = 0;
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
const nav = steps.map((step, i) => {
|
||||
if (completed.has(i)) return color.green("☑ " + 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");
|
||||
}
|
||||
step.options.forEach((opt, 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 (opt.hint) lines.push(" " + color.dim(opt.hint));
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function render() {
|
||||
console.clear();
|
||||
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;
|
||||
currentOption = (currentOption - 1 + optCount) % optCount;
|
||||
render();
|
||||
} else if (key.name === "down") {
|
||||
if (!optCount) return;
|
||||
currentOption = (currentOption + 1) % optCount;
|
||||
render();
|
||||
} else if (key.name === "space") {
|
||||
if (!optCount) return;
|
||||
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);
|
||||
render();
|
||||
} else if (key.name === "return") {
|
||||
if (!optCount) return;
|
||||
if (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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showSummary(lines) {
|
||||
console.clear();
|
||||
console.log(color.bgCyan(color.black(` ${title} `)));
|
||||
console.log("\n" + color.green("配置完成!当前设置:"));
|
||||
lines.forEach(line => console.log(" " + line));
|
||||
console.log();
|
||||
}
|
||||
|
||||
return { runInteractive, showSummary, initResults };
|
||||
}
|
||||
Reference in New Issue
Block a user