#!/usr/bin/env bash # 如果用 sh 调用,自动切换到 bash if [ -z "$BASH_VERSION" ]; then exec bash "$0" "$@" fi # dev.sh — 本地开发环境管理 # # 启动: # sh scripts/dev.sh run # 启动前后端(macOS 桌面端) # sh scripts/dev.sh run --force # 强制重启后端 # sh scripts/dev.sh run --web # 启动前后端(Chrome Web 端) # sh scripts/dev.sh run --all # 启动前后端(macOS + Web 同时) # sh scripts/dev.sh --backend-only # 仅启动后端 # sh scripts/dev.sh --frontend-only # 仅启动前端(macOS) # sh scripts/dev.sh --web-only # 仅启动前端(Web) # # 数据库: # sh scripts/dev.sh seed H001 # 清空并写入 H001 测试数据(SQL 文件) # sh scripts/dev.sh reset # 删表重建(AutoMigrate) # sh scripts/dev.sh clear # 清空所有业务数据 set -e export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:$PATH" ROOT="$(cd "$(dirname "$0")/.." && pwd)" BACKEND_DIR="$ROOT/backend" CLIENT_DIR="$ROOT/client" LOG_DIR="$ROOT/scripts/.logs" BACKEND_LOG="$LOG_DIR/backend.log" BACKEND_PID_FILE="$LOG_DIR/backend.pid" BACKEND_HASH_FILE="$LOG_DIR/backend.hash" WEB_LOG="$LOG_DIR/web.log" WEB_PID_FILE="$LOG_DIR/web.pid" ELEVENTY_LOG="$LOG_DIR/eleventy.log" ELEVENTY_PID_FILE="$LOG_DIR/eleventy.pid" PROXY_PID_FILE="$LOG_DIR/proxy.pid" mkdir -p "$LOG_DIR" # ── seed 命令 ──────────────────────────────────────────────── # 用法: cmd_seed [--clear] # 从 docker-compose.yml 读取容器信息,执行 backend/seeds/.sql # --clear: 写入前先清空该门店所有数据 cmd_seed() { local clear=false local shop="" while [ $# -gt 0 ]; do case "$1" in --clear) clear=true ;; *) shop="$1" ;; esac shift done if [ -z "$shop" ]; then error "用法: sh scripts/dev.sh seed [--clear] 示例: sh scripts/dev.sh seed S001" exit 1 fi local sql_file="$ROOT/backend/seeds/${shop}.sql" if [ ! -f "$sql_file" ]; then error "种子文件不存在: $sql_file" exit 1 fi # 从 docker-compose.yml 读取容器名和数据库信息 local compose_file="$ROOT/deploy/docker-compose.yml" local container db_pass db_name container=$(grep 'container_name:' "$compose_file" | grep -i mysql | head -1 | sed 's/.*container_name: *//') db_pass=$(grep 'MYSQL_ROOT_PASSWORD:' "$compose_file" | head -1 | sed 's/.*MYSQL_ROOT_PASSWORD: *//') db_name=$(grep 'MYSQL_DATABASE:' "$compose_file" | head -1 | sed 's/.*MYSQL_DATABASE: *//') # 检查容器是否运行 if ! docker ps --format '{{.Names}}' | grep -q "^${container}$"; then error "MySQL 容器 '${container}' 未运行,请先执行: docker compose -f deploy/docker-compose.yml up -d" exit 1 fi if [ "$clear" = "true" ]; then local clear_file="$ROOT/backend/seeds/clear_shop.sql" if [ ! -f "$clear_file" ]; then error "清空脚本不存在: $clear_file" exit 1 fi info "清空 ${shop} 数据 → 容器 ${container} / ${db_name}" { printf "SET @shop_code='%s';\n" "$shop"; cat "$clear_file"; } \ | docker exec -i "$container" mysql --default-character-set=utf8mb4 -uroot -p"$db_pass" "$db_name" fi info "写入 ${shop} 种子数据 → 容器 ${container} / ${db_name}" docker exec -i "$container" mysql --default-character-set=utf8mb4 -uroot -p"$db_pass" "$db_name" < "$sql_file" success "✅ ${shop} 种子数据写入完成" echo "" echo " 登录信息:" echo " 门店编号: ${shop}" echo " admin / password123" echo " operator / password123" echo " test / password123" } # ── 颜色输出 ────────────────────────────────────────────── GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' CYAN='\033[0;36m' NC='\033[0m' info() { echo -e "${CYAN}[dev]${NC} $*"; } success() { echo -e "${GREEN}[dev]${NC} $*"; } warn() { echo -e "${YELLOW}[dev]${NC} $*"; } error() { echo -e "${RED}[dev]${NC} $*"; } # ── 参数解析 ────────────────────────────────────────────── COMMAND="${1:-run}" FORCE=false RUN_BACKEND=true RUN_FRONTEND=true # macOS RUN_WEB=false cmd_marketing() { local WEB_DIR="$ROOT/web" local FLUTTER_WEB_PORT=9090 local ELEVENTY_PORT=3001 local PROXY_PORT=3000 # 检查依赖 if ! command -v node &>/dev/null; then error "未找到 node,请安装 Node.js" exit 1 fi if ! command -v flutter &>/dev/null; then error "未找到 flutter" exit 1 fi # 清理旧进程 for pid_file in "$ELEVENTY_PID_FILE" "$PROXY_PID_FILE" "$WEB_PID_FILE"; do if [ -f "$pid_file" ]; then pid=$(cat "$pid_file") kill "$pid" 2>/dev/null || true rm -f "$pid_file" fi done for port in $ELEVENTY_PORT $FLUTTER_WEB_PORT $PROXY_PORT; do pids=$(lsof -ti:"$port" 2>/dev/null || true) [ -n "$pids" ] && echo "$pids" | xargs kill -9 2>/dev/null || true done sleep 0.5 # 启动后端(如已运行则跳过) if ! curl -s -o /dev/null http://localhost:8080/health 2>/dev/null; then info "启动后端..." cd "$BACKEND_DIR" go run main.go >>"$BACKEND_LOG" 2>&1 & local bp=$! echo "$bp" > "$BACKEND_PID_FILE" wait_backend cd "$ROOT" else success "后端已在运行 → http://localhost:8080" fi # 启动 Eleventy(宣传站)在后台,3001 口 info "启动 Eleventy(宣传站后台)→ :${ELEVENTY_PORT}..." cd "$WEB_DIR" npx eleventy --serve --port=$ELEVENTY_PORT >>"$ELEVENTY_LOG" 2>&1 & echo "$!" > "$ELEVENTY_PID_FILE" cd "$ROOT" # 启动代理(后台) info "启动代理(后台)→ http://localhost:${PROXY_PORT}..." PROXY_PORT=$PROXY_PORT ELEVENTY_PORT=$ELEVENTY_PORT FLUTTER_PORT=$FLUTTER_WEB_PORT \ node "$ROOT/scripts/dev-proxy.js" >>"$LOG_DIR/proxy.log" 2>&1 & echo "$!" > "$PROXY_PID_FILE" sleep 1 echo "" success "代理已就绪:http://localhost:${PROXY_PORT}(宣传站)/ http://localhost:${PROXY_PORT}/app/(Flutter)" echo -e " ${CYAN}Eleventy 日志:tail -f $ELEVENTY_LOG${NC}" echo "" info "在前台启动 Flutter Web(支持热重载,按 r 重载,按 q 退出)..." echo "" # Flutter 在前台运行,支持交互热重载 cd "$CLIENT_DIR" flutter pub get flutter run -d chrome --web-port "$FLUTTER_WEB_PORT" \ "--dart-define=BASE_URL=http://localhost:8080" \ "--dart-define=PUBLIC_URL=http://localhost:${PROXY_PORT}" \ "--dart-define=APP_VERSION=dev" } show_help() { echo "用法: sh scripts/dev.sh <命令> [选项]" echo "" echo "启动命令:" echo " run 启动前后端(macOS 桌面端)" echo " run --force 强制重启后端" echo " run --web 启动前后端(Chrome Web 端)" echo " run --all 启动前后端(macOS + Web 同时)" echo " --backend-only 仅启动后端" echo " --frontend-only 仅启动前端(macOS)" echo " --web-only 仅启动前端(Web)" echo " marketing 宣传站 + Flutter Web + 代理(localhost:3000,/app/ 路由到 Flutter)" echo "" echo "数据库命令:" echo " seed 写入指定门店测试数据(如 S001)" echo " seed --clear 先清空再写入(单店)" echo " reset 删表重建(AutoMigrate)" echo " clear 清空所有业务数据" echo "" echo "其他:" echo " stop 停止后端服务" echo " help / --help / -h 显示此帮助" } case "$COMMAND" in marketing) ;; # 在底部所有函数定义完后执行 run) case "${2:-}" in --force) FORCE=true ;; --web) RUN_FRONTEND=false; RUN_WEB=true ;; --all) RUN_WEB=true ;; --help|-h|help) show_help exit 0 ;; "") ;; *) error "run: 未知选项 '${2}'" echo "可用选项: --force | --web | --all" exit 1 ;; esac ;; seed) cmd_seed "${2:-}" "${3:-}" exit 0 ;; reset) info "删表重建(AutoMigrate)..." cd "$BACKEND_DIR" go run cmd/seed/main.go --reset exit 0 ;; clear) info "清空所有业务数据..." cd "$BACKEND_DIR" go run cmd/seed/main.go --clear exit 0 ;; --backend-only) COMMAND=run RUN_FRONTEND=false [ "${2:-}" = "--force" ] && FORCE=true ;; --frontend-only) COMMAND=run RUN_BACKEND=false ;; --web-only) COMMAND=run RUN_BACKEND=false RUN_FRONTEND=false RUN_WEB=true ;; stop) if [ -f "$BACKEND_PID_FILE" ]; then pid=$(cat "$BACKEND_PID_FILE") kill -9 "$pid" 2>/dev/null || true rm -f "$BACKEND_PID_FILE" fi if [ -f "$WEB_PID_FILE" ]; then pid=$(cat "$WEB_PID_FILE") kill -9 "$pid" 2>/dev/null || true rm -f "$WEB_PID_FILE" fi lsof -ti TCP:8080 -s TCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null || true sleep 0.5 if lsof -ti TCP:8080 -s TCP:LISTEN >/dev/null 2>&1; then error "端口 8080 仍被占用,请手动处理" else success "后端已停止,端口 8080 已释放" fi exit 0 ;; help|--help|-h) show_help exit 0 ;; *) error "未知命令: $COMMAND" echo "" show_help exit 1 ;; esac # ── 清理函数 ────────────────────────────────────────────── cleanup() { echo "" info "正在关闭服务..." for pid_file in "$WEB_PID_FILE" "$ELEVENTY_PID_FILE" "$PROXY_PID_FILE"; do if [ -f "$pid_file" ]; then pid=$(cat "$pid_file") kill "$pid" 2>/dev/null || true rm -f "$pid_file" fi done info "后端继续在后台运行。如需停止:sh scripts/dev.sh stop" exit 0 } trap cleanup INT TERM # ── 依赖检查 ────────────────────────────────────────────── check_deps() { local missing=0 if [ "$RUN_BACKEND" = true ] && ! command -v go &>/dev/null; then error "未找到 go,请确认 PATH 包含 Homebrew bin" missing=1 fi if [ "$RUN_FRONTEND" = true ] && ! command -v flutter &>/dev/null; then error "未找到 flutter,请安装 Flutter SDK" missing=1 fi [ "$missing" -eq 1 ] && exit 1 return 0 } # ── 计算后端源码 hash ────────────────────────────────────── backend_hash() { # 对所有 .go 文件 + go.mod 内容做 hash find "$BACKEND_DIR" -name "*.go" -o -name "go.mod" -o -name "go.sum" \ | sort \ | xargs shasum 2>/dev/null \ | shasum \ | awk '{print $1}' } # ── 检查后端进程是否还活着 ─────────────────────────────── backend_running() { if [ ! -f "$BACKEND_PID_FILE" ]; then return 1; fi local pid pid=$(cat "$BACKEND_PID_FILE") kill -0 "$pid" 2>/dev/null } # ── 杀掉占用 8080 的进程 ────────────────────────────────── kill_port_8080() { local pids pids=$(lsof -ti:8080 2>/dev/null || true) if [ -n "$pids" ]; then warn "端口 8080 被占用,正在清理..." echo "$pids" | xargs kill -9 2>/dev/null || true sleep 1 success "端口 8080 已释放" fi } # ── 等待后端就绪 ────────────────────────────────────────── wait_backend() { info "等待后端启动..." local i=0 while [ $i -lt 30 ]; do if curl -s -o /dev/null http://localhost:8080/health 2>/dev/null; then success "后端已就绪 → http://localhost:8080" return 0 fi sleep 1 i=$((i + 1)) done warn "后端启动超时,请查看日志:tail -f $BACKEND_LOG" } # ── 启动后端 ────────────────────────────────────────────── start_backend() { local current_hash current_hash=$(backend_hash) local saved_hash="" [ -f "$BACKEND_HASH_FILE" ] && saved_hash=$(cat "$BACKEND_HASH_FILE") # 判断是否需要重启 if [ "$FORCE" = false ] && backend_running && [ "$current_hash" = "$saved_hash" ]; then local pid pid=$(cat "$BACKEND_PID_FILE") success "后端无改动,跳过重启 (PID $pid) → http://localhost:8080" return 0 fi if backend_running; then local pid pid=$(cat "$BACKEND_PID_FILE") warn "后端代码已变更,重启中 (旧 PID $pid)..." kill "$pid" 2>/dev/null || true sleep 1 fi kill_port_8080 info "编译并启动后端 (Go)..." cd "$BACKEND_DIR" go run main.go >>"$BACKEND_LOG" 2>&1 & local new_pid=$! echo "$new_pid" > "$BACKEND_PID_FILE" echo "$current_hash" > "$BACKEND_HASH_FILE" info "后端进程 PID=$new_pid,日志:tail -f $BACKEND_LOG" wait_backend } # ── 启动 Web 前端(后台) ───────────────────────────────── start_web_bg() { # 清理占用 8081 的旧进程 local old_pid old_pid=$(lsof -ti:8081 2>/dev/null | head -1) if [ -n "$old_pid" ]; then kill -9 "$old_pid" 2>/dev/null || true sleep 1 fi info "启动 Flutter Web (Chrome,后台)..." flutter run -d chrome --web-port 8081 >>"$WEB_LOG" 2>&1 & local pid=$! echo "$pid" > "$WEB_PID_FILE" # 等待 Chrome 打开(最多 15 秒) local i=0 while [ $i -lt 15 ]; do if grep -q "To hot reload\|Launching lib/main.dart" "$WEB_LOG" 2>/dev/null; then success "Web 已启动 → http://localhost:8081 (日志: tail -f $WEB_LOG)" return 0 fi sleep 1 i=$((i + 1)) done warn "Web 启动较慢,继续等待... (日志: tail -f $WEB_LOG)" } # ════════════════════════════════════════════════════════ if [ "$COMMAND" = "marketing" ]; then cmd_marketing exit 0 fi check_deps if [ "$RUN_BACKEND" = true ]; then start_backend fi # --all 模式:先在后台启动 Web,再在前台启动 macOS if [ "$RUN_WEB" = true ] && [ "$RUN_FRONTEND" = true ]; then cd "$CLIENT_DIR" && flutter pub get start_web_bg echo "" success "Flutter macOS 启动中..." echo -e "${CYAN}提示:macOS 窗口 + Chrome 同时运行,Ctrl+C 退出时 Web 同步停止${NC}" echo "" flutter run -d macos elif [ "$RUN_WEB" = true ]; then cd "$CLIENT_DIR" && flutter pub get # 清理占用 8081 的旧进程 _old=$(lsof -ti:8081 2>/dev/null | head -1) [ -n "$_old" ] && kill -9 "$_old" 2>/dev/null && sleep 1 echo "" success "Chrome 启动中,稍候自动打开..." echo -e "${CYAN}提示:按 r 热重载,按 q 退出,后端继续在后台运行${NC}" echo "" flutter run -d chrome --web-port 8081 elif [ "$RUN_FRONTEND" = true ]; then cd "$CLIENT_DIR" && flutter pub get echo "" success "Flutter macOS 启动中,窗口将自动弹出..." echo -e "${CYAN}提示:按 q 退出前端,后端继续运行${NC}" echo -e "${CYAN} 再次执行 sh scripts/dev.sh run 可复用已有后端${NC}" echo "" flutter run -d macos else echo "" success "后端运行中,按 Ctrl+C 退出此脚本(后端仍在后台)" echo -e " 查看日志:tail -f $BACKEND_LOG" wait "$(cat "$BACKEND_PID_FILE")" 2>/dev/null || true fi