# -*- coding: utf-8 -*- # 启动管理后台(后端 + 前端) # 服务成功启动后自动打开浏览器 $ErrorActionPreference = "Stop" try { # CHANGE 2026-03-07 | 定位项目根目录:从 bat 启动目录推算,不穿透 junction # 背景:C:\Project\NeoZQYY 是 junction → D:\NeoZQYY\...\repo, # $MyInvocation.MyCommand.Path 和 Split-Path 都会穿透 junction 解析到 D 盘, # 导致后端 CWD、.venv python、.env 全部指向 D 盘副本。 # 解决:优先用环境变量 NEOZQYY_ROOT;其次用 bat 传入的 %~dp0(不穿透 junction); # 最后才回退到脚本路径推算。 if ($env:NEOZQYY_ROOT -and (Test-Path $env:NEOZQYY_ROOT)) { $ProjectRoot = $env:NEOZQYY_ROOT } elseif ($env:NEOZQYY_LAUNCH_DIR -and (Test-Path $env:NEOZQYY_LAUNCH_DIR)) { # 由 start-admin.bat 通过 %~dp0 注入,不穿透 junction $ProjectRoot = $env:NEOZQYY_LAUNCH_DIR.TrimEnd('\') } elseif ($MyInvocation.MyCommand.Path) { $ProjectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)) } else { $ProjectRoot = $PWD.Path } # CHANGE 2026-03-07 | 将 ProjectRoot 注入环境变量 NEOZQYY_ROOT, # 使 config.py 的 _find_project_root() 策略 1 直接命中, # 不再依赖 __file__ 推算(__file__ 会穿透 junction 到 D 盘)。 # 这个环境变量会被子进程(uvicorn)继承。 $env:NEOZQYY_ROOT = $ProjectRoot Write-Host "========================================" -ForegroundColor Cyan Write-Host " NeoZQYY 管理后台启动脚本" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan Write-Host "" Write-Host "项目根目录: $ProjectRoot" Write-Host "NEOZQYY_ROOT=$env:NEOZQYY_ROOT" Write-Host "" $backendDir = Join-Path $ProjectRoot "apps\backend" $frontendDir = Join-Path $ProjectRoot "apps\admin-web" $tenantDir = Join-Path $ProjectRoot "apps\tenant-admin" if (-not (Test-Path $backendDir)) { throw "后端目录不存在: $backendDir" } if (-not (Test-Path $frontendDir)) { throw "前端目录不存在: $frontendDir" } if (-not (Test-Path $tenantDir)) { throw "租户管理后台目录不存在: $tenantDir" } # ── 端口冲突检测:确保 8000/5173/5174 未被旧进程占用 ── foreach ($port in @(8000, 5173, 5174)) { $listeners = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue if ($listeners) { foreach ($l in $listeners) { $procId = $l.OwningProcess $proc = Get-Process -Id $procId -ErrorAction SilentlyContinue $procPath = if ($proc) { $proc.Path } else { "未知" } Write-Host "端口 $port 被占用: PID=$procId ($procPath)" -ForegroundColor Yellow # 尝试多种方式终止:Stop-Process → taskkill /T /F Write-Host " 正在终止旧进程..." -ForegroundColor Yellow Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 1 # 如果 Stop-Process 不够,用 taskkill 连子进程一起杀 # taskkill 非零退出码不应终止脚本 $ErrorActionPreference = "Continue" taskkill /PID $procId /T /F 2>$null | Out-Null $ErrorActionPreference = "Stop" } # 等待端口释放(最多 30 秒) $waitMax = 30 $waited = 0 while ($waited -lt $waitMax) { Start-Sleep -Seconds 2 $waited += 2 $still = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue if (-not $still) { Write-Host " 端口 $port 已释放(等待 ${waited}s)" -ForegroundColor Green break } # 父进程可能已死但子进程仍占端口,扫描并杀子进程 $zombiePid = $still.OwningProcess $children = Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq $zombiePid } if ($children) { foreach ($child in $children) { Write-Host " 发现残留子进程: PID=$($child.ProcessId) ($($child.ExecutablePath))" -ForegroundColor Yellow # taskkill 非零退出码不应终止脚本 $ErrorActionPreference = "Continue" taskkill /PID $child.ProcessId /T /F 2>$null | Out-Null $ErrorActionPreference = "Stop" } } Write-Host " 端口 $port 仍被占用,继续等待... (${waited}/${waitMax}s)" -ForegroundColor Yellow } if ($waited -ge $waitMax) { $still2 = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue if ($still2) { Write-Host "" Write-Host " !! 端口 $port 无法释放(僵尸进程 PID=$($still2.OwningProcess))" -ForegroundColor Red Write-Host " !! 请重启电脑或手动执行: netsh int ipv4 reset" -ForegroundColor Red Write-Host " !! 或等待几分钟后重试" -ForegroundColor Red throw "端口 $port 被僵尸进程占用,无法启动" } } } } # 前端日志文件(每次用唯一文件名,避免上次进程锁定旧文件) $ts = Get-Date -Format "yyyyMMdd_HHmmss" $frontendLog = Join-Path $env:TEMP "neozqyy_fe_${ts}.log" $tenantLog = Join-Path $env:TEMP "neozqyy_ta_${ts}.log" # 选择 PowerShell 可执行文件:优先 pwsh (7+),回退 powershell (5.1) $psExe = if (Get-Command pwsh -ErrorAction SilentlyContinue) { "pwsh" } else { "powershell" } # ── 生成临时启动脚本(带时间戳,避免旧脚本残留) ── $beTmp = Join-Path $env:TEMP "neozqyy_start_be_${ts}.ps1" $feTmp = Join-Path $env:TEMP "neozqyy_start_fe_${ts}.ps1" $taTmp = Join-Path $env:TEMP "neozqyy_start_ta_${ts}.ps1" $q = [char]39 # CHANGE 2026-03-07 | 显式使用 .venv 的 python,避免 uv run 解析到 miniconda # 背景:uv run uvicorn 在此机器上解析到 C:\ProgramData\miniconda3\python.exe, # 导致 config.py 的 Path(__file__).resolve() 指向错误的代码副本(D 盘) $venvPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe" if (-not (Test-Path $venvPython)) { throw ".venv Python 不存在: $venvPython — 请先运行 uv sync" } # CHANGE 2026-03-07 | 临时脚本中显式注入 NEOZQYY_ROOT,确保 uvicorn 子进程 # 无论通过何种方式启动都能拿到正确的项目根目录 # CHANGE 2026-03-07 | 解决 ANSI 转义码乱码: # PowerShell 5.1 + 旧版 conhost 不解析 ANSI 转义序列,uvicorn 彩色日志 # 显示为 [32m、[0m 等乱码。设置 NO_COLOR=1 禁用颜色输出。 # pwsh 7+ 和 Windows Terminal 原生支持 VT,不受影响。 # CHANGE 2026-04-01 | 修复三个显示问题: # 1. Write-Host 用单引号包裹导致 $env:NEOZQYY_ROOT 不展开 → 改用双引号 # 2. NO_COLOR 对 uvicorn 不生效 → 加 --no-color 参数 # 3. PS 5.1 传统控制台 UTF-8 箭头乱码 → 所有临时脚本开头 chcp 65001 # 并统一设 NO_COLOR=1 禁用 ANSI 转义码 $beLines = @( "`$env:NEOZQYY_ROOT = ${q}${ProjectRoot}${q}" "" "# PS 5.1 + 传统控制台:启用 UTF-8 代码页 + 禁用 ANSI 颜色" "if (`$PSVersionTable.PSVersion.Major -lt 7 -and -not `$env:WT_SESSION) {" " chcp 65001 | Out-Null" " `$env:NO_COLOR = ${q}1${q}" " [Console]::OutputEncoding = [System.Text.Encoding]::UTF8" " Write-Host ${q}[提示] PS 5.1 + 传统控制台,已启用 UTF-8 并禁用 ANSI 颜色${q} -ForegroundColor Yellow" "}" "" "Set-Location -LiteralPath ${q}${backendDir}${q}" "Write-Host ${q}=== 后端 FastAPI ===${q} -ForegroundColor Green" "Write-Host `"NEOZQYY_ROOT=`$env:NEOZQYY_ROOT`"" "& ${q}${venvPython}${q} -m uvicorn app.main:app --reload --port 8000 --no-use-colors" "Write-Host ${q}后端已退出,按任意键关闭...${q} -ForegroundColor Red" "`$null = `$Host.UI.RawUI.ReadKey(${q}NoEcho,IncludeKeyDown${q})" ) $beLines | Set-Content -Path $beTmp -Encoding UTF8 $feLines = @( "# PS 5.1 + 传统控制台:启用 UTF-8 + 禁用 ANSI" "if (`$PSVersionTable.PSVersion.Major -lt 7 -and -not `$env:WT_SESSION) {" " chcp 65001 | Out-Null" " `$env:NO_COLOR = ${q}1${q}" " [Console]::OutputEncoding = [System.Text.Encoding]::UTF8" "}" "" "Set-Location -LiteralPath ${q}${frontendDir}${q}" "Write-Host ${q}=== 前端 Vite (admin-web) ===${q} -ForegroundColor Green" "cmd /c ${q}pnpm dev 2>&1${q} | Tee-Object -FilePath ${q}${frontendLog}${q}" "Write-Host ${q}前端已退出,按任意键关闭...${q} -ForegroundColor Red" "`$null = `$Host.UI.RawUI.ReadKey(${q}NoEcho,IncludeKeyDown${q})" ) $feLines | Set-Content -Path $feTmp -Encoding UTF8 $taLines = @( "# PS 5.1 + 传统控制台:启用 UTF-8 + 禁用 ANSI" "if (`$PSVersionTable.PSVersion.Major -lt 7 -and -not `$env:WT_SESSION) {" " chcp 65001 | Out-Null" " `$env:NO_COLOR = ${q}1${q}" " [Console]::OutputEncoding = [System.Text.Encoding]::UTF8" "}" "" "Set-Location -LiteralPath ${q}${tenantDir}${q}" "Write-Host ${q}=== 租户管理后台 Vite (tenant-admin) ===${q} -ForegroundColor Green" "cmd /c ${q}pnpm dev 2>&1${q} | Tee-Object -FilePath ${q}${tenantLog}${q}" "Write-Host ${q}租户管理后台已退出,按任意键关闭...${q} -ForegroundColor Red" "`$null = `$Host.UI.RawUI.ReadKey(${q}NoEcho,IncludeKeyDown${q})" ) $taLines | Set-Content -Path $taTmp -Encoding UTF8 # ── 启动服务函数 ── function Start-AllServices { param([ref]$BeProc, [ref]$FeProc, [ref]$TaProc) Write-Host "[1/3] 启动后端 FastAPI (http://localhost:8000) ..." -ForegroundColor Yellow $BeProc.Value = Start-Process $psExe -ArgumentList "-NoExit", "-ExecutionPolicy", "Bypass", "-File", $beTmp -PassThru Start-Sleep -Seconds 2 Write-Host "[2/3] 启动前端 Vite admin-web (http://localhost:5173) ..." -ForegroundColor Yellow $FeProc.Value = Start-Process $psExe -ArgumentList "-NoExit", "-ExecutionPolicy", "Bypass", "-File", $feTmp -PassThru Start-Sleep -Seconds 1 Write-Host "[3/3] 启动租户管理后台 Vite tenant-admin (http://localhost:5174) ..." -ForegroundColor Yellow $TaProc.Value = Start-Process $psExe -ArgumentList "-NoExit", "-ExecutionPolicy", "Bypass", "-File", $taTmp -PassThru Write-Host "" Write-Host "========================================" -ForegroundColor Green Write-Host " 三个服务已在新窗口中启动" -ForegroundColor Green Write-Host " 后端: http://localhost:8000" -ForegroundColor Green Write-Host " 管理后台: http://localhost:5173" -ForegroundColor Green Write-Host " 租户管理后台: http://localhost:5174" -ForegroundColor Green Write-Host "========================================" -ForegroundColor Green Write-Host "" } # ── 终止服务函数 ── function Stop-AllServices { param($BeProc, $FeProc, $TaProc) Write-Host "" Write-Host "正在终止所有服务..." -ForegroundColor Yellow $ErrorActionPreference = "Continue" foreach ($info in @( @{ Name = "后端 FastAPI"; Proc = $BeProc; Port = 8000 }, @{ Name = "前端 admin-web"; Proc = $FeProc; Port = 5173 }, @{ Name = "租户 tenant-admin"; Proc = $TaProc; Port = 5174 } )) { $p = $info.Proc if ($p -and -not $p.HasExited) { Write-Host " 终止 $($info.Name) (PID=$($p.Id))..." -ForegroundColor Yellow taskkill /PID $p.Id /T /F 2>$null | Out-Null } # 同时按端口清理(防止子进程残留) $listeners = Get-NetTCPConnection -LocalPort $info.Port -State Listen -ErrorAction SilentlyContinue foreach ($l in $listeners) { taskkill /PID $l.OwningProcess /T /F 2>$null | Out-Null } } $ErrorActionPreference = "Stop" Start-Sleep -Seconds 1 Write-Host " 所有服务已终止" -ForegroundColor Green } # ── 等待前端就绪 + 打开浏览器 ── function Wait-AndOpenBrowser { Write-Host "等待前端 Vite 就绪..." -ForegroundColor Yellow $timeout = 45 $elapsed = 0 $feReady = $false $taReady = $false while ($elapsed -lt $timeout -and (-not $feReady -or -not $taReady)) { Start-Sleep -Seconds 1 $elapsed++ if (-not $feReady -and (Test-Path $frontendLog)) { $raw = Get-Content $frontendLog -Raw -ErrorAction SilentlyContinue if ($raw) { $clean = $raw -replace '\x1b\[[0-9;]*m', '' if ($clean -match "localhost:5173" -or $clean -match "ready in") { $feReady = $true Write-Host " admin-web 已就绪(${elapsed}s)" -ForegroundColor Green } } } if (-not $taReady -and (Test-Path $tenantLog)) { $raw = Get-Content $tenantLog -Raw -ErrorAction SilentlyContinue if ($raw) { $clean = $raw -replace '\x1b\[[0-9;]*m', '' if ($clean -match "localhost:5174" -or $clean -match "ready in") { $taReady = $true Write-Host " tenant-admin 已就绪(${elapsed}s)" -ForegroundColor Green } } } } if ($feReady) { Start-Process "http://localhost:5173" } else { Write-Host "admin-web 等待超时,请手动打开 http://localhost:5173" -ForegroundColor Red } if ($taReady) { Start-Process "http://localhost:5174" } else { Write-Host "tenant-admin 等待超时,请手动打开 http://localhost:5174" -ForegroundColor Red } } # ── 倒计时函数 ── function Show-Countdown { param([int]$Seconds) for ($i = $Seconds; $i -ge 1; $i--) { Write-Host "`r 重启倒计时: ${i}s ... " -NoNewline -ForegroundColor Cyan Start-Sleep -Seconds 1 } Write-Host "`r 重启倒计时: 0s — 开始启动! " -ForegroundColor Green Write-Host "" } # ── 首次启动 ── $beProc = $null; $feProc = $null; $taProc = $null Start-AllServices -BeProc ([ref]$beProc) -FeProc ([ref]$feProc) -TaProc ([ref]$taProc) Wait-AndOpenBrowser # ── 交互菜单循环 ── $running = $true while ($running) { Write-Host "" Write-Host "========================================" -ForegroundColor Cyan Write-Host " 操作菜单" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan Write-Host " [1] 终止所有服务" -ForegroundColor Yellow Write-Host " [2] 重启所有服务(间隔 5 秒倒计时)" -ForegroundColor Yellow Write-Host " [3] 退出(终止服务并关闭窗口)" -ForegroundColor Yellow Write-Host "========================================" -ForegroundColor Cyan $choice = Read-Host "请输入选项 (1/2/3)" switch ($choice) { "1" { Stop-AllServices -BeProc $beProc -FeProc $feProc -TaProc $taProc $beProc = $null; $feProc = $null; $taProc = $null } "2" { Stop-AllServices -BeProc $beProc -FeProc $feProc -TaProc $taProc Show-Countdown -Seconds 5 # 重新生成日志文件名(避免旧文件锁定) $ts = Get-Date -Format "yyyyMMdd_HHmmss" $frontendLog = Join-Path $env:TEMP "neozqyy_fe_${ts}.log" $tenantLog = Join-Path $env:TEMP "neozqyy_ta_${ts}.log" # 重新生成临时启动脚本 $beLines = @( "`$env:NEOZQYY_ROOT = ${q}${ProjectRoot}${q}" "" "if (`$PSVersionTable.PSVersion.Major -lt 7 -and -not `$env:WT_SESSION) {" " chcp 65001 | Out-Null" " `$env:NO_COLOR = ${q}1${q}" " [Console]::OutputEncoding = [System.Text.Encoding]::UTF8" " Write-Host ${q}[提示] PS 5.1 + 传统控制台,已启用 UTF-8 并禁用 ANSI 颜色${q} -ForegroundColor Yellow" "}" "" "Set-Location -LiteralPath ${q}${backendDir}${q}" "Write-Host ${q}=== 后端 FastAPI ===${q} -ForegroundColor Green" "Write-Host `"NEOZQYY_ROOT=`$env:NEOZQYY_ROOT`"" "& ${q}${venvPython}${q} -m uvicorn app.main:app --reload --port 8000 --no-use-colors" "Write-Host ${q}后端已退出,按任意键关闭...${q} -ForegroundColor Red" "`$null = `$Host.UI.RawUI.ReadKey(${q}NoEcho,IncludeKeyDown${q})" ) $beLines | Set-Content -Path $beTmp -Encoding UTF8 $feLines = @( "if (`$PSVersionTable.PSVersion.Major -lt 7 -and -not `$env:WT_SESSION) {" " chcp 65001 | Out-Null" " `$env:NO_COLOR = ${q}1${q}" " [Console]::OutputEncoding = [System.Text.Encoding]::UTF8" "}" "" "Set-Location -LiteralPath ${q}${frontendDir}${q}" "Write-Host ${q}=== 前端 Vite (admin-web) ===${q} -ForegroundColor Green" "cmd /c ${q}pnpm dev 2>&1${q} | Tee-Object -FilePath ${q}${frontendLog}${q}" "Write-Host ${q}前端已退出,按任意键关闭...${q} -ForegroundColor Red" "`$null = `$Host.UI.RawUI.ReadKey(${q}NoEcho,IncludeKeyDown${q})" ) $feLines | Set-Content -Path $feTmp -Encoding UTF8 $taLines = @( "if (`$PSVersionTable.PSVersion.Major -lt 7 -and -not `$env:WT_SESSION) {" " chcp 65001 | Out-Null" " `$env:NO_COLOR = ${q}1${q}" " [Console]::OutputEncoding = [System.Text.Encoding]::UTF8" "}" "" "Set-Location -LiteralPath ${q}${tenantDir}${q}" "Write-Host ${q}=== 租户管理后台 Vite (tenant-admin) ===${q} -ForegroundColor Green" "cmd /c ${q}pnpm dev 2>&1${q} | Tee-Object -FilePath ${q}${tenantLog}${q}" "Write-Host ${q}租户管理后台已退出,按任意键关闭...${q} -ForegroundColor Red" "`$null = `$Host.UI.RawUI.ReadKey(${q}NoEcho,IncludeKeyDown${q})" ) $taLines | Set-Content -Path $taTmp -Encoding UTF8 $beProc = $null; $feProc = $null; $taProc = $null Start-AllServices -BeProc ([ref]$beProc) -FeProc ([ref]$feProc) -TaProc ([ref]$taProc) Wait-AndOpenBrowser } "3" { Stop-AllServices -BeProc $beProc -FeProc $feProc -TaProc $taProc $running = $false } default { Write-Host " 无效选项,请输入 1、2 或 3" -ForegroundColor Red } } } } catch { Write-Host "" Write-Host "启动失败: $_" -ForegroundColor Red Write-Host $_.ScriptStackTrace -ForegroundColor DarkRed } Write-Host "" Write-Host "按任意键关闭此窗口..." -ForegroundColor DarkGray $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")