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:
Losita
2026-05-06 12:59:29 +08:00
parent d4afc6ef74
commit 7d324b77aa
27 changed files with 1329 additions and 135 deletions

1
.gitignore vendored
View File

@@ -31,3 +31,4 @@ backend/config.yaml
.staticcheck-cache/
.claude/
.omc/
/backend/.dev/

View File

@@ -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 总体介绍

View File

@@ -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
)

View File

@@ -1,7 +0,0 @@
package main
import "github.com/LoveLosita/smartflow/backend/cmd"
func main() {
cmd.StartAll()
}

View File

@@ -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。

View File

@@ -108,7 +108,7 @@ course:
listenOn: "0.0.0.0:9087"
endpoints:
- "127.0.0.1:9087"
timeout: 10s
timeout: 5m
# 主动调度服务配置。
activeScheduler:

View File

@@ -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

View File

@@ -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()

View 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))"
}
}
}

View 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

View 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 ""
}
}

View 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"
}

View 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

View 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

View File

@@ -0,0 +1,7 @@
[CmdletBinding()]
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
& (Join-Path $PSScriptRoot "dev-down.ps1")

View File

@@ -0,0 +1,7 @@
[CmdletBinding()]
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
& (Join-Path $PSScriptRoot "dev-up.ps1") -SkipInfra

View File

@@ -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
)

View File

@@ -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{

View File

@@ -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 {

View File

@@ -52,7 +52,9 @@ router.afterEach(() => {
<MainSidebar />
<div class="smartmate-content">
<router-view v-slot="{ Component }">
<component :is="Component" />
<keep-alive include="DashboardView">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
</div>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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); }

View File

@@ -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),
}
}),

View File

@@ -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 availableHeight = main.clientHeight
const gridGap = 10
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"