start.ps1 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. [CmdletBinding()]
  2. param(
  3. [string]$BackendBindAddress = "0.0.0.0",
  4. [int]$BackendPort = 8000,
  5. [string]$FrontendBindAddress = "0.0.0.0",
  6. [int]$FrontendPort = 5173,
  7. [string]$ApiBaseUrl = "",
  8. [switch]$SkipFrontend,
  9. [switch]$NoBrowser
  10. )
  11. $ErrorActionPreference = "Stop"
  12. $ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
  13. $RuntimeDir = Join-Path $ProjectRoot ".runtime"
  14. $LogDir = Join-Path $RuntimeDir "logs"
  15. $StatePath = Join-Path $RuntimeDir "processes.json"
  16. $PythonPath = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
  17. $BackendDir = Join-Path $ProjectRoot "backend"
  18. $FrontendDir = Join-Path $ProjectRoot "frontend"
  19. $NodeModulesDir = Join-Path $FrontendDir "node_modules"
  20. function Get-ListeningProcessId {
  21. param([int]$Port)
  22. $connection = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
  23. Select-Object -First 1
  24. if ($null -eq $connection) {
  25. return $null
  26. }
  27. return [int]$connection.OwningProcess
  28. }
  29. function Wait-ForPort {
  30. param(
  31. [int]$Port,
  32. [System.Diagnostics.Process]$Launcher,
  33. [int]$TimeoutSeconds = 30
  34. )
  35. $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
  36. while ((Get-Date) -lt $deadline) {
  37. $listenerPid = Get-ListeningProcessId -Port $Port
  38. if ($null -ne $listenerPid) {
  39. return $listenerPid
  40. }
  41. if ($Launcher.HasExited) {
  42. throw "启动进程已退出,退出码:$($Launcher.ExitCode)"
  43. }
  44. Start-Sleep -Milliseconds 300
  45. $Launcher.Refresh()
  46. }
  47. throw "等待端口 $Port 监听超时(${TimeoutSeconds} 秒)"
  48. }
  49. function Stop-ProcessTree {
  50. param([int]$RootProcessId)
  51. if ($RootProcessId -le 0) {
  52. return
  53. }
  54. # 先结束子进程,避免 npm/cmd 退出后留下独立的 Vite 进程。
  55. $pending = [System.Collections.Generic.List[int]]::new()
  56. $pending.Add($RootProcessId)
  57. $allProcessIds = [System.Collections.Generic.List[int]]::new()
  58. while ($pending.Count -gt 0) {
  59. $currentProcessId = $pending[0]
  60. $pending.RemoveAt(0)
  61. $allProcessIds.Add($currentProcessId)
  62. Get-CimInstance Win32_Process -Filter "ParentProcessId = $currentProcessId" -ErrorAction SilentlyContinue |
  63. ForEach-Object { $pending.Add([int]$_.ProcessId) }
  64. }
  65. $orderedProcessIds = @($allProcessIds)
  66. [array]::Reverse($orderedProcessIds)
  67. foreach ($processId in $orderedProcessIds) {
  68. Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
  69. }
  70. }
  71. if (-not (Test-Path -LiteralPath $PythonPath -PathType Leaf)) {
  72. throw "未找到 Python 虚拟环境:$PythonPath。请先执行 python -m venv .venv 并安装后端依赖。"
  73. }
  74. if (-not (Test-Path -LiteralPath $BackendDir -PathType Container)) {
  75. throw "未找到后端目录:$BackendDir"
  76. }
  77. if (-not $SkipFrontend) {
  78. if (-not (Get-Command npm.cmd -ErrorAction SilentlyContinue)) {
  79. throw "未找到 npm.cmd,请先安装 Node.js 并确认 npm 已加入 PATH。"
  80. }
  81. if (-not (Test-Path -LiteralPath $NodeModulesDir -PathType Container)) {
  82. throw "未找到前端依赖目录:$NodeModulesDir。请先在 frontend 目录执行 npm install。"
  83. }
  84. }
  85. $portsToCheck = @($BackendPort)
  86. if (-not $SkipFrontend) {
  87. $portsToCheck += $FrontendPort
  88. }
  89. foreach ($port in $portsToCheck) {
  90. if ($null -ne (Get-ListeningProcessId -Port $port)) {
  91. throw "端口 $port 已被占用。请先关闭占用进程,或通过参数指定其他端口。"
  92. }
  93. }
  94. New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
  95. if (Test-Path -LiteralPath $StatePath) {
  96. Remove-Item -LiteralPath $StatePath -Force
  97. }
  98. $backendProcess = $null
  99. $frontendProcess = $null
  100. try {
  101. Write-Host "正在启动后端:http://127.0.0.1:$BackendPort" -ForegroundColor Cyan
  102. $backendProcess = Start-Process `
  103. -FilePath $PythonPath `
  104. -ArgumentList @("-m", "uvicorn", "app.main:app", "--host", $BackendBindAddress, "--port", $BackendPort) `
  105. -WorkingDirectory $BackendDir `
  106. -WindowStyle Hidden `
  107. -RedirectStandardOutput (Join-Path $LogDir "backend.out.log") `
  108. -RedirectStandardError (Join-Path $LogDir "backend.err.log") `
  109. -PassThru
  110. $backendListenerPid = Wait-ForPort -Port $BackendPort -Launcher $backendProcess
  111. $frontendListenerPid = $null
  112. if (-not $SkipFrontend) {
  113. Write-Host "正在启动前端:http://127.0.0.1:$FrontendPort" -ForegroundColor Cyan
  114. $originalApiBaseUrl = $env:VITE_API_BASE
  115. try {
  116. if (-not [string]::IsNullOrWhiteSpace($ApiBaseUrl)) {
  117. $env:VITE_API_BASE = $ApiBaseUrl.TrimEnd("/")
  118. } elseif ($BackendPort -ne 8000) {
  119. # 自定义后端端口时,为本机访问自动同步前端 API 地址。
  120. $env:VITE_API_BASE = "http://127.0.0.1:$BackendPort"
  121. }
  122. $frontendProcess = Start-Process `
  123. -FilePath "npm.cmd" `
  124. -ArgumentList @("run", "dev", "--", "--host", $FrontendBindAddress, "--port", $FrontendPort) `
  125. -WorkingDirectory $FrontendDir `
  126. -WindowStyle Hidden `
  127. -RedirectStandardOutput (Join-Path $LogDir "frontend.out.log") `
  128. -RedirectStandardError (Join-Path $LogDir "frontend.err.log") `
  129. -PassThru
  130. } finally {
  131. if ($null -eq $originalApiBaseUrl) {
  132. Remove-Item Env:VITE_API_BASE -ErrorAction SilentlyContinue
  133. } else {
  134. $env:VITE_API_BASE = $originalApiBaseUrl
  135. }
  136. }
  137. $frontendListenerPid = Wait-ForPort -Port $FrontendPort -Launcher $frontendProcess
  138. }
  139. $state = [ordered]@{
  140. started_at = (Get-Date).ToString("o")
  141. project_root = $ProjectRoot
  142. backend = [ordered]@{
  143. launcher_pid = $backendProcess.Id
  144. listener_pid = $backendListenerPid
  145. bind_address = $BackendBindAddress
  146. port = $BackendPort
  147. }
  148. frontend = if ($SkipFrontend) {
  149. $null
  150. } else {
  151. [ordered]@{
  152. launcher_pid = $frontendProcess.Id
  153. listener_pid = $frontendListenerPid
  154. bind_address = $FrontendBindAddress
  155. port = $FrontendPort
  156. api_base_url = if (-not [string]::IsNullOrWhiteSpace($ApiBaseUrl)) {
  157. $ApiBaseUrl.TrimEnd("/")
  158. } elseif ($BackendPort -ne 8000) {
  159. "http://127.0.0.1:$BackendPort"
  160. } else {
  161. $null
  162. }
  163. }
  164. }
  165. }
  166. $state | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $StatePath -Encoding UTF8
  167. Write-Host "项目启动成功。" -ForegroundColor Green
  168. Write-Host "后端:http://127.0.0.1:$BackendPort"
  169. if (-not $SkipFrontend) {
  170. $frontendUrl = "http://127.0.0.1:$FrontendPort"
  171. Write-Host "前端:$frontendUrl"
  172. if (-not $NoBrowser) {
  173. Start-Process $frontendUrl
  174. }
  175. }
  176. Write-Host "日志目录:$LogDir"
  177. Write-Host "关闭项目:.\stop.ps1"
  178. } catch {
  179. Write-Host "启动失败:$($_.Exception.Message)" -ForegroundColor Red
  180. if ($null -ne $frontendProcess) {
  181. Stop-ProcessTree -RootProcessId $frontendProcess.Id
  182. }
  183. if ($null -ne $backendProcess) {
  184. Stop-ProcessTree -RootProcessId $backendProcess.Id
  185. }
  186. Remove-Item -LiteralPath $StatePath -Force -ErrorAction SilentlyContinue
  187. throw
  188. }