Version: 0.9.79.dev.260506
后端: 1. 本地后端启动体系收口到 `backend/scripts`,移除 `cmd/all` 聚合入口,并将仓库根兼容启动语义收敛为 `StartAPI` 别名;新增 dev-up / dev-down / services-up / services-down / dev-status / dev-logs / service-restart 脚本,统一托管多服务进程、日志、PID 与基础设施启动。 2. 课表服务超时口径统一放宽到 5 分钟,覆盖 gateway / client / rpc server / config example,避免课表导入与图片识别在长耗时场景下被内层提前截断。 3. `today` 课表查询修正为读取真实当前日期,不再使用硬编码测试日期;同时剔除旧缓存与返回结果里的 `empty` 占位事件,后端只返回真实日程,空档改由前端时间轴自行补齐。 前端: 4. 首页路由切回改为复用 `DashboardView` 实例,补 `keep-alive`、`onActivated` 与双帧缩放重算,修复从侧栏返回首页时首帧布局放大与重复加载闪动问题。 5. 首页加载态与今日时间线口径收口:移除额外 800ms `pageLoading` 人为延迟,task / schedule 改为分开驱动;时间线忽略 `empty` 事件,并统一空档文案为“无课”。 6. 收敛助手页与首页若干进场/弹性动画,降低结果卡片、微调弹窗、思考区与面板切换时的抖动感。 仓库: 7. README 补充后端本地快速启动说明,`.gitignore` 忽略 `backend/.dev` 脚本运行态产物。
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,3 +31,4 @@ backend/config.yaml
|
||||
.staticcheck-cache/
|
||||
.claude/
|
||||
.omc/
|
||||
/backend/.dev/
|
||||
|
||||
27
README.md
27
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 <name>` 用于重启单个脚本托管的后端服务;若该服务由外部进程托管,则会直接拒绝操作。
|
||||
- `scripts/services-down.ps1` 只停止脚本托管的后端服务进程。
|
||||
- `scripts/dev-down.ps1` 默认只停止脚本托管的后端进程;加 `-StopInfra` 才会一并停止 Docker 基础设施。
|
||||
|
||||
# 1 项目概览
|
||||
|
||||
## 1.1 总体介绍
|
||||
|
||||
@@ -15,7 +15,8 @@ import (
|
||||
|
||||
const (
|
||||
defaultEndpoint = "127.0.0.1:9087"
|
||||
defaultTimeout = 10 * time.Second
|
||||
// 课表导入可能一次展开大量周次与节次,RPC 默认超时与网关保持一致,避免内层先被截断。
|
||||
defaultTimeout = 5 * time.Minute
|
||||
defaultMaxRPCMessageSize = 8 * 1024 * 1024
|
||||
rpcMessageSizePadding = 1024 * 1024
|
||||
)
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package main
|
||||
|
||||
import "github.com/LoveLosita/smartflow/backend/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.StartAll()
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
@@ -108,7 +108,7 @@ course:
|
||||
listenOn: "0.0.0.0:9087"
|
||||
endpoints:
|
||||
- "127.0.0.1:9087"
|
||||
timeout: 10s
|
||||
timeout: 5m
|
||||
|
||||
# 主动调度服务配置。
|
||||
activeScheduler:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
900
backend/scripts/dev-common.ps1
Normal file
900
backend/scripts/dev-common.ps1
Normal file
@@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
29
backend/scripts/dev-down.ps1
Normal file
29
backend/scripts/dev-down.ps1
Normal file
@@ -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
|
||||
67
backend/scripts/dev-logs.ps1
Normal file
67
backend/scripts/dev-logs.ps1
Normal file
@@ -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 ""
|
||||
}
|
||||
}
|
||||
32
backend/scripts/dev-status.ps1
Normal file
32
backend/scripts/dev-status.ps1
Normal file
@@ -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"
|
||||
}
|
||||
27
backend/scripts/dev-up.ps1
Normal file
27
backend/scripts/dev-up.ps1
Normal file
@@ -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
|
||||
22
backend/scripts/service-restart.ps1
Normal file
22
backend/scripts/service-restart.ps1
Normal file
@@ -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
|
||||
7
backend/scripts/services-down.ps1
Normal file
7
backend/scripts/services-down.ps1
Normal file
@@ -0,0 +1,7 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
& (Join-Path $PSScriptRoot "dev-down.ps1")
|
||||
7
backend/scripts/services-up.ps1
Normal file
7
backend/scripts/services-up.ps1
Normal file
@@ -0,0 +1,7 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
& (Join-Path $PSScriptRoot "dev-up.ps1") -SkipInfra
|
||||
@@ -14,7 +14,8 @@ import (
|
||||
|
||||
const (
|
||||
defaultListenOn = "0.0.0.0:9087"
|
||||
defaultTimeout = 10 * time.Second
|
||||
// 课表导入与图片识别都可能持续较久,服务端默认超时统一放宽到 5 分钟,避免 zrpc 提前取消上下文。
|
||||
defaultTimeout = 5 * time.Minute
|
||||
defaultMaxRPCMessageSize = 8 * 1024 * 1024
|
||||
rpcMessageSizePadding = 1024 * 1024
|
||||
)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -52,7 +52,9 @@ router.afterEach(() => {
|
||||
<MainSidebar />
|
||||
<div class="smartmate-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive include="DashboardView">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4000,14 +4000,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes assistant-item-pop {
|
||||
0% { opacity: 0; transform: scale(0.98) translateY(10px); }
|
||||
60% { opacity: 1; transform: scale(1.01) translateY(-1px); }
|
||||
100% { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.dashboard-item-pop {
|
||||
animation: assistant-item-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
animation-delay: var(--anim-delay, 0s);
|
||||
}
|
||||
|
||||
@@ -4742,7 +4735,6 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-top: 4px solid #f59e0b; /* 警告色顶部装饰条 */
|
||||
animation: confirm-card-enter 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.assistant-confirm-card__header {
|
||||
@@ -5261,19 +5253,16 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* 推理框展开收起弹性动效 */
|
||||
.reasoning-bounce-enter-active {
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
transform-origin: top center;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.reasoning-bounce-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
transform-origin: top center;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.reasoning-bounce-enter-from,
|
||||
.reasoning-bounce-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-15px);
|
||||
}
|
||||
|
||||
.chat-message__reasoning-title {
|
||||
@@ -5826,11 +5815,6 @@ onBeforeUnmount(() => {
|
||||
to { background-position: 0% 0; }
|
||||
}
|
||||
|
||||
@keyframes confirm-card-enter {
|
||||
0% { opacity: 0; transform: translateY(10px) scale(0.985); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0% { box-shadow: 0 0 0 0 rgba(90, 152, 255, 0.34); }
|
||||
70% { box-shadow: 0 0 0 8px rgba(90, 152, 255, 0); }
|
||||
|
||||
@@ -62,6 +62,7 @@ function buildTimeKey(start?: string | null, end?: string | null) {
|
||||
const eventMap = computed(() => {
|
||||
const map = new Map<string, TodayEvent>()
|
||||
for (const event of props.events ?? []) {
|
||||
if ((event.type || '').trim() === 'empty') continue
|
||||
map.set(buildTimeKey(event.start_time, event.end_time), event)
|
||||
}
|
||||
return map
|
||||
@@ -92,8 +93,8 @@ const renderSlots = computed<RenderSlot[]>(() =>
|
||||
key: slot.key,
|
||||
kind: 'event',
|
||||
timeText: formatTimeRange(event?.start_time || slot.startTime, event?.end_time || slot.endTime),
|
||||
title: event?.name || '今日无安排',
|
||||
locationText: event?.location || '休息时间',
|
||||
title: event?.name || '无课',
|
||||
locationText: event?.location || '当前时段无课程安排',
|
||||
tone: resolveCardTone(event),
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -11,10 +11,13 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import type { TaskItem, TodayEvent } from '@/types/dashboard'
|
||||
import { formatHeaderDate } from '@/utils/date'
|
||||
|
||||
defineOptions({
|
||||
name: 'DashboardView',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const pageLoading = ref(true)
|
||||
const taskLoading = ref(true)
|
||||
const scheduleLoading = ref(true)
|
||||
const saveTaskLoading = ref(false)
|
||||
@@ -32,6 +35,8 @@ const dashboardMainScale = ref(1)
|
||||
const tasks = ref<TaskItem[]>([])
|
||||
const todayEvents = ref<TodayEvent[]>([])
|
||||
|
||||
let dashboardScaleAnimationFrame = 0
|
||||
|
||||
const taskForm = reactive<{
|
||||
title: string
|
||||
priority_group: number
|
||||
@@ -110,10 +115,7 @@ async function loadScheduleData() {
|
||||
}
|
||||
|
||||
async function loadDashboardData() {
|
||||
pageLoading.value = true
|
||||
const minLoadingTimer = new Promise((resolve) => setTimeout(resolve, 800))
|
||||
await Promise.allSettled([loadTasksData(), loadScheduleData(), minLoadingTimer])
|
||||
pageLoading.value = false
|
||||
await Promise.allSettled([loadTasksData(), loadScheduleData()])
|
||||
}
|
||||
|
||||
async function handleTaskToggle(task: TaskItem) {
|
||||
@@ -228,31 +230,52 @@ function syncDashboardMainScale() {
|
||||
const topbar = dashboardTopbarRef.value
|
||||
const content = dashboardContentRef.value
|
||||
if (!main || !inner || !topbar || !content || window.innerWidth <= 980) { dashboardMainScale.value = 1; return }
|
||||
dashboardMainScale.value = 1
|
||||
window.requestAnimationFrame(() => {
|
||||
|
||||
const availableHeight = main.clientHeight
|
||||
const gridGap = 10
|
||||
const naturalHeight = topbar.getBoundingClientRect().height + content.scrollHeight + gridGap
|
||||
if (!availableHeight || !naturalHeight) { dashboardMainScale.value = 1; return }
|
||||
const nextScale = Math.min(1, (availableHeight / naturalHeight) * 0.96)
|
||||
dashboardMainScale.value = Number(nextScale.toFixed(4))
|
||||
const naturalHeight = topbar.offsetHeight + content.scrollHeight + gridGap
|
||||
if (!availableHeight || !naturalHeight) return
|
||||
|
||||
const nextScale = Number(Math.min(1, (availableHeight / naturalHeight) * 0.96).toFixed(4))
|
||||
dashboardMainScale.value = nextScale
|
||||
}
|
||||
|
||||
function scheduleDashboardMainScaleSync() {
|
||||
if (typeof window === 'undefined') return
|
||||
if (dashboardScaleAnimationFrame) window.cancelAnimationFrame(dashboardScaleAnimationFrame)
|
||||
|
||||
// 1. 侧栏切回首页时,外层布局会比首页内容晚一点稳定。
|
||||
// 2. 延后两帧再测量,只处理“回首页首帧偏大”的问题,避免持续重算。
|
||||
dashboardScaleAnimationFrame = window.requestAnimationFrame(() => {
|
||||
dashboardScaleAnimationFrame = window.requestAnimationFrame(() => {
|
||||
dashboardScaleAnimationFrame = 0
|
||||
syncDashboardMainScale()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
scheduleDashboardMainScaleSync()
|
||||
await loadDashboardData()
|
||||
await nextTick()
|
||||
syncDashboardMainScale()
|
||||
window.addEventListener('resize', syncDashboardMainScale)
|
||||
scheduleDashboardMainScaleSync()
|
||||
window.addEventListener('resize', scheduleDashboardMainScaleSync)
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
await nextTick()
|
||||
scheduleDashboardMainScaleSync()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', syncDashboardMainScale)
|
||||
if (dashboardScaleAnimationFrame) window.cancelAnimationFrame(dashboardScaleAnimationFrame)
|
||||
window.removeEventListener('resize', scheduleDashboardMainScaleSync)
|
||||
})
|
||||
|
||||
watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], async () => {
|
||||
watch([() => tasks.value.length, () => todayEvents.value.length, taskLoading, scheduleLoading], async () => {
|
||||
await nextTick()
|
||||
syncDashboardMainScale()
|
||||
scheduleDashboardMainScaleSync()
|
||||
}, { flush: 'post' })
|
||||
</script>
|
||||
|
||||
@@ -277,7 +300,7 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
||||
</header>
|
||||
|
||||
<div ref="dashboardContentRef" class="dashboard-content page-shell">
|
||||
<TodayTimeline :style="{ '--anim-delay': '0.04s' }" :events="todayEvents" :loading="scheduleLoading || pageLoading" />
|
||||
<TodayTimeline :style="{ '--anim-delay': '0.04s' }" :events="todayEvents" :loading="scheduleLoading" />
|
||||
|
||||
<div class="dashboard-actions dashboard-item-pop" :style="{ '--anim-delay': '0.08s' }">
|
||||
<button type="button" class="dashboard-actions__primary" @click="openCreateTaskDialog">添加任务</button>
|
||||
@@ -295,7 +318,7 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
||||
:empty-text="quadrantMeta[group].emptyText"
|
||||
:count="groupedTasks[group].length"
|
||||
:tasks="groupedTasks[group]"
|
||||
:loading="taskLoading || pageLoading"
|
||||
:loading="taskLoading"
|
||||
@toggle="handleTaskToggle"
|
||||
@edit="handleTaskEdit"
|
||||
@delete="handleTaskDelete"
|
||||
|
||||
Reference in New Issue
Block a user