Files
ctms/scripts/common.sh
T
Cheng Zhou efc325568d 美化终端脚本 UI 并修复对齐与闪屏
- 配色体系:256 色语义色板(primary/accent/success/warn/danger)与统一图标常量
- 修复右边框对齐:ctms_text_width 用 perl 精确计算显示宽度,剥离 sgr0 的 \e(B 序列
- 修复菜单闪屏:改用光标归位原地覆盖重绘,去掉每帧清屏
- 美化各面板:install.sh banner/确认/成功框、根菜单、status.sh 资源表格,标签列对齐
- 新增 ctms_pad_right 按显示宽度填充含中文标签

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:24:22 +08:00

496 lines
17 KiB
Bash
Raw 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.
#!/usr/bin/env bash
# 公共脚本函数。被 install/update/uninstall/status 入口 source,不直接执行。
# 这是整个安装体系唯一的共享库:颜色、UI、环境派生、compose 调用、
# 流程输出、依赖检查、.env 读写、交互菜单全部集中在此,避免重复实现。
CTMS_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CTMS_ROOT_DIR="$(cd "$CTMS_SCRIPT_DIR/.." && pwd)"
CTMS_ENV_FILE="$CTMS_ROOT_DIR/.env"
CTMS_LOG_DIR="$CTMS_ROOT_DIR/.install-logs"
# ── 配色体系 ─────────────────────────────────
# 优先使用 256 色语义色板;终端仅支持基础 8 色时回退到 ANSI 原色;
# 无色彩能力(管道/CI)时全部置空。C_* 为兼容旧脚本保留的别名。
_ctms_color_depth=0
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then
_ctms_color_depth="$(tput colors 2>/dev/null || printf 0)"
fi
if [[ "$_ctms_color_depth" -ge 256 ]]; then
C_RESET="$(tput sgr0)"
C_BOLD="$(tput bold)"
C_DIM="$(tput dim 2>/dev/null || printf '')"
# 语义色板(xterm-256 索引)
CC_PRIMARY="$(tput setaf 39)" # 主色:明亮天蓝(边框/标题)
CC_ACCENT="$(tput setaf 213)" # 强调:粉紫(次级面板/高亮)
CC_SUCCESS="$(tput setaf 78)" # 成功:清新绿
CC_WARN="$(tput setaf 214)" # 警告:琥珀橙
CC_DANGER="$(tput setaf 203)" # 危险:珊瑚红
CC_INFO="$(tput setaf 117)" # 信息:淡青
CC_MUTED="$(tput setaf 245)" # 弱化:中灰
CC_TEXT="$(tput setaf 252)" # 正文:近白
elif [[ "$_ctms_color_depth" -ge 8 ]]; then
C_RESET="$(tput sgr0)"
C_BOLD="$(tput bold)"
C_DIM="$(tput dim 2>/dev/null || printf '')"
CC_PRIMARY="$(tput setaf 4)"
CC_ACCENT="$(tput setaf 5)"
CC_SUCCESS="$(tput setaf 2)"
CC_WARN="$(tput setaf 3)"
CC_DANGER="$(tput setaf 1)"
CC_INFO="$(tput setaf 6)"
CC_MUTED="$(tput setaf 7)"
CC_TEXT="$(tput setaf 7)"
else
C_RESET="" C_BOLD="" C_DIM=""
CC_PRIMARY="" CC_ACCENT="" CC_SUCCESS="" CC_WARN="" CC_DANGER="" CC_INFO="" CC_MUTED="" CC_TEXT=""
fi
# C_* 兼容别名(旧脚本/面板仍在引用)
C_BLUE="$CC_PRIMARY"
C_MAGENTA="$CC_ACCENT"
C_CYAN="$CC_INFO"
C_GREEN="$CC_SUCCESS"
C_YELLOW="$CC_WARN"
C_RED="$CC_DANGER"
C_WHITE="$CC_TEXT"
# ── 图标体系 ─────────────────────────────────
# 统一的状态/装饰图标,集中定义便于风格一致。
ICON_OK="✔"
ICON_FAIL="✖"
ICON_WARN="⚠"
ICON_INFO="•"
ICON_STEP="▸"
ICON_PROMPT=""
ICON_BULLET="◆"
ICON_BULLET_OFF="◇"
ICON_RUNNING="●"
ICON_STOPPED="○"
ICON_ARROW="→"
# ── 基础输出 ─────────────────────────────────
ctms_fail() {
printf '\n %s%s 出错%s %s%s%s\n' "${CC_DANGER}${C_BOLD}" "$ICON_FAIL" "${C_RESET}" "${CC_DANGER}" "$*" "${C_RESET}" >&2
exit 1
}
ctms_info() {
printf ' %s%s%s %s\n' "${CC_MUTED}" "$ICON_INFO" "${C_RESET}" "$*"
}
ctms_ok() {
printf ' %s%s%s %s\n' "${CC_SUCCESS}${C_BOLD}" "$ICON_OK" "${C_RESET}" "$*"
}
ctms_warn() {
printf ' %s%s%s %s\n' "${CC_WARN}${C_BOLD}" "$ICON_WARN" "${C_RESET}" "$*" >&2
}
# 带步骤计数的分节标题
ctms_step() {
_CTMS_STEP_IDX=$(( ${_CTMS_STEP_IDX:-0} + 1 ))
printf '\n %s%s%s %s步骤 %s%s %s%s%s\n' \
"${CC_PRIMARY}${C_BOLD}" "$ICON_STEP" "${C_RESET}" \
"${CC_MUTED}" "$_CTMS_STEP_IDX" "${C_RESET}" \
"${C_BOLD}" "$*" "${C_RESET}"
}
# 回显并执行命令
ctms_run() {
printf ' %s$%s %s%s%s\n' "${CC_MUTED}" "${C_RESET}" "${CC_TEXT}" "$*" "${C_RESET}"
"$@"
}
# ── box UI ───────────────────────────────────
ctms_repeat_char() {
local char="$1"
local count="$2"
local out=""
while [[ "$count" -gt 0 ]]; do
out="${out}${char}"
count=$(( count - 1 ))
done
printf '%s' "$out"
}
# 计算字符串的终端显示宽度(列数)。先剥离 ANSI 转义。
# 优先用 perl 精确判断:东亚宽字符/emoji 计 2 列,其余(含 ◆✔▸ 等几何符号)计 1 列。
# 无 perl 时回退到字节估算(中文准确,几何符号会高估 1 列)。
if command -v perl >/dev/null 2>&1; then _CTMS_HAS_PERL=1; else _CTMS_HAS_PERL=0; fi
ctms_text_width() {
local text="$1"
if [[ "$_CTMS_HAS_PERL" -eq 1 ]]; then
printf '%s' "$text" | perl -CSD -e '
local $/; my $s = <STDIN>;
$s =~ s/\e\[[0-9;?]*[ -\/]*[@-~]//g; # CSI 序列(颜色等)
$s =~ s/\e[()][0-9A-Za-z]//g; # 字符集切换,如 sgr0 的 \e(B
$s =~ s/\e[]PX^_].*?(\e\\|\a)//g; # OSC/DCS 等带终止符的序列
my $w = 0;
for my $c (split //, $s) {
my $o = ord $c;
next if $o == 10 || $o == 13 || $o == 27;
if (($o>=0x1100&&$o<=0x115F)||($o>=0x2E80&&$o<=0x303E)||($o>=0x3041&&$o<=0x33FF)||
($o>=0x3400&&$o<=0x4DBF)||($o>=0x4E00&&$o<=0x9FFF)||($o>=0xA000&&$o<=0xA4CF)||
($o>=0xAC00&&$o<=0xD7A3)||($o>=0xF900&&$o<=0xFAFF)||($o>=0xFE30&&$o<=0xFE4F)||
($o>=0xFF00&&$o<=0xFF60)||($o>=0xFFE0&&$o<=0xFFE6)||($o>=0x1F300&&$o<=0x1FAFF)||
($o>=0x20000&&$o<=0x3FFFD)) { $w += 2 } else { $w += 1 }
}
print $w;
'
return
fi
local esc chars bytes extra
esc="$(printf '\033')"
text="$(printf '%s' "$text" | sed -e "s/${esc}\\[[0-9;?]*[A-Za-z]//g" -e "s/${esc}[()][0-9A-Za-z]//g")"
chars="$(printf '%s' "$text" | wc -m | tr -d ' ')"
bytes="$(printf '%s' "$text" | LC_ALL=C wc -c | tr -d ' ')"
extra=$(( (bytes - chars) / 2 ))
printf '%s' "$(( chars + extra ))"
}
# 按终端显示宽度右填充字符串到指定列数(中文按 2 列计),用于对齐含中文的标签列。
ctms_pad_right() {
local text="$1" target="$2"
local w pad
w="$(ctms_text_width "$text")"
pad=$(( target - w ))
[[ "$pad" -lt 0 ]] && pad=0
printf '%s%s' "$text" "$(ctms_repeat_char ' ' "$pad")"
}
ctms_box_top() {
local color="$1"
local title="$2"
local width="$3"
local label="─ ${title} "
local label_width fill_width
label_width="$(ctms_text_width "$label")"
fill_width=$(( width - 2 - label_width ))
[[ "$fill_width" -lt 0 ]] && fill_width=0
printf '%s╭%s%s╮%s\n' "$color" "$label" "$(ctms_repeat_char '─' "$fill_width")" "$C_RESET"
}
ctms_box_bottom() {
local color="$1"
local width="$2"
printf '%s╰%s╯%s\n' "$color" "$(ctms_repeat_char '─' "$(( width - 2 ))")" "$C_RESET"
}
ctms_box_line() {
local color="$1"
local width="$2"
local plain="$3"
local styled="${4:-$3}"
local max_content_width=$(( width - 4 ))
local content=" ${plain}"
local content_width fill_width
local chunk chunk_width
local prefix=" "
content_width="$(ctms_text_width "$content")"
if [[ "$content_width" -le "$(( width - 2 ))" ]]; then
fill_width=$(( width - 2 - content_width ))
[[ "$fill_width" -lt 0 ]] && fill_width=0
printf '%s│%s %b%s%s│%s\n' "$color" "$C_RESET" "$styled" "$(ctms_repeat_char ' ' "$fill_width")" "$color" "$C_RESET"
return 0
fi
while [[ -n "$plain" ]]; do
chunk="${plain:0:$max_content_width}"
while [[ -n "$chunk" && "$(ctms_text_width " ${chunk}")" -gt "$(( width - 2 ))" ]]; do
chunk="${chunk%?}"
done
[[ -n "$chunk" ]] || chunk="${plain:0:1}"
chunk_width="$(ctms_text_width "${prefix}${chunk}")"
fill_width=$(( width - 2 - chunk_width ))
[[ "$fill_width" -lt 0 ]] && fill_width=0
printf '%s│%s%s%s%s%s│%s\n' "$color" "$C_RESET" "$prefix" "$chunk" "$(ctms_repeat_char ' ' "$fill_width")" "$color" "$C_RESET"
plain="${plain:${#chunk}}"
prefix=" "
done
}
# ── 环境校验与派生 ───────────────────────────
ctms_require_env() {
case "${1:-}" in
dev|main|release) ;;
*) ctms_fail "未知环境 \"${1:-}\",有效值为 dev、main、release" ;;
esac
}
ctms_project_name() {
ctms_require_env "$1"
case "$1" in
dev) printf 'ctms_dev' ;;
main) printf 'ctms_main' ;;
release) printf 'ctms_release' ;;
esac
}
ctms_compose_file() {
ctms_require_env "$1"
if [[ "$1" == "dev" ]]; then
printf '%s/docker-compose.dev.yaml' "$CTMS_ROOT_DIR"
else
printf '%s/docker-compose.yaml' "$CTMS_ROOT_DIR"
fi
}
# 运行模式:dev → development,其余 → production
ctms_app_env() {
ctms_require_env "$1"
[[ "$1" == "dev" ]] && printf 'development' || printf 'production'
}
# 登录 RSA 密钥 ID
ctms_key_id() {
ctms_require_env "$1"
local today
today="$(date +%Y%m%d)"
case "$1" in
dev) printf 'default' ;;
main) printf 'main-%s' "$today" ;;
release) printf 'release-%s' "$today" ;;
esac
}
# 默认健康检查地址;release 无默认值(必须显式提供 HTTPS)
ctms_default_base_url() {
ctms_require_env "$1"
case "$1" in
dev|main) printf 'http://127.0.0.1:8888' ;;
release) printf '' ;;
esac
}
# 统一的 compose 调用:始终显式 -f <file> -p <project>,确保环境隔离
# 用法: ctms_compose <env> <compose 参数...>
ctms_compose() {
local env="$1"; shift
docker compose -f "$(ctms_compose_file "$env")" -p "$(ctms_project_name "$env")" "$@"
}
# ── 依赖检查 ─────────────────────────────────
ctms_require_command() {
local cmd="$1"
local hint="$2"
if ! command -v "$cmd" >/dev/null 2>&1; then
ctms_warn "缺少依赖: ${C_BOLD}${cmd}${C_RESET}"
ctms_warn "安装方式: $hint"
return 1
fi
}
ctms_require_docker_compose() {
local missing=0
ctms_require_command docker "安装 Docker Engine 或 Docker Desktop,并确认 docker 命令可用" || missing=1
[[ "$missing" -eq 0 ]] || ctms_fail "系统依赖不完整,请按上方提示安装后重试"
if ! docker compose version >/dev/null 2>&1; then
ctms_fail "未检测到 Docker Compose v2,请确认 docker compose version 可正常执行"
fi
}
# 安装/更新所需依赖。openssl、curl 仅在非 dev 环境要求(dev 用默认密钥、不做 RSA 生成)。
ctms_require_dependencies() {
local env="$1"
ctms_require_env "$env"
local missing=0
ctms_require_command docker "安装 Docker Engine 或 Docker Desktop,并确认 docker 命令可用" || missing=1
ctms_require_command curl "Linux: sudo apt-get install -y curl | macOS: brew install curl" || missing=1
# openssl 仅非 dev 需要:dev 用 dev-secret 与空 RSA key,不生成密钥
if [[ "$env" != "dev" ]]; then
ctms_require_command openssl "Linux: sudo apt-get install -y openssl | macOS: brew install openssl" || missing=1
fi
[[ "$missing" -eq 0 ]] || ctms_fail "系统依赖不完整,请按上方提示安装后重试"
if ! docker compose version >/dev/null 2>&1; then
ctms_fail "未检测到 Docker Compose v2,请确认 docker compose version 可正常执行"
fi
}
# ── 长命令执行(折叠输出 + spinner) ──────────
# 用法: ctms_run_quiet <提示语> -- <命令...>
# VERBOSE=1 时展开实时输出;否则折叠为 spinner,失败时回放完整日志。
ctms_run_quiet() {
local label="$1"; shift
[[ "${1:-}" == "--" ]] && shift
if [[ "${VERBOSE:-0}" -eq 1 ]]; then
printf ' %s$%s %s%s%s\n' "${CC_MUTED}" "${C_RESET}" "${CC_TEXT}" "$*" "${C_RESET}"
"$@"
return $?
fi
mkdir -p "$CTMS_LOG_DIR"
local log_file
log_file="$(mktemp "$CTMS_LOG_DIR/cmd_XXXXXXXX")"
local start_ts elapsed exit_code
start_ts="$(date +%s)"
set +e
if [[ -t 1 ]]; then
"$@" >"$log_file" 2>&1 &
local cmd_pid=$!
_ctms_spinner "$cmd_pid" "$label" "$start_ts" &
local spin_pid=$!
wait "$cmd_pid"
exit_code=$?
kill "$spin_pid" 2>/dev/null || true
wait "$spin_pid" 2>/dev/null || true
printf '\r\033[K'
else
printf ' %s%s%s %s …\n' "${CC_MUTED}" "$ICON_INFO" "${C_RESET}" "$label"
"$@" >"$log_file" 2>&1
exit_code=$?
fi
set -e
elapsed=$(( $(date +%s) - start_ts ))
if [[ "$exit_code" -eq 0 ]]; then
printf ' %s%s%s %s %s(%ss)%s\n' \
"${CC_SUCCESS}${C_BOLD}" "$ICON_OK" "${C_RESET}" "$label" "${CC_MUTED}" "$elapsed" "${C_RESET}"
return 0
fi
printf '\n %s%s%s %s%s 失败(耗时 %ss),完整输出:%s\n\n' \
"${CC_DANGER}${C_BOLD}" "$ICON_FAIL" "${C_RESET}" "${CC_DANGER}" "$label" "$elapsed" "${C_RESET}" >&2
sed 's/^/ /' "$log_file" >&2
printf '\n' >&2
ctms_fail "命令执行失败,详细日志保留于:${log_file#"$CTMS_ROOT_DIR"/}(追加 --verbose 可看实时输出)"
}
_ctms_spinner() {
local cmd_pid="$1" label="$2" start_ts="$3"
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
local i=0
while kill -0 "$cmd_pid" 2>/dev/null; do
local elapsed=$(( $(date +%s) - start_ts ))
printf '\r\033[K %s%s%s %s %s(%ss)%s' \
"${CC_INFO}${C_BOLD}" "${frames[i]}" "${C_RESET}" \
"$label" "${CC_MUTED}" "$elapsed" "${C_RESET}"
i=$(( (i + 1) % ${#frames[@]} ))
sleep 0.1
done
}
# ── .env 读写 ─────────────────────────────────
ctms_read_env_value() {
local key="$1"
[[ -f "$CTMS_ENV_FILE" ]] || return 0
awk -v key="$key" '
BEGIN { prefix = key "=" }
index($0, prefix) == 1 {
value = substr($0, length(prefix) + 1)
if (value ~ /^".*"$/ || value ~ /^\047.*\047$/) {
value = substr(value, 2, length(value) - 2)
}
print value
exit
}
' "$CTMS_ENV_FILE"
}
ctms_upsert_env_value() {
local key="$1" value="$2"
local temp_file
temp_file="$(mktemp "$CTMS_ROOT_DIR/.env.tmp.XXXXXX")"
if [[ -f "$CTMS_ENV_FILE" && -n "$(ctms_read_env_value "$key" || true)" ]]; then
awk -v key="$key" -v value="$value" '
BEGIN { prefix = key "=" }
index($0, prefix) == 1 { print key "=" value; next }
{ print }
' "$CTMS_ENV_FILE" > "$temp_file"
else
[[ -f "$CTMS_ENV_FILE" ]] && cat "$CTMS_ENV_FILE" > "$temp_file"
printf '%s=%s\n' "$key" "$value" >> "$temp_file"
fi
mv "$temp_file" "$CTMS_ENV_FILE"
}
ctms_write_env_file() {
local project_name="$1" runtime_env="$2" login_key_id="$3"
local jwt_secret="$4" rsa_private_key="$5"
local allow_insecure_dev_login="false"
[[ "$runtime_env" == "development" ]] && allow_insecure_dev_login="true"
local temp_file
temp_file="$(mktemp "$CTMS_ROOT_DIR/.env.tmp.XXXXXX")"
{
printf 'COMPOSE_PROJECT_NAME=%s\n' "$project_name"
printf 'ENV=%s\n' "$runtime_env"
printf 'VITE_RUNTIME_ENV=%s\n' "$runtime_env"
printf 'VITE_ALLOW_INSECURE_DEV_LOGIN=%s\n' "$allow_insecure_dev_login"
printf 'JWT_SECRET_KEY=%s\n' "$jwt_secret"
printf 'LOGIN_RSA_KEY_ID=%s\n' "$login_key_id"
printf 'LOGIN_RSA_PRIVATE_KEY=%s\n' "$rsa_private_key"
} >> "$temp_file"
mv "$temp_file" "$CTMS_ENV_FILE"
}
# ── 危险操作确认 ─────────────────────────────
ctms_confirm_danger() {
local operation="$1"
local scope="$2"
local risk="$3"
local answer=""
local width=72
printf '\n'
ctms_box_top "${CC_DANGER}${C_BOLD}" "$ICON_WARN 危险操作确认" "$width"
ctms_box_line "${CC_DANGER}${C_BOLD}" "$width" "操作类型 $operation" "${CC_MUTED}操作类型${C_RESET} $operation"
ctms_box_line "${CC_DANGER}${C_BOLD}" "$width" "影响范围 $scope" "${CC_MUTED}影响范围${C_RESET} $scope"
ctms_box_line "${CC_DANGER}${C_BOLD}" "$width" "风险评估 $risk" "${CC_MUTED}风险评估${C_RESET} $risk"
ctms_box_bottom "${CC_DANGER}${C_BOLD}" "$width"
printf '\n %s%s%s 输入 %s是%s / %s确认%s / %s继续%s 以执行,其它任意键取消: ' \
"${CC_WARN}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" \
"${C_BOLD}" "${C_RESET}" "${C_BOLD}" "${C_RESET}" "${C_BOLD}" "${C_RESET}"
IFS= read -r answer || answer=""
case "$answer" in
|确认|继续) ;;
*) ctms_fail "用户取消操作" ;;
esac
}
# ── 交互输入 ─────────────────────────────────
ctms_restore_cursor() {
printf '\033[?25h'
}
ctms_read_key() {
local key=""
IFS= read -rsn1 key || true
if [[ "$key" == $'\x1b' ]]; then
key=""
IFS= read -rsn2 -t 1 key || true
printf '%s' "$key"
return
fi
printf '%s' "$key"
}
# 单行文本输入,带可选默认值
ctms_ask_line() {
local prompt="$1"
local default_value="${2:-}"
local value=""
if [[ -n "$default_value" ]]; then
printf '\n%s%s%s %s %s[%s]%s: ' "${CC_INFO}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "$prompt" "${CC_MUTED}" "$default_value" "${C_RESET}"
else
printf '\n%s%s%s %s: ' "${CC_INFO}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "$prompt"
fi
IFS= read -r value || value=""
printf '%s' "${value:-$default_value}"
}