终端界面与交互式 TUI 脚本

系统讲解 Shell 脚本的终端界面构建:dialog 与 whiptail 的对话框组件、fzf 模糊选择、菜单与进度反馈封装,以及 CI 环境下非交互降级的设计原则。

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
fzfstdin 喂候选、stdout 出结果,可内联可预览
抽象层ui_confirm/ui_input/ui_select 隔离工具细节
CI 适配用 -t 0 与 CI 变量判断,交互一律加 timeout
实战环境变量驱动的向导,批处理模式即可自动化测试

终端界面的核心不是「画得好看」,而是给人类一个安全的参数输入通道,同时不给自动化添堵。把交互后端抽象成一层薄接口,TUI 与批处理模式共享同一套业务逻辑,脚本才真正具备生命力。界面做好之后,下一个绕不开的话题是敏感信息——向导要输 Token、要连数据库,密钥如何不落盘、不进日志,是下一篇的主题。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 任务编排与 Makefile 实战
  2. 文件监控与事件驱动流水线实战
  3. 结构化数据清洗与报表生成实战