美化终端脚本 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>
This commit is contained in:
Cheng Zhou
2026-06-23 15:24:22 +08:00
parent c351ca59c4
commit efc325568d
10 changed files with 1101 additions and 310 deletions
+468
View File
@@ -0,0 +1,468 @@
#!/usr/bin/env bash
set -euo pipefail
# ─────────────────────────────────────────────
# CTMS 一键安装脚本
# 复用 scripts/common.sh 的共享库(颜色、UI、compose、依赖、.env)。
# ─────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/common.sh
source "$SCRIPT_DIR/common.sh"
TARGET_ENV=""
BASE_URL=""
ASSUME_YES=0
SKIP_BUILD=0
SKIP_MIGRATE=0
VERBOSE=0
ROOT_DIR="$CTMS_ROOT_DIR"
ENV_FILE="$CTMS_ENV_FILE"
# ── Banner ───────────────────────────────────
_banner() {
local b="${CC_PRIMARY}${C_BOLD}" # 边框主色
local a="${CC_INFO}" # 字形填充色
local r="${C_RESET}"
printf '\n'
printf '%s╔══════════════════════════════════════════════╗%s\n' "$b" "$r"
printf '%s║%s ██████╗████████╗███╗ ███╗███████╗ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s ██╔════╝╚══██╔══╝████╗ ████║██╔════╝ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s ██║ ██║ ██╔████╔██║███████╗ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s ██║ ██║ ██║╚██╔╝██║╚════██║ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s ╚██████╗ ██║ ██║ ╚═╝ ██║███████║ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝ %s║%s\n' "$b" "$a" "${CC_PRIMARY}" "$r"
printf '%s║%s 临床试验管理系统 · 安装部署程序 %s║%s\n' "$b" "${CC_MUTED}" "${CC_PRIMARY}" "$r"
printf '%s╚══════════════════════════════════════════════╝%s\n' "$b" "$r"
printf '\n'
}
# ── 简短别名(统一走 common 的 ctms_*) ────────
log() { ctms_info "$@"; }
ok() { ctms_ok "$@"; }
warn() { ctms_warn "$@"; }
fail() { ctms_fail "$@"; }
step() { ctms_step "$@"; }
# 当前环境的 compose 调用
compose_cmd() {
ctms_compose "$TARGET_ENV" "$@"
}
# ── 帮助 ─────────────────────────────────────
usage() {
cat <<EOF
${C_BOLD}用法:${C_RESET}
bash scripts/install.sh ${C_CYAN}<环境>${C_RESET} [选项]
${C_BOLD}环境:${C_RESET}
${C_CYAN}dev${C_RESET} 本地开发环境(使用默认密钥,不生成 RSA 密钥对)
前端通过 Vite dev server 热加载,入口仍为 http://127.0.0.1:8888
${C_CYAN}main${C_RESET} 内网测试 / 预发布环境
${C_CYAN}release${C_RESET} 生产环境(强制 HTTPS,自动生成密钥对)
${C_BOLD}选项:${C_RESET}
${C_WHITE}--base-url <url>${C_RESET} 健康检查使用的访问地址
dev/main 默认 http://127.0.0.1:8888
release 必须通过此参数或交互输入提供
${C_WHITE}--yes${C_RESET} 跳过所有交互确认,适合 CI/CD 自动化执行
${C_WHITE}--skip-build${C_RESET} 跳过镜像构建,仍会启动容器(docker compose up -d
${C_WHITE}--skip-migrate${C_RESET} 跳过数据库迁移(alembic upgrade head
${C_WHITE}--verbose${C_RESET} 展开所有命令的实时输出(默认折叠为 spinner)
${C_WHITE}-h, --help${C_RESET} 显示此帮助信息
${C_BOLD}示例:${C_RESET}
bash scripts/install.sh dev
bash scripts/install.sh main --base-url http://127.0.0.1:8888
bash scripts/install.sh release --base-url https://ctms.example.com --yes
EOF
}
# ── 参数解析 ─────────────────────────────────
parse_args() {
if [[ $# -eq 0 ]]; then
usage
exit 1
fi
TARGET_ENV="$1"
shift
case "$TARGET_ENV" in
dev|main|release) ;;
-h|--help) usage; exit 0 ;;
*)
usage
fail "未知环境 \"$TARGET_ENV\",有效值为 dev、main 或 release"
;;
esac
while [[ $# -gt 0 ]]; do
case "$1" in
--base-url)
[[ $# -ge 2 ]] || fail "--base-url 需要提供一个 URL 参数"
BASE_URL="$2"
shift 2
;;
--yes) ASSUME_YES=1; shift ;;
--skip-build) SKIP_BUILD=1; shift ;;
--skip-migrate) SKIP_MIGRATE=1; shift ;;
--verbose) VERBOSE=1; shift ;;
-h|--help) usage; exit 0 ;;
*)
usage
fail "未知参数: $1"
;;
esac
done
}
# ── 依赖检查 ─────────────────────────────────
check_dependencies() {
step "检查系统依赖"
ctms_require_dependencies "$TARGET_ENV"
ok "系统依赖检查通过"
}
# ── 环境参数计算 ──────────────────────────────
compose_project_name() { ctms_project_name "$TARGET_ENV"; }
app_env() { ctms_app_env "$TARGET_ENV"; }
key_id() { ctms_key_id "$TARGET_ENV"; }
default_base_url() { ctms_default_base_url "$TARGET_ENV"; }
# ── .env 读写(复用 common) ──────────────────
read_env_value() { ctms_read_env_value "$@"; }
resolve_effective_config() {
local desired_project_name="$1"
local desired_runtime_env="$2"
local desired_login_key_id="$3"
EFFECTIVE_PROJECT_NAME="$desired_project_name"
EFFECTIVE_RUNTIME_ENV="$desired_runtime_env"
EFFECTIVE_LOGIN_KEY_ID="$desired_login_key_id"
[[ -f "$ENV_FILE" ]] || return 0
local env_project_name env_runtime_env env_login_key_id
env_project_name="$(read_env_value COMPOSE_PROJECT_NAME || true)"
env_runtime_env="$(read_env_value ENV || true)"
env_login_key_id="$(read_env_value LOGIN_RSA_KEY_ID || true)"
[[ -n "$env_project_name" ]] || fail ".env 缺少 COMPOSE_PROJECT_NAME,请补齐后重试"
[[ -n "$env_runtime_env" ]] || fail ".env 缺少 ENV,请补齐后重试"
[[ -n "$env_login_key_id" ]] || fail ".env 缺少 LOGIN_RSA_KEY_ID,请补齐后重试"
if [[ "$env_project_name" != "$desired_project_name" ]]; then
fail ".env 的 COMPOSE_PROJECT_NAME=$env_project_name 与目标环境 $TARGET_ENV 期望值 $desired_project_name 不一致"
fi
if [[ "$env_runtime_env" != "$desired_runtime_env" ]]; then
fail ".env 的 ENV=$env_runtime_env 与目标环境 $TARGET_ENV 期望值 $desired_runtime_env 不一致"
fi
EFFECTIVE_PROJECT_NAME="$env_project_name"
EFFECTIVE_RUNTIME_ENV="$env_runtime_env"
EFFECTIVE_LOGIN_KEY_ID="$env_login_key_id"
}
# 生成单行转义 PEM:行间用 \n 连接,末尾不追加多余 \n
generate_escaped_private_key() {
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 2>/dev/null \
| awk 'NR > 1 { printf "\\n" } { printf "%s", $0 }'
}
generate_jwt_secret() {
openssl rand -hex 32
}
# ── 地址解析 ─────────────────────────────────
resolve_base_url() {
[[ -z "$BASE_URL" ]] && BASE_URL="$(default_base_url)"
if [[ "$TARGET_ENV" == "release" && -z "$BASE_URL" && "$ASSUME_YES" -eq 0 ]]; then
printf ' %s▸%s 请输入 release 健康检查域名(例如 https://ctms.example.com: ' \
"${C_YELLOW}${C_BOLD}" "${C_RESET}"
read -r BASE_URL || true
fi
[[ -n "$BASE_URL" ]] || fail "release 环境必须通过 --base-url 或交互输入提供 HTTPS 地址"
if [[ "$TARGET_ENV" == "release" && "$BASE_URL" != https://* ]]; then
fail "release 环境的访问地址必须使用 HTTPS"
fi
}
# ── 安装确认 ─────────────────────────────────
confirm_install() {
local project_name="$1"
local runtime_env="$2"
step "确认安装参数"
local env_status build_status migrate_status pg_status
env_status="$([[ -f "$ENV_FILE" ]] && printf '已存在' || printf '新建')"
build_status="$([[ "$SKIP_BUILD" -eq 1 ]] && printf '跳过' || printf '执行')"
migrate_status="$([[ "$SKIP_MIGRATE" -eq 1 ]] && printf '跳过' || printf '执行')"
pg_status="$([[ -d "$ROOT_DIR/pg_data" ]] && printf '已存在' || printf '新建')"
local w=66
local row
row() {
local label="$1" value="$2" vcolor="${3:-$CC_TEXT}"
local padded
padded="$(ctms_pad_right "$label" 14)"
ctms_box_line "${CC_PRIMARY}${C_BOLD}" "$w" \
"${padded}${value}" \
"${CC_MUTED}${padded}${C_RESET}${vcolor}${value}${C_RESET}"
}
printf '\n'
ctms_box_top "${CC_PRIMARY}${C_BOLD}" "安装参数预览" "$w"
row "目标环境" "$TARGET_ENV" "${CC_INFO}${C_BOLD}"
row "运行模式" "$runtime_env"
row "Compose 项目" "$project_name"
row "健康检查地址" "$BASE_URL" "${CC_INFO}"
row ".env 文件" "$env_status"
row "pg_data 目录" "$pg_status"
row "镜像构建" "$build_status"
row "数据库迁移" "$migrate_status"
ctms_box_bottom "${CC_PRIMARY}${C_BOLD}" "$w"
if [[ "$ASSUME_YES" -eq 1 ]]; then
log "已启用 --yes,跳过确认"
return
fi
printf '\n %s%s%s 确认以上参数并继续安装?输入 %syes%s 继续: ' \
"${CC_WARN}${C_BOLD}" "$ICON_PROMPT" "${C_RESET}" "${C_BOLD}" "${C_RESET}"
local answer
read -r answer || answer=""
[[ "$answer" == "yes" ]] || fail "用户取消安装"
}
# ── .env 准备 ─────────────────────────────────
sync_frontend_build_env() {
local runtime_env="$1"
local allow_insecure_dev_login="false"
[[ "$runtime_env" == "development" ]] && allow_insecure_dev_login="true"
ctms_upsert_env_value VITE_RUNTIME_ENV "$runtime_env"
ctms_upsert_env_value VITE_ALLOW_INSECURE_DEV_LOGIN "$allow_insecure_dev_login"
ok "前端构建环境变量已同步"
}
prepare_env_file() {
step "准备环境配置文件"
local project_name="$1" runtime_env="$2" login_key_id="$3"
local jwt_secret="" rsa_private_key=""
if [[ -f "$ENV_FILE" ]]; then
ok "检测到现有 .env,保留原文件并跳过写入"
return
fi
if [[ "$TARGET_ENV" == "dev" ]]; then
jwt_secret="dev-secret"
rsa_private_key=""
else
jwt_secret="$(generate_jwt_secret)"
ok "已生成新的 JWT_SECRET_KEY"
log "正在生成 RSA 2048 密钥对,请稍候…"
rsa_private_key="$(generate_escaped_private_key)"
ok "已生成新的 LOGIN_RSA_PRIVATE_KEY"
fi
ctms_write_env_file "$project_name" "$runtime_env" "$login_key_id" "$jwt_secret" "$rsa_private_key"
ok ".env 写入完成"
}
# ── Docker 流程 ───────────────────────────────
run_compose_config() {
step "校验 Docker Compose 配置"
ctms_run_quiet "Docker Compose 配置语法校验" -- compose_cmd config
}
run_backend_init() {
step "初始化数据库与管理员账号"
ctms_run_quiet "运行 backend-init 容器" -- compose_cmd run --rm backend-init
}
run_build_and_start() {
if [[ "$SKIP_BUILD" -eq 1 ]]; then
step "启动服务(跳过镜像构建)"
if [[ "$TARGET_ENV" == "dev" ]]; then
ctms_run_quiet "启动开发容器并刷新后端/Nginx/Vite" -- compose_cmd up -d --force-recreate backend nginx frontend-dev
else
ctms_run_quiet "启动容器" -- compose_cmd up -d
fi
else
step "构建镜像并启动服务"
if [[ "$TARGET_ENV" == "dev" ]]; then
ctms_run_quiet "构建并刷新开发容器(首次较慢)" -- compose_cmd up -d --build --force-recreate backend nginx frontend-dev
else
ctms_run_quiet "构建镜像并启动容器(首次较慢)" -- compose_cmd up -d --build
fi
fi
}
run_migrations() {
if [[ "$SKIP_MIGRATE" -eq 1 ]]; then
log "已跳过数据库迁移(--skip-migrate"
return
fi
step "执行数据库迁移"
ctms_run_quiet "Alembic upgrade head" -- compose_cmd run --rm backend python -m alembic upgrade head
}
# ── 健康检查 ─────────────────────────────────
check_container_status() {
step "检查容器运行状态"
local running
running="$(compose_cmd ps --services --filter status=running 2>/dev/null || true)"
local services=(db backend nginx)
[[ "$TARGET_ENV" != "dev" ]] || services+=(frontend-dev)
local svc
for svc in "${services[@]}"; do
if printf '%s\n' "$running" | grep -qx "$svc"; then
printf ' %s%s%s %-12s %s运行中%s\n' "${CC_SUCCESS}${C_BOLD}" "$ICON_RUNNING" "${C_RESET}" "$svc" "${CC_SUCCESS}" "${C_RESET}"
else
printf ' %s%s%s %-12s %s未运行%s\n' "${CC_DANGER}${C_BOLD}" "$ICON_STOPPED" "${C_RESET}" "$svc" "${CC_DANGER}" "${C_RESET}"
fail "$svc 容器未正常运行,请执行 'bash scripts/install.sh $TARGET_ENV --skip-build --skip-migrate --verbose' 或对应 compose ps 命令排查"
fi
done
}
check_backend_environment() {
step "校验后端运行时环境变量"
local expected_env="$1" expected_key_id="$2" expect_rsa="$3"
local check_code
check_code=$(cat <<'PY'
import os, sys
from app.core.config import settings
from app.core.login_crypto import _normalize_pem
expected_env = os.environ["EXPECTED_ENV"]
expected_key_id = os.environ["EXPECTED_KEY_ID"]
expect_rsa = os.environ["EXPECT_RSA"] == "1"
if settings.ENV != expected_env:
sys.exit(f"ENV 不匹配: {settings.ENV} != {expected_env}")
if settings.LOGIN_RSA_KEY_ID != expected_key_id:
sys.exit("LOGIN_RSA_KEY_ID 不匹配")
if expected_env == "production" and settings.JWT_SECRET_KEY == "dev-secret":
sys.exit("生产环境不允许使用默认 JWT_SECRET_KEY")
if expect_rsa:
if not settings.LOGIN_RSA_PRIVATE_KEY:
sys.exit("LOGIN_RSA_PRIVATE_KEY 未配置")
from cryptography.hazmat.primitives import serialization
serialization.load_pem_private_key(
_normalize_pem(settings.LOGIN_RSA_PRIVATE_KEY).encode("utf-8"),
password=None,
)
PY
)
ctms_run_quiet "执行后端配置自检" -- compose_cmd exec -T \
-e EXPECTED_ENV="$expected_env" \
-e EXPECTED_KEY_ID="$expected_key_id" \
-e EXPECT_RSA="$expect_rsa" \
backend python -c "$check_code"
}
check_http_endpoint() {
local path="$1"
local max_attempts="${2:-10}"
local url="${BASE_URL%/}$path"
log "探测接口: $url"
local attempt=1 delay=2 warmup_delay=3
log "等待服务预热 ${warmup_delay}s …"
sleep "$warmup_delay"
while [[ "$attempt" -le "$max_attempts" ]]; do
if curl -fsS "$url" >/dev/null 2>&1; then
ok "接口就绪: $path"
return 0
fi
if [[ "$attempt" -lt "$max_attempts" ]]; then
log "接口尚未就绪,${delay}s 后重试(${attempt}/${max_attempts})…"
sleep "$delay"
fi
attempt=$(( attempt + 1 ))
done
if [[ "$path" == "/api/v1/auth/login-key" ]]; then
warn "登录公钥接口无响应,请检查 .env 中 LOGIN_RSA_PRIVATE_KEY 是否为完整的单行转义 PEM 文本"
fi
fail "接口探测失败: $url"
}
check_dev_nginx_mode() {
[[ "$TARGET_ENV" == "dev" ]] || return 0
step "校验开发 Nginx 代理配置"
if compose_cmd exec -T nginx sh -c 'grep -q "frontend_dev" /etc/nginx/nginx.conf && grep -q "proxy_pass http://frontend_dev" /etc/nginx/nginx.conf' >/dev/null 2>&1; then
ok "Nginx 已代理到 Vite dev server"
return
fi
fail "当前 Nginx 未加载开发配置,请重试:bash scripts/install.sh dev --skip-migrate --verbose"
}
run_health_checks() {
local runtime_env="$1" login_key_id="$2"
local expect_rsa=0
[[ "$runtime_env" == "production" ]] && expect_rsa=1
check_container_status
check_dev_nginx_mode
check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa"
step "探测 HTTP 接口可用性"
check_http_endpoint "/health"
check_http_endpoint "/api/v1/auth/login-key"
if [[ "$TARGET_ENV" == "dev" ]]; then
check_http_endpoint "/" 60
fi
ok "HTTP 接口全部就绪"
}
# ── 完成提示 ─────────────────────────────────
show_success() {
local w=66
local pa pe
pa="$(ctms_pad_right "访问地址" 14)"
pe="$(ctms_pad_right "环境模式" 14)"
printf '\n'
ctms_box_top "${CC_SUCCESS}${C_BOLD}" "$ICON_OK CTMS 安装成功" "$w"
ctms_box_line "${CC_SUCCESS}${C_BOLD}" "$w" \
"${pa}${BASE_URL}" \
"${CC_MUTED}${pa}${C_RESET}${CC_INFO}${BASE_URL}${C_RESET}"
ctms_box_line "${CC_SUCCESS}${C_BOLD}" "$w" \
"${pe}${TARGET_ENV}" \
"${CC_MUTED}${pe}${C_RESET}${CC_TEXT}${TARGET_ENV}${C_RESET}"
ctms_box_bottom "${CC_SUCCESS}${C_BOLD}" "$w"
printf '\n'
}
# ── 入口 ─────────────────────────────────────
main() {
_banner
parse_args "$@"
cd "$ROOT_DIR"
local project_name runtime_env login_key_id
project_name="$(compose_project_name)"
runtime_env="$(app_env)"
login_key_id="$(key_id)"
resolve_base_url
resolve_effective_config "$project_name" "$runtime_env" "$login_key_id"
check_dependencies
confirm_install "$EFFECTIVE_PROJECT_NAME" "$EFFECTIVE_RUNTIME_ENV"
prepare_env_file "$project_name" "$runtime_env" "$login_key_id"
sync_frontend_build_env "$runtime_env"
run_compose_config
run_backend_init
run_build_and_start
run_migrations
resolve_effective_config "$project_name" "$runtime_env" "$login_key_id"
run_health_checks "$EFFECTIVE_RUNTIME_ENV" "$EFFECTIVE_LOGIN_KEY_ID"
show_success
}
main "$@"