diff --git a/.gitignore b/.gitignore index e1d9728..e07694e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ backend/config.yaml .staticcheck-cache/ .claude/ .omc/ +/backend/.dev/ diff --git a/README.md b/README.md index 77ad18a..d93af04 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,33 @@ > 越用越懂你的成长型 AI 排程伙伴 · 面向大学生的陪伴式日程管理平台 +## 后端本地快速启动 + +后端开发统一使用 `backend` 根目录下的 PowerShell 启动脚本,不再维护 `cmd/all` 聚合入口。 + +```powershell +cd backend +.\scripts\dev-up.ps1 +.\scripts\services-up.ps1 +.\scripts\dev-status.ps1 +.\scripts\dev-logs.ps1 -Service api -Stream stdout -Follow +.\scripts\service-restart.ps1 -Service api +.\scripts\services-down.ps1 +.\scripts\dev-down.ps1 +.\scripts\dev-down.ps1 -StopInfra +``` + +说明: + +- 所有后端脚本统一收敛在 `backend/scripts` 目录下。 +- `scripts/dev-up.ps1` 会先确保 Docker 基础设施就绪,再按顺序构建并拉起全部 RPC 服务与 API。 +- `scripts/services-up.ps1` 只拉起后端服务本身,不触碰 Docker 基础设施。 +- `scripts/dev-status.ps1` 用于查看各服务是脚本托管、外部运行还是未启动。 +- `scripts/dev-logs.ps1` 用于查看单个服务最新日志;可选 `-Stream stdout|stderr|both`,带 `-Follow` 可持续追日志。 +- `scripts/service-restart.ps1 -Service ` 用于重启单个脚本托管的后端服务;若该服务由外部进程托管,则会直接拒绝操作。 +- `scripts/services-down.ps1` 只停止脚本托管的后端服务进程。 +- `scripts/dev-down.ps1` 默认只停止脚本托管的后端进程;加 `-StopInfra` 才会一并停止 Docker 基础设施。 + # 1 项目概览 ## 1.1 总体介绍 diff --git a/backend/client/course/client.go b/backend/client/course/client.go index 843fe96..718a90a 100644 --- a/backend/client/course/client.go +++ b/backend/client/course/client.go @@ -14,8 +14,9 @@ import ( ) const ( - defaultEndpoint = "127.0.0.1:9087" - defaultTimeout = 10 * time.Second + defaultEndpoint = "127.0.0.1:9087" + // 课表导入可能一次展开大量周次与节次,RPC 默认超时与网关保持一致,避免内层先被截断。 + defaultTimeout = 5 * time.Minute defaultMaxRPCMessageSize = 8 * 1024 * 1024 rpcMessageSizePadding = 1024 * 1024 ) diff --git a/backend/cmd/all/main.go b/backend/cmd/all/main.go deleted file mode 100644 index ec80bf5..0000000 --- a/backend/cmd/all/main.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "github.com/LoveLosita/smartflow/backend/cmd" - -func main() { - cmd.StartAll() -} diff --git a/backend/cmd/start.go b/backend/cmd/start.go index 6c900fa..0450ef2 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -74,7 +74,7 @@ const ( // 职责边界: // 1. 只负责保存启动期已经装配好的基础设施、仓储、服务和 HTTP handler; // 2. 不承载业务逻辑,业务仍然由 service / agent / memory 等领域模块负责; -// 3. 不决定进程角色,api / worker / all 由 StartAPI、StartWorker、StartAll 选择启动哪些生命周期。 +// 3. 不决定进程角色,api / worker 由 StartAPI、StartWorker 选择启动哪些生命周期;StartAll 仅保留兼容别名。 type appRuntime struct { db *gorm.DB redisClient *redis.Client @@ -95,24 +95,20 @@ func loadConfig() error { return bootstrap.LoadConfig() } -// Start 保留历史兼容入口,当前默认等价于 StartAll。 +// Start 保留历史兼容入口,当前默认等价于 StartAPI。 // 1. 兼容 backend/main.go 和旧部署命令。 -// 2. 不新增业务语义,只转发给 StartAll。 +// 2. 不新增业务语义,只转发给 StartAPI。 // 3. 后续若全面切到独立 api/worker 启动,本入口只保留过渡兼容。 func Start() { - StartAll() + StartAPI() } -// StartAll 启动当前仓库的完整运行态:HTTP API + 后台 worker。 -// 这仍然是迁移期的兼容装配,不是终态的“一个服务一个 main.go”模型。 +// StartAll 保留给历史入口与旧命令的兼容别名,当前语义与 StartAPI 完全一致。 +// 1. cmd/all 已移除,不再作为后端本地启动标准入口。 +// 2. 之所以暂时保留该函数,是为了避免仓库根兼容入口和旧脚本立刻失效。 +// 3. 后续若仓库根入口一并收口,可直接删除该兼容别名。 func StartAll() { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - runtime := mustBuildRuntime(ctx) - defer runtime.close() - - runtime.startWorkers(ctx) - runtime.startHTTP(ctx) + StartAPI() } // StartAPI 只启动 Gin API 和其同步依赖,不启动后台 worker。 diff --git a/backend/config.example.yaml b/backend/config.example.yaml index fdacf21..758254b 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -108,7 +108,7 @@ course: listenOn: "0.0.0.0:9087" endpoints: - "127.0.0.1:9087" - timeout: 10s + timeout: 5m # 主动调度服务配置。 activeScheduler: diff --git a/backend/gateway/api/course.go b/backend/gateway/api/course.go index 1baa63e..52acf75 100644 --- a/backend/gateway/api/course.go +++ b/backend/gateway/api/course.go @@ -15,7 +15,8 @@ import ( "github.com/gin-gonic/gin" ) -const courseRequestTimeout = 10 * time.Second +// 课表导入与校验可能涉及较多课程展开与冲突检测,统一放宽到 5 分钟,避免网关提前超时。 +const courseRequestTimeout = 5 * time.Minute type CourseHandler struct { client ports.CourseCommandClient diff --git a/backend/main.go b/backend/main.go index bdf7979..93bcd46 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,7 +4,7 @@ import ( "github.com/LoveLosita/smartflow/backend/cmd" ) -// main 保留仓库根入口的兼容壳,阶段 0 期间仍转发到 cmd.Start()。 +// main 保留仓库根入口的兼容壳,当前仍转发到 cmd.Start()(等价于 cmd.StartAPI())。 // 终态会逐步迁移为各服务各自的独立 main.go。 func main() { cmd.Start() diff --git a/backend/scripts/dev-common.ps1 b/backend/scripts/dev-common.ps1 new file mode 100644 index 0000000..e1b9e05 --- /dev/null +++ b/backend/scripts/dev-common.ps1 @@ -0,0 +1,900 @@ +$BackendRoot = Split-Path -Parent $PSScriptRoot +$RepoRoot = Split-Path -Parent $BackendRoot +$ComposeFile = Join-Path $RepoRoot "docker-compose.yml" +$StateRoot = Join-Path $BackendRoot ".dev" +$PidRoot = Join-Path $StateRoot "pids" +$LogRoot = Join-Path $StateRoot "logs" +$BinRoot = Join-Path $StateRoot "bin" +$DockerConfigRoot = Join-Path $StateRoot "docker-config" +$GoCacheRoot = Join-Path $StateRoot "gocache" + +function Initialize-DevState { + foreach ($path in @($StateRoot, $PidRoot, $LogRoot, $BinRoot, $DockerConfigRoot, $GoCacheRoot)) { + if (-not (Test-Path -LiteralPath $path)) { + New-Item -ItemType Directory -Path $path -Force | Out-Null + } + } + + # 1. 当前桌面环境里可能同时注入 Path / PATH 两个大小写不同的环境变量。 + # 2. Windows PowerShell 的 Env: 提供器与 Start-Process 在这种情况下会报“重复键”错误。 + # 3. 这里统一只保留一个进程级 Path,避免启动脚本因为环境脏数据直接失败。 + $effectivePath = "" + try { + $pathValue = [System.Environment]::GetEnvironmentVariable("Path", "Process") + if ($null -ne $pathValue) { + $effectivePath = [string]$pathValue + } + } + catch { + $effectivePath = "" + } + [System.Environment]::SetEnvironmentVariable("PATH", $null, "Process") + [System.Environment]::SetEnvironmentVariable("Path", $null, "Process") + [System.Environment]::SetEnvironmentVariable("Path", $effectivePath, "Process") + + $env:DOCKER_CONFIG = $DockerConfigRoot + $env:GOCACHE = $GoCacheRoot +} + +function Assert-ToolExists { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "Command not found: $Name" + } +} + +function Invoke-ExternalCommand { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + + [Parameter(Mandatory = $true)] + [string]$WorkingDirectory + ) + + Push-Location $WorkingDirectory + try { + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Command failed: $FilePath $($Arguments -join ' ')" + } + } + finally { + Pop-Location + } +} + +function Get-InfrastructureDefinitions { + return @( + [pscustomobject]@{ + Name = "mysql" + Container = "smartflow-mysql" + TimeoutSec = 120 + }, + [pscustomobject]@{ + Name = "redis" + Container = "smartflow-redis" + TimeoutSec = 90 + }, + [pscustomobject]@{ + Name = "kafka" + Container = "smartflow-kafka" + TimeoutSec = 180 + }, + [pscustomobject]@{ + Name = "etcd" + Container = "smartflow-etcd" + TimeoutSec = 120 + }, + [pscustomobject]@{ + Name = "minio" + Container = "smartflow-minio" + TimeoutSec = 120 + }, + [pscustomobject]@{ + Name = "milvus-standalone" + Container = "smartflow-milvus" + TimeoutSec = 240 + } + ) +} + +function Get-InfrastructureComposeServices { + return @( + "mysql", + "redis", + "kafka", + "etcd", + "minio", + "milvus-standalone", + "kafka-init" + ) +} + +function Get-BackendServiceDefinitions { + return @( + [pscustomobject]@{ + Name = "userauth" + Package = "./cmd/userauth" + BinaryPath = (Join-Path $BinRoot "userauth.exe") + Port = 9081 + ProbeType = "tcp" + ProbeTarget = $null + StartTimeoutSec = 60 + Dependencies = @() + }, + [pscustomobject]@{ + Name = "task" + Package = "./cmd/task" + BinaryPath = (Join-Path $BinRoot "task.exe") + Port = 9085 + ProbeType = "tcp" + ProbeTarget = $null + StartTimeoutSec = 90 + Dependencies = @() + }, + [pscustomobject]@{ + Name = "schedule" + Package = "./cmd/schedule" + BinaryPath = (Join-Path $BinRoot "schedule.exe") + Port = 9084 + ProbeType = "tcp" + ProbeTarget = $null + StartTimeoutSec = 90 + Dependencies = @() + }, + [pscustomobject]@{ + Name = "task-class" + Package = "./cmd/task-class" + BinaryPath = (Join-Path $BinRoot "task-class.exe") + Port = 9086 + ProbeType = "tcp" + ProbeTarget = $null + StartTimeoutSec = 90 + Dependencies = @() + }, + [pscustomobject]@{ + Name = "course" + Package = "./cmd/course" + BinaryPath = (Join-Path $BinRoot "course.exe") + Port = 9087 + ProbeType = "tcp" + ProbeTarget = $null + StartTimeoutSec = 120 + Dependencies = @() + }, + [pscustomobject]@{ + Name = "tokenstore" + Package = "./cmd/tokenstore" + BinaryPath = (Join-Path $BinRoot "tokenstore.exe") + Port = 9095 + ProbeType = "tcp" + ProbeTarget = $null + StartTimeoutSec = 90 + Dependencies = @() + }, + [pscustomobject]@{ + Name = "notification" + Package = "./cmd/notification" + BinaryPath = (Join-Path $BinRoot "notification.exe") + Port = 9082 + ProbeType = "tcp" + ProbeTarget = $null + StartTimeoutSec = 90 + Dependencies = @() + }, + [pscustomobject]@{ + Name = "memory" + Package = "./cmd/memory" + BinaryPath = (Join-Path $BinRoot "memory.exe") + Port = 9088 + ProbeType = "tcp" + ProbeTarget = $null + StartTimeoutSec = 150 + Dependencies = @() + }, + [pscustomobject]@{ + Name = "taskclassforum" + Package = "./cmd/taskclassforum" + BinaryPath = (Join-Path $BinRoot "taskclassforum.exe") + Port = 9090 + ProbeType = "tcp" + ProbeTarget = $null + StartTimeoutSec = 90 + Dependencies = @("task-class") + }, + [pscustomobject]@{ + Name = "active-scheduler" + Package = "./cmd/active-scheduler" + BinaryPath = (Join-Path $BinRoot "active-scheduler.exe") + Port = 9083 + ProbeType = "tcp" + ProbeTarget = $null + StartTimeoutSec = 120 + Dependencies = @("task", "schedule") + }, + [pscustomobject]@{ + Name = "agent" + Package = "./cmd/agent" + BinaryPath = (Join-Path $BinRoot "agent.exe") + Port = 9089 + ProbeType = "tcp" + ProbeTarget = $null + StartTimeoutSec = 180 + Dependencies = @("task", "schedule", "task-class", "memory") + }, + [pscustomobject]@{ + Name = "api" + Package = "./cmd/api" + BinaryPath = (Join-Path $BinRoot "api.exe") + Port = 8080 + ProbeType = "http" + ProbeTarget = "http://127.0.0.1:8080/api/v1/health" + StartTimeoutSec = 90 + Dependencies = @( + "userauth", + "task", + "schedule", + "task-class", + "course", + "tokenstore", + "notification", + "memory", + "taskclassforum", + "active-scheduler", + "agent" + ) + } + ) +} + +function Get-BackendServiceDefinition { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + foreach ($service in (Get-BackendServiceDefinitions)) { + if ($service.Name -eq $Name) { + return $service + } + } + + throw "Service definition not found: $Name" +} + +function Get-ServicePidFilePath { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service + ) + + return (Join-Path $PidRoot "$($Service.Name).json") +} + +function Get-ServiceStdoutLogPath { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service + ) + + return (Join-Path $LogRoot "$($Service.Name).log") +} + +function Get-ServiceStderrLogPath { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service + ) + + return (Join-Path $LogRoot "$($Service.Name).err.log") +} + +function New-ServiceLogPaths { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service + ) + + $stamp = Get-Date -Format "yyyyMMdd-HHmmss" + return [pscustomobject]@{ + Stdout = (Join-Path $LogRoot "$($Service.Name)-$stamp.log") + Stderr = (Join-Path $LogRoot "$($Service.Name)-$stamp.err.log") + } +} + +function Read-ServicePidInfo { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service + ) + + $path = Get-ServicePidFilePath -Service $Service + if (-not (Test-Path -LiteralPath $path)) { + return $null + } + + return (Get-Content -LiteralPath $path -Encoding UTF8 | ConvertFrom-Json) +} + +function Get-LatestServiceLogPath { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service, + + [Parameter(Mandatory = $true)] + [ValidateSet("stdout", "stderr")] + [string]$Stream + ) + + $pidInfo = Read-ServicePidInfo -Service $Service + if ($null -ne $pidInfo) { + $managedPath = if ($Stream -eq "stdout") { [string]$pidInfo.StdoutLogPath } else { [string]$pidInfo.StderrLogPath } + if (-not [string]::IsNullOrWhiteSpace($managedPath) -and (Test-Path -LiteralPath $managedPath)) { + return $managedPath + } + } + + if ($Stream -eq "stdout") { + $candidates = @(Get-ChildItem -LiteralPath $LogRoot -Filter "$($Service.Name)-*.log" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notlike "*.err.log" } | + Sort-Object LastWriteTime -Descending) + } + else { + $candidates = @(Get-ChildItem -LiteralPath $LogRoot -Filter "$($Service.Name)-*.err.log" -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending) + } + + if ($null -eq $candidates -or $candidates.Count -eq 0) { + return $null + } + + return $candidates[0].FullName +} + +function Write-ServicePidInfo { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service, + + [Parameter(Mandatory = $true)] + [System.Diagnostics.Process]$Process, + + [Parameter(Mandatory = $true)] + [string]$StdoutLogPath, + + [Parameter(Mandatory = $true)] + [string]$StderrLogPath + ) + + $payload = [pscustomobject]@{ + Name = $Service.Name + Pid = [int]$Process.Id + Port = [int]$Service.Port + StartedAt = (Get-Date).ToString("o") + BinaryPath = $Service.BinaryPath + StdoutLogPath = $StdoutLogPath + StderrLogPath = $StderrLogPath + } + + $path = Get-ServicePidFilePath -Service $Service + $payload | ConvertTo-Json | Set-Content -LiteralPath $path -Encoding UTF8 +} + +function Remove-ServicePidInfo { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service + ) + + $path = Get-ServicePidFilePath -Service $Service + if (Test-Path -LiteralPath $path) { + Remove-Item -LiteralPath $path -Force + } +} + +function Test-ProcessAlive { + param( + [Parameter(Mandatory = $true)] + [int]$ProcessId + ) + + return ($null -ne (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue)) +} + +function Get-ListeningProcessId { + param( + [Parameter(Mandatory = $true)] + [int]$Port + ) + + try { + $connection = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop | + Sort-Object -Property OwningProcess | + Select-Object -First 1 + if ($null -ne $connection) { + return [int]$connection.OwningProcess + } + } + catch { + } + + $pattern = "^\s*TCP\s+\S+:$Port\s+\S+\s+LISTENING\s+(\d+)\s*$" + foreach ($line in (netstat -ano -p tcp)) { + if ($line -match $pattern) { + return [int]$matches[1] + } + } + + return $null +} + +function Test-TcpPort { + param( + [Parameter(Mandatory = $true)] + [int]$Port, + + [string]$HostName = "127.0.0.1", + + [int]$TimeoutMs = 500 + ) + + $client = New-Object System.Net.Sockets.TcpClient + try { + $asyncResult = $client.BeginConnect($HostName, $Port, $null, $null) + if (-not $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) { + return $false + } + + $client.EndConnect($asyncResult) + return $true + } + catch { + return $false + } + finally { + $client.Close() + } +} + +function Test-HttpEndpoint { + param( + [Parameter(Mandatory = $true)] + [string]$Url, + + [int]$TimeoutSec = 2 + ) + + try { + $response = Invoke-WebRequest -Uri $Url -Method Get -TimeoutSec $TimeoutSec -UseBasicParsing + return ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300) + } + catch { + return $false + } +} + +function Test-ServiceHealthy { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service + ) + + switch ($Service.ProbeType) { + "http" { + return (Test-HttpEndpoint -Url $Service.ProbeTarget) + } + default { + return (Test-TcpPort -Port $Service.Port) + } + } +} + +function Wait-ServiceReady { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service, + + [int]$TimeoutSec, + + [int]$ProcessId + ) + + $effectiveTimeout = $TimeoutSec + if ($effectiveTimeout -le 0) { + $effectiveTimeout = [int]$Service.StartTimeoutSec + } + + $deadline = (Get-Date).AddSeconds($effectiveTimeout) + while ((Get-Date) -lt $deadline) { + if ($ProcessId -gt 0 -and -not (Test-ProcessAlive -ProcessId $ProcessId)) { + return $false + } + + if (Test-ServiceHealthy -Service $Service) { + return $true + } + + Start-Sleep -Seconds 1 + } + + return $false +} + +function Get-ServiceStatus { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service + ) + + $pidInfo = Read-ServicePidInfo -Service $Service + $managedPid = $null + $hasStalePid = $false + $stdoutLogPath = Get-ServiceStdoutLogPath -Service $Service + $stderrLogPath = Get-ServiceStderrLogPath -Service $Service + if ($null -ne $pidInfo) { + $managedPid = [int]$pidInfo.Pid + if (-not [string]::IsNullOrWhiteSpace($pidInfo.StdoutLogPath)) { + $stdoutLogPath = [string]$pidInfo.StdoutLogPath + } + if (-not [string]::IsNullOrWhiteSpace($pidInfo.StderrLogPath)) { + $stderrLogPath = [string]$pidInfo.StderrLogPath + } + if (-not (Test-ProcessAlive -ProcessId $managedPid)) { + $hasStalePid = $true + $managedPid = $null + } + } + + $healthy = Test-ServiceHealthy -Service $Service + $listenerPid = Get-ListeningProcessId -Port $Service.Port + + if ($null -ne $managedPid) { + if ($healthy) { + return [pscustomobject]@{ + State = "managed" + Summary = "managed-running" + Pid = $managedPid + Port = $Service.Port + HasStalePid = $false + StdoutLog = $stdoutLogPath + StderrLog = $stderrLogPath + } + } + + return [pscustomobject]@{ + State = "managed_unhealthy" + Summary = "managed-not-ready" + Pid = $managedPid + Port = $Service.Port + HasStalePid = $false + StdoutLog = $stdoutLogPath + StderrLog = $stderrLogPath + } + } + + if ($healthy) { + return [pscustomobject]@{ + State = "external" + Summary = "external-running" + Pid = $listenerPid + Port = $Service.Port + HasStalePid = $hasStalePid + StdoutLog = $null + StderrLog = $null + } + } + + if ($null -ne $listenerPid) { + return [pscustomobject]@{ + State = "conflict" + Summary = "port-conflict" + Pid = $listenerPid + Port = $Service.Port + HasStalePid = $hasStalePid + StdoutLog = $null + StderrLog = $null + } + } + + if ($hasStalePid) { + return [pscustomobject]@{ + State = "stale" + Summary = "stale-pid-file" + Pid = $null + Port = $Service.Port + HasStalePid = $true + StdoutLog = $stdoutLogPath + StderrLog = $stderrLogPath + } + } + + return [pscustomobject]@{ + State = "stopped" + Summary = "stopped" + Pid = $null + Port = $Service.Port + HasStalePid = $false + StdoutLog = $stdoutLogPath + StderrLog = $stderrLogPath + } +} + +function Build-ServiceBinary { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service + ) + + Initialize-DevState + Invoke-ExternalCommand -FilePath "go" -Arguments @( + "build", + "-o", + $Service.BinaryPath, + $Service.Package + ) -WorkingDirectory $BackendRoot +} + +function Start-ServiceProcess { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service + ) + + $logPaths = New-ServiceLogPaths -Service $Service + $stdoutLog = $logPaths.Stdout + $stderrLog = $logPaths.Stderr + + $process = Start-Process -FilePath $Service.BinaryPath ` + -WorkingDirectory $BackendRoot ` + -WindowStyle Hidden ` + -RedirectStandardOutput $stdoutLog ` + -RedirectStandardError $stderrLog ` + -PassThru + + Write-ServicePidInfo -Service $Service -Process $process -StdoutLogPath $stdoutLog -StderrLogPath $stderrLog + if (-not (Wait-ServiceReady -Service $Service -TimeoutSec $Service.StartTimeoutSec -ProcessId $process.Id)) { + if (Test-ProcessAlive -ProcessId $process.Id) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + } + Remove-ServicePidInfo -Service $Service + throw "Service start failed: $($Service.Name). Check logs: $stdoutLog and $stderrLog" + } + + return $process +} + +function Stop-ServiceProcess { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service + ) + + $pidInfo = Read-ServicePidInfo -Service $Service + if ($null -eq $pidInfo) { + return [pscustomobject]@{ + Name = $Service.Name + Action = "skip" + Message = "no managed process" + } + } + + $managedProcessId = [int]$pidInfo.Pid + if (Test-ProcessAlive -ProcessId $managedProcessId) { + try { + Stop-Process -Id $managedProcessId -ErrorAction Stop + } + catch { + Stop-Process -Id $managedProcessId -Force -ErrorAction SilentlyContinue + } + + $deadline = (Get-Date).AddSeconds(15) + while ((Get-Date) -lt $deadline -and (Test-ProcessAlive -ProcessId $managedProcessId)) { + Start-Sleep -Milliseconds 500 + } + + if (Test-ProcessAlive -ProcessId $managedProcessId) { + Stop-Process -Id $managedProcessId -Force -ErrorAction SilentlyContinue + } + } + + Remove-ServicePidInfo -Service $Service + return [pscustomobject]@{ + Name = $Service.Name + Action = "stopped" + Message = "managed process stopped" + } +} + +function Ensure-ServiceStarted { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service + ) + + $status = Get-ServiceStatus -Service $Service + if ($status.HasStalePid) { + Remove-ServicePidInfo -Service $Service + $status = Get-ServiceStatus -Service $Service + } + + switch ($status.State) { + "managed" { + return [pscustomobject]@{ + Name = $Service.Name + Port = $Service.Port + Action = "skip" + Detail = "managed and healthy" + } + } + "external" { + return [pscustomobject]@{ + Name = $Service.Name + Port = $Service.Port + Action = "skip" + Detail = "external process already healthy" + } + } + "managed_unhealthy" { + if (Wait-ServiceReady -Service $Service -TimeoutSec 15 -ProcessId $status.Pid) { + return [pscustomobject]@{ + Name = $Service.Name + Port = $Service.Port + Action = "skip" + Detail = "existing managed process became healthy" + } + } + + throw "Service $($Service.Name) already has a managed process, but it is still not healthy after waiting. Check log: $($status.StdoutLog)" + } + "conflict" { + throw "Service $($Service.Name) port $($Service.Port) is occupied by process $($status.Pid), but the health check failed. Resolve the conflict first." + } + } + + Assert-ServiceDependenciesReady -Service $Service + Build-ServiceBinary -Service $Service + $process = Start-ServiceProcess -Service $Service + return [pscustomobject]@{ + Name = $Service.Name + Port = $Service.Port + Action = "start" + Detail = "started, PID=$($process.Id)" + } +} + +function Restart-BackendService { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service + ) + + $status = Get-ServiceStatus -Service $Service + if ($status.HasStalePid) { + Remove-ServicePidInfo -Service $Service + $status = Get-ServiceStatus -Service $Service + } + + switch ($status.State) { + "external" { + throw "Service $($Service.Name) is managed by an external process. Refuse to restart it automatically." + } + "conflict" { + throw "Service $($Service.Name) port $($Service.Port) is occupied by process $($status.Pid), but the health check failed. Resolve the conflict first." + } + "managed" { + Stop-ServiceProcess -Service $Service | Out-Null + } + "managed_unhealthy" { + Stop-ServiceProcess -Service $Service | Out-Null + } + } + + Assert-ServiceDependenciesReady -Service $Service + Build-ServiceBinary -Service $Service + $process = Start-ServiceProcess -Service $Service + return [pscustomobject]@{ + Name = $Service.Name + Port = $Service.Port + Action = "restart" + Detail = "restarted, PID=$($process.Id)" + } +} + +function Get-ContainerStatus { + param( + [Parameter(Mandatory = $true)] + [string]$ContainerName + ) + + $status = cmd.exe /d /c "docker inspect --format ""{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}"" $ContainerName 2>nul" + if ($LASTEXITCODE -ne 0) { + return "unavailable" + } + + return (($status | Select-Object -First 1).Trim()) +} + +function Wait-ContainerStatus { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$ContainerDefinition + ) + + $deadline = (Get-Date).AddSeconds([int]$ContainerDefinition.TimeoutSec) + while ((Get-Date) -lt $deadline) { + $status = Get-ContainerStatus -ContainerName $ContainerDefinition.Container + if ($status -eq "healthy") { + return + } + + Start-Sleep -Seconds 2 + } + + throw "Container did not become healthy in time: $($ContainerDefinition.Name)" +} + +function Start-BackendInfrastructure { + Assert-ToolExists -Name "docker" + if (-not (Test-Path -LiteralPath $ComposeFile)) { + throw "docker-compose.yml not found: $ComposeFile" + } + + $composeServices = Get-InfrastructureComposeServices + Invoke-ExternalCommand -FilePath "docker" -Arguments (@( + "compose", + "-f", + $ComposeFile, + "up", + "-d" + ) + $composeServices) -WorkingDirectory $RepoRoot + + foreach ($definition in (Get-InfrastructureDefinitions)) { + Wait-ContainerStatus -ContainerDefinition $definition + } +} + +function Stop-BackendInfrastructure { + Assert-ToolExists -Name "docker" + $composeServices = Get-InfrastructureComposeServices + Invoke-ExternalCommand -FilePath "docker" -Arguments (@( + "compose", + "-f", + $ComposeFile, + "stop" + ) + $composeServices) -WorkingDirectory $RepoRoot +} + +function Get-InfrastructureStatus { + $rows = @() + foreach ($definition in (Get-InfrastructureDefinitions)) { + $rows += [pscustomobject]@{ + Name = $definition.Name + Container = $definition.Container + Status = (Get-ContainerStatus -ContainerName $definition.Container) + } + } + return $rows +} + +function Assert-ServiceDependenciesReady { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Service + ) + + foreach ($dependencyName in $Service.Dependencies) { + $dependency = Get-BackendServiceDefinition -Name $dependencyName + $status = Get-ServiceStatus -Service $dependency + if ($status.State -notin @("managed", "external")) { + throw "Dependency not ready for service $($Service.Name): $dependencyName (current state: $($status.Summary))" + } + } +} diff --git a/backend/scripts/dev-down.ps1 b/backend/scripts/dev-down.ps1 new file mode 100644 index 0000000..c3f3f93 --- /dev/null +++ b/backend/scripts/dev-down.ps1 @@ -0,0 +1,29 @@ +[CmdletBinding()] +param( + [switch]$StopInfra +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot\dev-common.ps1" + +Initialize-DevState + +$services = @(Get-BackendServiceDefinitions) +[array]::Reverse($services) + +$results = @() +foreach ($service in $services) { + Write-Host "==> Stop service: $($service.Name)" + $results += Stop-ServiceProcess -Service $service +} + +if ($StopInfra) { + Write-Host "==> Stop infrastructure containers" + Stop-BackendInfrastructure +} + +Write-Host "" +Write-Host "Backend stop summary:" +$results | Format-Table -AutoSize diff --git a/backend/scripts/dev-logs.ps1 b/backend/scripts/dev-logs.ps1 new file mode 100644 index 0000000..d3ad83e --- /dev/null +++ b/backend/scripts/dev-logs.ps1 @@ -0,0 +1,67 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$Service, + + [ValidateSet("stdout", "stderr", "both")] + [string]$Stream = "stdout", + + [int]$Tail = 80, + + [switch]$Follow +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot\dev-common.ps1" + +Initialize-DevState + +if ($Tail -le 0) { + throw "Tail must be greater than 0" +} + +$serviceDef = Get-BackendServiceDefinition -Name $Service +$streams = @( + if ($Stream -eq "both") { + "stdout" + "stderr" + } + else { + $Stream + } +) + +if ($Follow -and $streams.Count -gt 1) { + throw "Follow mode only supports a single stream. Use -Stream stdout or -Stream stderr." +} + +$paths = @() +foreach ($selectedStream in $streams) { + $path = Get-LatestServiceLogPath -Service $serviceDef -Stream $selectedStream + if ([string]::IsNullOrWhiteSpace($path)) { + throw "No log file found for service $Service stream $selectedStream" + } + + $paths += [pscustomobject]@{ + Stream = $selectedStream + Path = $path + } +} +$paths = @($paths) + +for ($index = 0; $index -lt $paths.Count; $index++) { + $entry = $paths[$index] + Write-Host "==> $Service [$($entry.Stream)]" + Write-Host "Path: $($entry.Path)" + if ($Follow) { + Get-Content -LiteralPath $entry.Path -Encoding UTF8 -Tail $Tail -Wait + return + } + + Get-Content -LiteralPath $entry.Path -Encoding UTF8 -Tail $Tail + if ($paths.Count -gt 1 -and $index -lt ($paths.Count - 1)) { + Write-Host "" + } +} diff --git a/backend/scripts/dev-status.ps1 b/backend/scripts/dev-status.ps1 new file mode 100644 index 0000000..6227ccb --- /dev/null +++ b/backend/scripts/dev-status.ps1 @@ -0,0 +1,32 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot\dev-common.ps1" + +Initialize-DevState + +$serviceRows = foreach ($service in (Get-BackendServiceDefinitions)) { + $status = Get-ServiceStatus -Service $service + [pscustomobject]@{ + Name = $service.Name + Port = $service.Port + Status = $status.Summary + PID = $(if ($null -ne $status.Pid) { $status.Pid } else { "-" }) + } +} + +Write-Host "Backend service status:" +$serviceRows | Format-Table -AutoSize + +if (Get-Command docker -ErrorAction SilentlyContinue) { + Write-Host "" + Write-Host "Infrastructure status:" + Get-InfrastructureStatus | Format-Table -AutoSize +} +else { + Write-Host "" + Write-Host "Infrastructure status: docker command not found" +} diff --git a/backend/scripts/dev-up.ps1 b/backend/scripts/dev-up.ps1 new file mode 100644 index 0000000..b505beb --- /dev/null +++ b/backend/scripts/dev-up.ps1 @@ -0,0 +1,27 @@ +[CmdletBinding()] +param( + [switch]$SkipInfra +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot\dev-common.ps1" + +Initialize-DevState +Assert-ToolExists -Name "go" + +if (-not $SkipInfra) { + Write-Host "==> Start infrastructure and wait for health checks" + Start-BackendInfrastructure +} + +$results = @() +:serviceLoop foreach ($service in (Get-BackendServiceDefinitions)) { + Write-Host "==> Process service: $($service.Name)" + $results += Ensure-ServiceStarted -Service $service +} + +Write-Host "" +Write-Host "Backend start summary:" +$results | Format-Table -AutoSize diff --git a/backend/scripts/service-restart.ps1 b/backend/scripts/service-restart.ps1 new file mode 100644 index 0000000..90ba242 --- /dev/null +++ b/backend/scripts/service-restart.ps1 @@ -0,0 +1,22 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$Service +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot\dev-common.ps1" + +Initialize-DevState +Assert-ToolExists -Name "go" + +$serviceDef = Get-BackendServiceDefinition -Name $Service + +Write-Host "==> Restart service: $($serviceDef.Name)" +$result = Restart-BackendService -Service $serviceDef + +Write-Host "" +Write-Host "Service restart summary:" +@($result) | Format-Table -AutoSize diff --git a/backend/scripts/services-down.ps1 b/backend/scripts/services-down.ps1 new file mode 100644 index 0000000..8189d4d --- /dev/null +++ b/backend/scripts/services-down.ps1 @@ -0,0 +1,7 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +& (Join-Path $PSScriptRoot "dev-down.ps1") diff --git a/backend/scripts/services-up.ps1 b/backend/scripts/services-up.ps1 new file mode 100644 index 0000000..eeef498 --- /dev/null +++ b/backend/scripts/services-up.ps1 @@ -0,0 +1,7 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +& (Join-Path $PSScriptRoot "dev-up.ps1") -SkipInfra diff --git a/backend/services/course/rpc/server.go b/backend/services/course/rpc/server.go index cef6450..4bb0473 100644 --- a/backend/services/course/rpc/server.go +++ b/backend/services/course/rpc/server.go @@ -13,8 +13,9 @@ import ( ) const ( - defaultListenOn = "0.0.0.0:9087" - defaultTimeout = 10 * time.Second + defaultListenOn = "0.0.0.0:9087" + // 课表导入与图片识别都可能持续较久,服务端默认超时统一放宽到 5 分钟,避免 zrpc 提前取消上下文。 + defaultTimeout = 5 * time.Minute defaultMaxRPCMessageSize = 8 * 1024 * 1024 rpcMessageSizePadding = 1024 * 1024 ) diff --git a/backend/services/runtime/conv/schedule.go b/backend/services/runtime/conv/schedule.go index eed8dab..919da53 100644 --- a/backend/services/runtime/conv/schedule.go +++ b/backend/services/runtime/conv/schedule.go @@ -163,6 +163,97 @@ func SchedulesToUserTodaySchedule(schedules []model.Schedule) []model.UserTodayS return result } +// SchedulesToExistingUserTodaySchedule 只返回真实存在的日程事件,不再补“无课/empty”占位。 +// +// 职责边界: +// 1. 负责把 DAO 返回的当天原子节次合并成前端展示事件。 +// 2. 负责保留真实课程/任务与嵌入任务信息。 +// 3. 不负责生成空档占位,空档展示交给前端按固定时间轴自行补齐。 +func SchedulesToExistingUserTodaySchedule(schedules []model.Schedule) []model.UserTodaySchedule { + if len(schedules) == 0 { + return []model.UserTodaySchedule{} + } + + dayGroups := make(map[string][]model.Schedule) + dayKeys := make([]string, 0, len(schedules)) + for _, s := range schedules { + dayKey := fmt.Sprintf("%d-%d", s.Week, s.DayOfWeek) + if _, ok := dayGroups[dayKey]; !ok { + dayKeys = append(dayKeys, dayKey) + } + dayGroups[dayKey] = append(dayGroups[dayKey], s) + } + sort.Strings(dayKeys) + + result := make([]model.UserTodaySchedule, 0, len(dayKeys)) + for _, dayKey := range dayKeys { + daySchedules := dayGroups[dayKey] + todayDTO := model.UserTodaySchedule{ + Week: daySchedules[0].Week, + DayOfWeek: daySchedules[0].DayOfWeek, + Events: []model.EventBrief{}, + } + + sectionMap := make(map[int]model.Schedule, len(daySchedules)) + for _, s := range daySchedules { + sectionMap[s.Section] = s + } + + order := 1 + for curr := 1; curr <= 12; { + slot, ok := sectionMap[curr] + if !ok { + curr++ + continue + } + + end := curr + for next := curr + 1; next <= 12; next++ { + nextSlot, exist := sectionMap[next] + if !exist || nextSlot.EventID != slot.EventID { + break + } + end = next + } + + location := "" + if slot.Event.Location != nil { + location = *slot.Event.Location + } + + brief := model.EventBrief{ + ID: slot.EventID, + Order: order, + Name: slot.Event.Name, + Location: location, + Type: slot.Event.Type, + StartTime: sectionTimeMap[curr][0], + EndTime: sectionTimeMap[end][1], + Span: end - curr + 1, + } + + for i := curr; i <= end; i++ { + if s, exist := sectionMap[i]; exist && s.EmbeddedTask != nil && s.EmbeddedTask.Content != nil { + brief.EmbeddedTaskInfo = model.TaskBrief{ + ID: s.EmbeddedTask.ID, + Name: *s.EmbeddedTask.Content, + Type: "task", + } + break + } + } + + todayDTO.Events = append(todayDTO.Events, brief) + curr = end + 1 + order++ + } + + result = append(result, todayDTO) + } + + return result +} + func SchedulesToUserWeeklySchedule(schedules []model.Schedule) *model.UserWeekSchedule { if len(schedules) == 0 { return &model.UserWeekSchedule{ diff --git a/backend/services/schedule/sv/service.go b/backend/services/schedule/sv/service.go index d497681..76b8e29 100644 --- a/backend/services/schedule/sv/service.go +++ b/backend/services/schedule/sv/service.go @@ -22,10 +22,13 @@ type ScheduleService struct { scheduleDAO *scheduledao.ScheduleDAO taskClassDAO *rootdao.TaskClassDAO repoManager *rootdao.RepoManager // 统一管理多个 DAO 的事务 - cacheDAO *rootdao.CacheDAO // 需要在 ScheduleService 中使用缓存 + cacheDAO *rootdao.CacheDAO // 负责 today/week/ongoing 等课表缓存读写 applyAdapter *applyadapter.GormApplyAdapter } +// scheduleNow 允许测试注入当前时钟,避免 today 接口再次退回到硬编码日期。 +var scheduleNow = time.Now + func NewScheduleService(scheduleDAO *scheduledao.ScheduleDAO, taskClassDAO *rootdao.TaskClassDAO, repoManager *rootdao.RepoManager, cacheDAO *rootdao.CacheDAO) *ScheduleService { return &ScheduleService{ scheduleDAO: scheduleDAO, @@ -50,6 +53,13 @@ func (ss *ScheduleService) SetApplyAdapter(applyAdapter *applyadapter.GormApplyA func (ss *ScheduleService) GetUserTodaySchedule(ctx context.Context, userID int) ([]model.UserTodaySchedule, error) { //1.先尝试从缓存获取数据 cachedResp, err := ss.cacheDAO.GetUserTodayScheduleFromCache(ctx, userID) + if err == nil { + normalized, changed := filterPlaceholderTodayEvents(cachedResp) + if changed { + _ = ss.cacheDAO.SetUserTodayScheduleToCache(ctx, userID, normalized) + } + cachedResp = normalized + } if err == nil { // 缓存命中,直接返回 return cachedResp, nil @@ -59,19 +69,18 @@ func (ss *ScheduleService) GetUserTodaySchedule(ctx context.Context, userID int) return nil, err } //2.获取当前日期 - /*curTime := time.Now().Format("2006-01-02")*/ - curTime := "2026-03-02" //测试数据 - week, dayOfWeek, err := conv.RealDateToRelativeDate(curTime) + week, dayOfWeek, err := currentRelativeWeekAndDay() if err != nil { return nil, err } //3.查询用户当天的日程安排 - schedules, err := ss.scheduleDAO.GetUserTodaySchedule(ctx, userID, week, dayOfWeek) //测试数据 + schedules, err := ss.scheduleDAO.GetUserTodaySchedule(ctx, userID, week, dayOfWeek) if err != nil { return nil, err } //4.转换为前端需要的格式 - todaySchedules := conv.SchedulesToUserTodaySchedule(schedules) + todaySchedules := conv.SchedulesToExistingUserTodaySchedule(schedules) + todaySchedules, _ = filterPlaceholderTodayEvents(todaySchedules) //5.将查询结果存入缓存,设置过期时间为当天结束 err = ss.cacheDAO.SetUserTodayScheduleToCache(ctx, userID, todaySchedules) return todaySchedules, nil @@ -114,6 +123,38 @@ func (ss *ScheduleService) GetUserWeeklySchedule(ctx context.Context, userID, we return weeklySchedule, nil } +// currentRelativeWeekAndDay 只负责把“当前时间”换算成课表内部使用的周次与星期。 +func currentRelativeWeekAndDay() (int, int, error) { + currentDate := scheduleNow().Format(conv.DateFormat) + return conv.RealDateToRelativeDate(currentDate) +} + +// filterPlaceholderTodayEvents 负责剔除旧缓存里遗留的 empty 占位事件,保证 today 接口只返回真实日程。 +func filterPlaceholderTodayEvents(schedules []model.UserTodaySchedule) ([]model.UserTodaySchedule, bool) { + if len(schedules) == 0 { + return []model.UserTodaySchedule{}, false + } + + result := make([]model.UserTodaySchedule, 0, len(schedules)) + changed := false + for _, day := range schedules { + filteredEvents := make([]model.EventBrief, 0, len(day.Events)) + for _, event := range day.Events { + if strings.EqualFold(strings.TrimSpace(event.Type), "empty") { + changed = true + continue + } + filteredEvents = append(filteredEvents, event) + } + if len(filteredEvents) != len(day.Events) { + changed = true + } + day.Events = filteredEvents + result = append(result, day) + } + return result, changed +} + func (ss *ScheduleService) DeleteScheduleEvent(ctx context.Context, requests []model.UserDeleteScheduleEvent, userID int) error { err := ss.repoManager.Transaction(ctx, func(txM *rootdao.RepoManager) error { for _, req := range requests { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a9a2613..4d1b151 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -52,7 +52,9 @@ router.afterEach(() => {
- + + +
diff --git a/frontend/src/components/assistant/ScheduleFineTuneModal.vue b/frontend/src/components/assistant/ScheduleFineTuneModal.vue index 278f4da..dfe9c50 100644 --- a/frontend/src/components/assistant/ScheduleFineTuneModal.vue +++ b/frontend/src/components/assistant/ScheduleFineTuneModal.vue @@ -772,14 +772,7 @@ const currentWeekEntries = computed(() => } /* 进场动画 */ -@keyframes board-item-spring { - 0% { opacity: 0; transform: scale(0.6) translateY(20px); } - 60% { opacity: 1; transform: scale(1.05) translateY(-2px); } - 100% { opacity: 1; transform: scale(1) translateY(0); } -} - .board-item-pop { - animation: board-item-spring 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both; } /* 弹窗核心动画:采用物理弹簧质感 */ @@ -796,26 +789,4 @@ const currentWeekEntries = computed(() => opacity: 0; } -.modal-enter-active .schedule-modal { - animation: modal-pop-in 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); -} - -.modal-leave-active .schedule-modal { - animation: modal-pop-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) reverse; -} - -@keyframes modal-pop-in { - 0% { - transform: scale(0.9) translateY(40px); - opacity: 0; - } - 60% { - transform: scale(1.02) translateY(-2px); - opacity: 1; - } - 100% { - transform: scale(1) translateY(0); - opacity: 1; - } -} diff --git a/frontend/src/components/assistant/ScheduleResultCard.vue b/frontend/src/components/assistant/ScheduleResultCard.vue index 12f6ccd..44f7565 100644 --- a/frontend/src/components/assistant/ScheduleResultCard.vue +++ b/frontend/src/components/assistant/ScheduleResultCard.vue @@ -44,23 +44,11 @@ const emit = defineEmits<{ border: 1px solid rgba(15, 23, 42, 0.06); border-radius: 20px; cursor: pointer; - transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + transition: all 0.2s ease; margin: 12px 0; position: relative; overflow: hidden; /* 弹出动画 */ - animation: schedule-card-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both; -} - -@keyframes schedule-card-pop { - 0% { - opacity: 0; - transform: scale(0.9) translateY(10px); - } - 100% { - opacity: 1; - transform: scale(1) translateY(0); - } } .schedule-result-card:hover { diff --git a/frontend/src/components/assistant/TaskClassPlanningPicker.vue b/frontend/src/components/assistant/TaskClassPlanningPicker.vue index 93883b3..b6507a3 100644 --- a/frontend/src/components/assistant/TaskClassPlanningPicker.vue +++ b/frontend/src/components/assistant/TaskClassPlanningPicker.vue @@ -353,18 +353,6 @@ function formatDateLabel(value: string) { display: grid; gap: 14px; /* 弹出动画 */ - animation: planning-panel-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both; -} - -@keyframes planning-panel-pop { - 0% { - opacity: 0; - transform: translateY(8px) scale(0.98); - } - 100% { - opacity: 1; - transform: translateY(0) scale(1); - } } .assistant-planning__panel-header strong { diff --git a/frontend/src/components/assistant/cards/BusinessCardRenderer.vue b/frontend/src/components/assistant/cards/BusinessCardRenderer.vue index 9b8c883..7d19751 100644 --- a/frontend/src/components/assistant/cards/BusinessCardRenderer.vue +++ b/frontend/src/components/assistant/cards/BusinessCardRenderer.vue @@ -43,12 +43,6 @@ const recordData = computed(() => props.payload.data as TaskRecordCardData) margin: 12px 0; display: flex; flex-direction: column; - animation: card-appear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); -} - -@keyframes card-appear { - 0% { opacity: 0; transform: scale(0.95) translateY(10px); } - 100% { opacity: 1; transform: scale(1) translateY(0); } } .unknown-card { diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue index eb2df21..d12e20c 100644 --- a/frontend/src/components/dashboard/AssistantPanel.vue +++ b/frontend/src/components/dashboard/AssistantPanel.vue @@ -4000,14 +4000,7 @@ onBeforeUnmount(() => {