[CmdletBinding()] param( [string]$BackendBindAddress = "0.0.0.0", [int]$BackendPort = 8000, [string]$FrontendBindAddress = "0.0.0.0", [int]$FrontendPort = 5173, [string]$ApiBaseUrl = "", [switch]$SkipFrontend, [switch]$NoBrowser ) $ErrorActionPreference = "Stop" $ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path $RuntimeDir = Join-Path $ProjectRoot ".runtime" $LogDir = Join-Path $RuntimeDir "logs" $StatePath = Join-Path $RuntimeDir "processes.json" $PythonPath = Join-Path $ProjectRoot ".venv\Scripts\python.exe" $BackendDir = Join-Path $ProjectRoot "backend" $FrontendDir = Join-Path $ProjectRoot "frontend" $NodeModulesDir = Join-Path $FrontendDir "node_modules" function Get-ListeningProcessId { param([int]$Port) $connection = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -eq $connection) { return $null } return [int]$connection.OwningProcess } function Wait-ForPort { param( [int]$Port, [System.Diagnostics.Process]$Launcher, [int]$TimeoutSeconds = 30 ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { $listenerPid = Get-ListeningProcessId -Port $Port if ($null -ne $listenerPid) { return $listenerPid } if ($Launcher.HasExited) { throw "启动进程已退出,退出码:$($Launcher.ExitCode)" } Start-Sleep -Milliseconds 300 $Launcher.Refresh() } throw "等待端口 $Port 监听超时(${TimeoutSeconds} 秒)" } function Stop-ProcessTree { param([int]$RootProcessId) if ($RootProcessId -le 0) { return } # 先结束子进程,避免 npm/cmd 退出后留下独立的 Vite 进程。 $pending = [System.Collections.Generic.List[int]]::new() $pending.Add($RootProcessId) $allProcessIds = [System.Collections.Generic.List[int]]::new() while ($pending.Count -gt 0) { $currentProcessId = $pending[0] $pending.RemoveAt(0) $allProcessIds.Add($currentProcessId) Get-CimInstance Win32_Process -Filter "ParentProcessId = $currentProcessId" -ErrorAction SilentlyContinue | ForEach-Object { $pending.Add([int]$_.ProcessId) } } $orderedProcessIds = @($allProcessIds) [array]::Reverse($orderedProcessIds) foreach ($processId in $orderedProcessIds) { Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue } } if (-not (Test-Path -LiteralPath $PythonPath -PathType Leaf)) { throw "未找到 Python 虚拟环境:$PythonPath。请先执行 python -m venv .venv 并安装后端依赖。" } if (-not (Test-Path -LiteralPath $BackendDir -PathType Container)) { throw "未找到后端目录:$BackendDir" } if (-not $SkipFrontend) { if (-not (Get-Command npm.cmd -ErrorAction SilentlyContinue)) { throw "未找到 npm.cmd,请先安装 Node.js 并确认 npm 已加入 PATH。" } if (-not (Test-Path -LiteralPath $NodeModulesDir -PathType Container)) { throw "未找到前端依赖目录:$NodeModulesDir。请先在 frontend 目录执行 npm install。" } } $portsToCheck = @($BackendPort) if (-not $SkipFrontend) { $portsToCheck += $FrontendPort } foreach ($port in $portsToCheck) { if ($null -ne (Get-ListeningProcessId -Port $port)) { throw "端口 $port 已被占用。请先关闭占用进程,或通过参数指定其他端口。" } } New-Item -ItemType Directory -Path $LogDir -Force | Out-Null if (Test-Path -LiteralPath $StatePath) { Remove-Item -LiteralPath $StatePath -Force } $backendProcess = $null $frontendProcess = $null try { Write-Host "正在启动后端:http://127.0.0.1:$BackendPort" -ForegroundColor Cyan $backendProcess = Start-Process ` -FilePath $PythonPath ` -ArgumentList @("-m", "uvicorn", "app.main:app", "--host", $BackendBindAddress, "--port", $BackendPort) ` -WorkingDirectory $BackendDir ` -WindowStyle Hidden ` -RedirectStandardOutput (Join-Path $LogDir "backend.out.log") ` -RedirectStandardError (Join-Path $LogDir "backend.err.log") ` -PassThru $backendListenerPid = Wait-ForPort -Port $BackendPort -Launcher $backendProcess $frontendListenerPid = $null if (-not $SkipFrontend) { Write-Host "正在启动前端:http://127.0.0.1:$FrontendPort" -ForegroundColor Cyan $originalApiBaseUrl = $env:VITE_API_BASE try { if (-not [string]::IsNullOrWhiteSpace($ApiBaseUrl)) { $env:VITE_API_BASE = $ApiBaseUrl.TrimEnd("/") } elseif ($BackendPort -ne 8000) { # 自定义后端端口时,为本机访问自动同步前端 API 地址。 $env:VITE_API_BASE = "http://127.0.0.1:$BackendPort" } $frontendProcess = Start-Process ` -FilePath "npm.cmd" ` -ArgumentList @("run", "dev", "--", "--host", $FrontendBindAddress, "--port", $FrontendPort) ` -WorkingDirectory $FrontendDir ` -WindowStyle Hidden ` -RedirectStandardOutput (Join-Path $LogDir "frontend.out.log") ` -RedirectStandardError (Join-Path $LogDir "frontend.err.log") ` -PassThru } finally { if ($null -eq $originalApiBaseUrl) { Remove-Item Env:VITE_API_BASE -ErrorAction SilentlyContinue } else { $env:VITE_API_BASE = $originalApiBaseUrl } } $frontendListenerPid = Wait-ForPort -Port $FrontendPort -Launcher $frontendProcess } $state = [ordered]@{ started_at = (Get-Date).ToString("o") project_root = $ProjectRoot backend = [ordered]@{ launcher_pid = $backendProcess.Id listener_pid = $backendListenerPid bind_address = $BackendBindAddress port = $BackendPort } frontend = if ($SkipFrontend) { $null } else { [ordered]@{ launcher_pid = $frontendProcess.Id listener_pid = $frontendListenerPid bind_address = $FrontendBindAddress port = $FrontendPort api_base_url = if (-not [string]::IsNullOrWhiteSpace($ApiBaseUrl)) { $ApiBaseUrl.TrimEnd("/") } elseif ($BackendPort -ne 8000) { "http://127.0.0.1:$BackendPort" } else { $null } } } } $state | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $StatePath -Encoding UTF8 Write-Host "项目启动成功。" -ForegroundColor Green Write-Host "后端:http://127.0.0.1:$BackendPort" if (-not $SkipFrontend) { $frontendUrl = "http://127.0.0.1:$FrontendPort" Write-Host "前端:$frontendUrl" if (-not $NoBrowser) { Start-Process $frontendUrl } } Write-Host "日志目录:$LogDir" Write-Host "关闭项目:.\stop.ps1" } catch { Write-Host "启动失败:$($_.Exception.Message)" -ForegroundColor Red if ($null -ne $frontendProcess) { Stop-ProcessTree -RootProcessId $frontendProcess.Id } if ($null -ne $backendProcess) { Stop-ProcessTree -RootProcessId $backendProcess.Id } Remove-Item -LiteralPath $StatePath -Force -ErrorAction SilentlyContinue throw }