1. 为什么脚本需要终端界面
一句话总结: 交互式界面把「记参数」变成「选选项」,让运维脚本可以被不熟悉命令行的人安全使用,但前提是界面必须是可降级的。
很多脚本的生命周期里,最终用户不是写脚本的人。一个部署脚本如果要求使用者记住 --env prod --region cn-north-1 --replicas 3 这样一长串参数,出错的概率远高于让人从菜单里选。终端界面(TUI)的价值就在这里:用最少的依赖,把参数选择、确认、进度反馈做成可视化流程。
#!/usr/bin/env bash
set -euo pipefail
# 无界面脚本:参数全靠记忆
deploy.sh --env prod --region cn-north-1 --replicas 3 --dry-run
1.1 三条设计原则
一句话总结: 界面永远是可选的增强层,非交互路径必须始终存在且功能等价。
第一条原则是可降级:检测到 stdin 不是 TTY 或设置了 CI=1,就自动切回「读参数 + 读环境变量」的纯命令行模式。第二条是不阻塞:任何交互都要有超时或默认值,否则自动化流水线会被一个等待输入的提示卡死。第三条是可测试:把界面逻辑与业务逻辑分离,业务函数不直接调用 dialog,而是通过一个抽象层。
# 判断是否处于可交互环境
is_interactive() {
[[ -t 0 && -t 1 ]] && [[ -z "${CI:-}" ]]
}
if is_interactive; then mode="tui"; else mode="batch"; fi
1.2 依赖探测与选型
一句话总结: dialog 功能最全但需要安装,whiptail 随发行版预装且 API 兼容,fzf 专注模糊选择,三者定位不同。
dialog 与 whiptail 的命令行接口几乎一致(whiptail 是 dialog 的轻量重写),所以常见做法是写一份代码、运行时探测哪个可用。fzf 则完全不同:它不画对话框,而是接管一个列表让用户模糊搜索,适合「从上千个候选中选一个」的场景。
# 探测可用工具,按优先级选择
pick_dialog() {
if command -v dialog >/dev/null 2>&1; then
echo dialog
elif command -v whiptail >/dev/null 2>&1; then
echo whiptail
else
echo "" # 都不可用,走纯文本降级
fi
}
DIALOG="$(pick_dialog)"
2. dialog 与 whiptail 的对话框组件
一句话总结: 两种工具都靠「退出码 + stderr 回传结果」通信,这是理解它们所有用法的关键。
dialog 系列最反直觉的一点:用户选中的内容是从 stderr 输出的,不是 stdout。所以 result=$(dialog ...) 拿到的是空字符串,必须写成 result=$(dialog ... 3>&1 1>&2 2>&3)。这个重定向组合把 stdout 和 stderr 对调,让结果回到命令替换里。
# 正确捕获 dialog 输出:交换 stdout 与 stderr
if choice=$(dialog --menu "请选择环境" 15 50 4 \
dev "开发环境" \
staging "预发环境" \
prod "生产环境" \
3>&1 1>&2 2>&3); then
echo "已选择: $choice"
else
echo "用户取消" >&2
exit 130
fi
2.1 常用组件速览
一句话总结: yesno 确认、inputbox 输入、menu 单选、checklist 多选、gauge 进度,覆盖了脚本交互的绝大多数需求。
# yesno:确认框,退出码 0 表示是
dialog --yesno "确定要删除所有缓存吗?" 8 40 && rm -rf ./cache
# inputbox / passwordbox:文本输入,后者隐藏回显
name=$(dialog --inputbox "请输入应用名称" 8 40 "myapp" 3>&1 1>&2 2>&3)
token=$(dialog --passwordbox "请输入 API Token" 8 40 3>&1 1>&2 2>&3)
# checklist:多选,返回「标签 状态」交替的字符串
services=$(dialog --checklist "选择要重启的服务" 15 60 4 \
nginx "Web 服务器" on \
redis "缓存" on \
mysql "数据库" off \
3>&1 1>&2 2>&3)
2.2 解析 checklist 的返回值
一句话总结: checklist 返回的是带引号的标签串,用
eval或数组转换解析,注意标签不能含空格。
# 输出形如: "nginx" "redis"
eval "selected=($services)"
for svc in "${selected[@]}"; do
systemctl restart "$svc"
done
2.3 终端尺寸与自动适配
一句话总结: dialog 的宽高参数写死会在小终端里错位,用
tput读取实际尺寸再计算。
# 读取终端尺寸并留出边距
read -r rows cols < <(stty size)
h=$(( rows > 20 ? rows - 6 : 15 ))
w=$(( cols > 60 ? cols - 10 : 50 ))
dialog --msgbox "终端尺寸 ${rows}x${cols}" "$h" "$w"
3. fzf 模糊选择
一句话总结: fzf 把「列表 + 过滤 + 选择」压缩成一行管道命令,是选择类交互的最优解。
fzf 的工作方式很符合 Unix 哲学:stdin 喂候选列表,stdout 输出选中项。它不需要在终端里「画」一个全屏界面,而是接管当前光标位置。这让它可以嵌入任何管道中间。
# 最简用法:从列表中选择
git branch --format='%(refname:short)' | fzf
# 带预览:右侧实时显示分支详情
git branch --format='%(refname:short)' \
| fzf --preview 'git log --oneline -20 {}' \
--preview-window right:50%
3.1 常用参数与交互键
一句话总结:
-m多选、--header表头、--height内联高度、--query预填,四个参数覆盖八成场景。
# 多选 + 表头 + 内联显示(不占满全屏)
selected=$(kubectl get pods -o name \
| fzf -m --height 40% --header "选择要删除的 Pod" --query "api-")
fzf --bind 'ctrl-a:select-all,ctrl-d:deselect-all'
3.2 fzf 作为通用选择器
一句话总结: 把「候选来源」和「选择后动作」分离,fzf 就成了脚本里所有选择逻辑的统一入口。
# 封装一个通用选择函数
choose_one() {
local prompt="$1"; shift
printf '%s\n' "$@" | fzf --height 40% --prompt "$prompt > "
}
host=$(choose_one "选择主机" web1 web2 db1 db2)
3.3 fzf 的降级写法
一句话总结: 无 fzf 时退化为
select内建或编号列表,保持同样的调用签名。
choose_one() {
local prompt="$1" PS3; shift
if command -v fzf >/dev/null 2>&1; then
printf '%s\n' "$@" | fzf --height 40% --prompt "$prompt > "
else
PS3="$prompt > "
select item in "$@"; do
[[ -n "$item" ]] && { echo "$item"; break; }
done
fi
}
4. 菜单与选择器的统一封装
一句话总结: 把界面后端抽象成三个函数(确认、输入、选择),业务代码就与具体工具解耦。
不要在每个业务函数里直接写 dialog。定义一层薄接口:ui_confirm、ui_input、ui_select、ui_progress。TUI 模式下它们调用 dialog/fzf,批处理模式下它们读环境变量或使用默认值。
# 界面抽象层
ui_confirm() {
local msg="$1" default="${2:-no}"
if [[ "$UI_MODE" == "tui" ]]; then
dialog --yesno "$msg" 8 50
else
[[ "$default" == "yes" ]]
fi
}
ui_input() {
local msg="$1" default="${2:-}"
if [[ "$UI_MODE" == "tui" ]]; then
dialog --inputbox "$msg" 8 50 "$default" 3>&1 1>&2 2>&3
else
echo "${!3:-$default}" # 从环境变量读取
fi
}
4.1 环境变量驱动的批处理模式
一句话总结: 非交互模式下,所有交互答案都从环境变量读取,变量名与界面提示一一对应。
# 批处理模式:APP_ENV 决定环境,REPLICAS 决定副本数
APP_ENV="${APP_ENV:-dev}"
REPLICAS="${REPLICAS:-1}"
# 校验必填项
for v in APP_ENV REPLICAS; do
[[ -n "${!v:-}" ]] || { echo "缺少环境变量 $v" >&2; exit 1; }
done
4.2 超时保护
一句话总结: 用
timeout包住任何交互命令,避免流水线因等待输入而永久挂起。
# 30 秒无响应则使用默认值
if answer=$(timeout 30 dialog --inputbox "超时用默认值" 8 50 "default" 3>&1 1>&2 2>&3); then
echo "用户输入: $answer"
else
echo "超时,使用默认值"
answer="default"
fi
5. 进度反馈与状态展示
一句话总结: 长任务必须给反馈,gauge 组件或自绘进度条二选一,关键是要让用户知道「还在跑」。
# dialog --gauge:从 stdin 读取 0-100 的进度
(
for i in $(seq 0 5 100); do
echo "$i"
sleep 0.2
done
) | dialog --gauge "正在处理..." 10 60 0
5.1 自绘进度条
一句话总结: 无 dialog 时用
\r回车覆盖同一行,配合百分号与剩余数量,效果足够。
# 纯 bash 进度条
total=50
for i in $(seq 1 "$total"); do
pct=$(( i * 100 / total ))
filled=$(( pct / 2 ))
bar=$(printf '%*s' "$filled" '' | tr ' ' '#')
printf '\r[%-50s] %3d%% (%d/%d)' "$bar" "$pct" "$i" "$total"
sleep 0.05
done
printf '\n'
5.2 阶段化输出
一句话总结: 多步骤流程用带序号的阶段标题,让日志本身就是进度报告。
step=0
stage() { step=$(( step + 1 )); printf '\n==> [%d] %s\n' "$step" "$*"; }
stage "检查依赖"; stage "拉取代码"; stage "构建镜像"; stage "部署服务"
6. 非交互环境降级与 CI 适配
一句话总结: CI 里没有 TTY,所有交互必须短路;判断依据是
-t 0与CI变量,而不是「猜」。
# 统一的环境判断
UI_MODE="batch"
if [[ -t 0 && -t 1 ]] && [[ -z "${CI:-}" ]] && [[ "${NO_TUI:-0}" != "1" ]]; then
UI_MODE="tui"
fi
export UI_MODE
6.1 常见 CI 陷阱
一句话总结: 忘记重定向会让 dialog 写坏日志,忘记超时会让流水线卡死,两者都要在入口处防住。
# dialog 在非 TTY 下会污染输出,务必重定向到 /dev/tty
[[ "$UI_MODE" == "tui" ]] && dialog --msgbox "开始部署" 8 40 >/dev/tty
export NO_TUI=1 # 在 CI 中强制关闭一切交互
6.2 日志可读性
一句话总结: 批处理模式下不要输出 ANSI 转义,颜色开关要跟着 TTY 判断走。
# 颜色开关
if [[ -t 1 ]]; then
C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_RESET=$'\033[0m'
else
C_RED=""; C_GREEN=""; C_RESET=""
fi
printf '%s成功%s\n' "$C_GREEN" "$C_RESET"
7. 实战:交互式部署向导
一句话总结: 把前面的组件串起来,就是一个既能给人类用、也能被流水线调用的部署向导。
#!/usr/bin/env bash
set -euo pipefail
UI_MODE="batch"
[[ -t 0 && -t 1 && -z "${CI:-}" ]] && UI_MODE="tui"
# ask 提示 变量名 默认值:TUI 下弹输入框,否则读环境变量
ask() {
local msg="$1" var="$2" def="${3:-}" v
if [[ "$UI_MODE" == "tui" ]]; then
v=$(dialog --inputbox "$msg" 8 60 "$def" 3>&1 1>&2 2>&3) || return 1
else
v="${!var:-$def}"
fi
printf -v "$var" '%s' "$v"
}
# pick 提示 变量名 默认 候选...:TUI 下用 fzf 选,否则读环境变量
pick() {
local msg="$1" var="$2" def="$3" v; shift 3
if [[ "$UI_MODE" == "tui" ]]; then
v=$(printf '%s\n' "$@" | fzf --height 40% --prompt "$msg > " --query "$def") || return 1
else
v="${!var:-$def}"
fi
printf -v "$var" '%s' "$v"
}
ask "应用名称" APP "myapp"
pick "选择环境" ENV "dev" dev staging prod
pick "选择区域" REGION "cn-north-1" cn-north-1 cn-south-1 ap-east-1
echo "即将部署: app=$APP env=$ENV region=$REGION"
[[ "$UI_MODE" == "tui" ]] && { dialog --yesno "确认部署?" 8 40 || exit 130; }
echo "开始部署..."
7.1 向导的可测试性
一句话总结: 用批处理模式跑同一个向导,就能在测试里断言它读环境变量、产出正确参数。
# 测试:批处理模式下从环境变量取值
APP=testapp ENV=prod REGION=cn-south-1 NO_TUI=1 ./deploy-wizard.sh
# 期望输出: 即将部署: app=testapp env=prod region=cn-south-1
7.2 状态回显与审计
一句话总结: 每次交互的结果都写进日志,事后能复盘「谁在什么时候选了什么」。
log_choice() {
printf '%s choice %s=%s\n' "$(date -Iseconds)" "$1" "$2" >> /var/log/deploy-wizard.log
}
log_choice ENV "$ENV"
log_choice REGION "$REGION"
8. 总结
| 环节 | 要点 |
|---|---|
| 设计原则 | 可降级、不阻塞、可测试,界面是增强层而非必需层 |
| 工具选型 | dialog 功能全、whiptail 预装、fzf 专攻模糊选择 |
| 输出捕获 | dialog 结果走 stderr,须交换 fd 3 与 stdout |
| 组件 | yesno、inputbox、passwordbox、menu、checklist、gauge |
| fzf | stdin 喂候选、stdout 出结果,可内联可预览 |
| 抽象层 | ui_confirm/ui_input/ui_select 隔离工具细节 |
| CI 适配 | 用 -t 0 与 CI 变量判断,交互一律加 timeout |
| 实战 | 环境变量驱动的向导,批处理模式即可自动化测试 |
终端界面的核心不是「画得好看」,而是给人类一个安全的参数输入通道,同时不给自动化添堵。把交互后端抽象成一层薄接口,TUI 与批处理模式共享同一套业务逻辑,脚本才真正具备生命力。界面做好之后,下一个绕不开的话题是敏感信息——向导要输 Token、要连数据库,密钥如何不落盘、不进日志,是下一篇的主题。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。