Initial commit: RHINE LAB · ANALYSIS OS:三维界面与动效实验(本仓库不含个人归档索引数据)

This commit is contained in:
WpyQwq
2026-09-19 13:04:56 +08:00
commit 3f422218a5
1098 changed files with 39410 additions and 0 deletions
+418
View File
@@ -0,0 +1,418 @@
<#
Rhine Lab UI · 本地归档后端(「数据库」)服务管理
这个后端就是 RhineLabUI 的数据层:它索引 E:\归档(当前 4.5 万文件 / 110 GB),
生成 content/archives.json、public/archive-index.json 与 47 份摘要,并在归档变动时
自动重建快照;界面上的 LOCAL ARCHIVE 面板与「文件位置」都靠它。
它只监听 127.0.0.1,且每次启动生成一枚访问令牌写入 .archive-token。
常用:
install 安装开机自启(登录时触发)+ 立即启动 ← 一次性
ensure 没在跑就拉起,在跑就什么都不做 ← 一键脚本会调
status 看状态(任务 / 端口 / 进程 / 快照 / 归档根)
stop 停掉后端
uninstall 取消开机自启并停掉后端
logs 看后端最近的输出
自启的两种装法(install 会自己挑):
· 管理员会话 → 计划任务 WpywArchiveService(登录触发、失败自动重试 3 次)
· 普通会话 → 「启动」文件夹里的快捷方式(不需要提权,效果相同)
实测本机普通会话注册计划任务会被「拒绝访问」,所以默认走「启动」文件夹这条。
为什么用「登录时」而不是「开机 + SYSTEM」:
1) 服务要用 explorer.exe /select 打开资源管理器定位文件(SHOW IN FOLDER)。
以 SYSTEM 跑会落在会话 0,用户屏幕上什么都不会出现 —— 那功能会静默失效。
2) 以当前用户跑,它写出的 archives.json / archive-index.json 归属正常,
用户随后自己跑 npm run build 不会被权限挡住。
3) 开机触发要求把账户口令存进计划任务,本机没有这个必要。
结论:本机单人使用,登录即等于开机可用,且功能无损。窗口由 VBS 隐藏,不弹黑框。
#>
[CmdletBinding()]
param(
[ValidateSet('ensure', 'start', 'stop', 'restart', 'status', 'install', 'uninstall', 'logs', 'run')]
[string]$Action = 'status'
)
$ErrorActionPreference = 'Stop'
$TaskName = 'WpywArchiveService'
$ProjectRoot = Split-Path -Parent $PSScriptRoot
$ConfigPath = Join-Path $ProjectRoot 'archive.config.json'
$ServiceJs = Join-Path $ProjectRoot 'scripts\archive-service.mjs'
$TokenFile = Join-Path $ProjectRoot '.archive-token'
$LogDir = Join-Path $ProjectRoot 'logs'
$LogFile = Join-Path $LogDir 'archive-service.log'
$HiddenVbs = Join-Path $ProjectRoot 'scripts\archive-service-hidden.vbs'
$WScript = Join-Path $env:SystemRoot 'System32\wscript.exe'
$StartupLnk = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::Startup)) '归档后端 (Rhine Lab).lnk'
function Write-Head($text) { Write-Host ''; Write-Host " $text" -ForegroundColor Cyan }
function Write-Item($label, $value) {
Write-Host (' {0,-12} {1}' -f $label, $value)
}
function Write-Ok($text) { Write-Host " $text" -ForegroundColor Green }
function Write-Info2($text) { Write-Host " $text" -ForegroundColor Gray }
function Write-Warn2($text) { Write-Host " $text" -ForegroundColor Yellow }
function Get-NodeExe {
$cmd = Get-Command node.exe -ErrorAction SilentlyContinue
if ($cmd -and $cmd.Source) { return $cmd.Source }
$fallback = 'C:\Program Files\nodejs\node.exe'
if (Test-Path -LiteralPath $fallback) { return $fallback }
throw '找不到 node.exe,请先安装 Node.js。'
}
function Get-ArchiveConfig {
if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "缺少配置文件:$ConfigPath" }
return (Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json)
}
function Get-Port {
$cfg = Get-ArchiveConfig
if ($cfg.port) { return [int]$cfg.port }
return 43117
}
function Get-Token {
if (-not (Test-Path -LiteralPath $TokenFile)) { return '' }
return (Get-Content -LiteralPath $TokenFile -Raw).Trim()
}
function Get-ListenerProcessId {
param([int]$Port)
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($conn) { return [int]$conn.OwningProcess }
return 0
}
function Get-CommandLineOf {
param([int]$ProcessId)
$p = Get-CimInstance Win32_Process -Filter "ProcessId=$ProcessId" -ErrorAction SilentlyContinue
if ($p) { return [string]$p.CommandLine }
return ''
}
function Test-BackendListening {
param([int]$Port)
$procId = Get-ListenerProcessId -Port $Port
if ($procId -le 0) { return $false }
return ((Get-CommandLineOf -ProcessId $procId) -like '*archive-service.mjs*')
}
function Get-BackendState {
param([int]$Port)
$state = [ordered]@{
listening = $false; pid = 0; healthy = $false
watching = $false; lastScan = $null; totals = $null; root = $null; lastError = $null; httpError = $null
}
$procId = Get-ListenerProcessId -Port $Port
if ($procId -le 0) { return $state }
if ((Get-CommandLineOf -ProcessId $procId) -notlike '*archive-service.mjs*') { return $state }
$state.listening = $true
$state.pid = $procId
try {
$headers = @{}
$token = Get-Token
if ($token) { $headers['x-archive-token'] = $token }
$r = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/status" -Headers $headers -TimeoutSec 5
$state.healthy = $true
$state.watching = [bool]$r.watching
$state.lastScan = $r.lastScan
$state.totals = $r.totals
$state.root = $r.root
$state.lastError = $r.lastError
} catch {
$state.httpError = $_.Exception.Message
}
return $state
}
function Wait-BackendReady {
param([int]$Port, [int]$TimeoutSec = 150)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline) {
$s = Get-BackendState -Port $Port
# watching 只有首轮扫描 + 快照写完之后才会为真
if ($s.healthy -and $s.watching) { return $s }
Start-Sleep -Milliseconds 800
}
return (Get-BackendState -Port $Port)
}
function Write-HiddenLauncher {
$node = Get-NodeExe
$ps1 = $PSCommandPath
$lines = @(
"' Generated by archive-service.ps1 - do not edit by hand."
"' Purpose: start the archive backend with a fully hidden window (style 0), so"
"' that logging on does not flash a black console. Also wraps it so the backend"
"' keeps its own log file. NOTE: this file is ASCII-only on purpose - wscript"
"' reads .vbs in the system ANSI code page, so non-ASCII here would garble."
'Set sh = CreateObject("WScript.Shell")'
('sh.CurrentDirectory = "' + $ProjectRoot + '"')
('sh.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File " & Chr(34) & "' + $ps1 + '" & Chr(34) & " -Action run", 0, False')
)
Set-Content -LiteralPath $HiddenVbs -Value $lines -Encoding ASCII
return $HiddenVbs
}
function Test-Elevated {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
return (New-Object Security.Principal.WindowsPrincipal($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Install-ScheduledTaskAutostart {
$vbs = Write-HiddenLauncher
$userId = "$env:USERDOMAIN\$env:USERNAME"
$action = New-ScheduledTaskAction -Execute $WScript -Argument ('"{0}"' -f $vbs) -WorkingDirectory $ProjectRoot
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $userId
$taskPrincipal = New-ScheduledTaskPrincipal -UserId $userId -LogonType Interactive -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit ([TimeSpan]::Zero) `
-RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 2) `
-MultipleInstances IgnoreNew -StartWhenAvailable
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
-Principal $taskPrincipal -Settings $settings -Force -ErrorAction Stop `
-Description '莱茵生命档案 UI 的本地归档后端(127.0.0.1:43117)。登录时后台静默启动,随归档变动自动重建索引。' | Out-Null
Write-Ok "已注册计划任务 $TaskName(登录触发 / 后台静默 / 失败自动重试 3 次)"
}
function Install-StartupShortcut {
$vbs = Write-HiddenLauncher
$dir = [Environment]::GetFolderPath([Environment+SpecialFolder]::Startup)
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
$shell = New-Object -ComObject WScript.Shell
$lnk = $shell.CreateShortcut($StartupLnk)
$lnk.TargetPath = $WScript
$lnk.Arguments = '"' + $vbs + '"'
$lnk.WorkingDirectory = $ProjectRoot
$lnk.WindowStyle = 7
$lnk.Description = '莱茵生命档案 UI 的本地归档后端(登录时后台静默启动)'
$lnk.Save()
[void][Runtime.InteropServices.Marshal]::ReleaseComObject($shell)
Write-Ok "已在「启动」文件夹放置快捷方式:$StartupLnk"
Write-Info2 '登录后自动在后台静默启动,不弹窗口;删掉这个快捷方式即取消自启。'
}
function Install-Autostart {
$vbs = Write-HiddenLauncher
if (Test-Elevated) {
try {
Install-ScheduledTaskAutostart
} catch {
Write-Warn2 "计划任务注册失败($($_.Exception.Message)),改用「启动」文件夹。"
Install-StartupShortcut
}
} else {
Write-Info2 '当前不是管理员会话 —— 计划任务注册会被拒绝,改用「启动」文件夹实现登录自启。'
Write-Info2 '两者效果相同;想要带「失败自动重试」的计划任务版,请用管理员身份再跑一次 -Action install。'
Install-StartupShortcut
}
if (-not (Test-BackendListening -Port (Get-Port))) {
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($task) { Start-ScheduledTask -TaskName $TaskName }
else { Start-Process -FilePath $WScript -ArgumentList ('"{0}"' -f $vbs) -WorkingDirectory $ProjectRoot | Out-Null }
Write-Host ' 已立即启动后端,等待首轮扫描(4.5 万条目,十几秒到一分钟)……'
$state = Wait-BackendReady -Port (Get-Port) -TimeoutSec 300
if ($state.healthy -and $state.watching) {
Write-Ok "后端就绪:端口 $(Get-Port),PID $($state.pid)"
} else {
Write-Warn2 "后端尚未就绪,稍后用 -Action status 复查;日志:$LogFile"
}
}
}
function Uninstall-Autostart {
Stop-Backend
$removed = $false
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($task) {
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
Write-Ok "已删除计划任务 $TaskName"
$removed = $true
}
if (Test-Path -LiteralPath $StartupLnk) {
Remove-Item -LiteralPath $StartupLnk -Force
Write-Ok "已删除「启动」文件夹里的快捷方式"
$removed = $true
}
if (-not $removed) { Write-Warn2 '没有找到任何自启项,跳过。' }
Remove-Item -LiteralPath $HiddenVbs -Force -ErrorAction SilentlyContinue
}
function Start-Backend {
$port = Get-Port
if (Test-BackendListening -Port $port) {
Write-Ok "后端已在运行(端口 $port)"
return $true
}
$other = Get-ListenerProcessId -Port $port
if ($other -gt 0) {
throw "端口 $port 已被 PID $other 占用,而它不是归档后端。请先处理冲突。"
}
$node = Get-NodeExe
if (-not (Test-Path -LiteralPath $ServiceJs)) { throw "找不到后端脚本:$ServiceJs" }
if (-not (Test-Path -LiteralPath $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null }
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($task) {
Start-ScheduledTask -TaskName $TaskName
} else {
$vbs = Write-HiddenLauncher
Start-Process -FilePath $WScript -ArgumentList ('"{0}"' -f $vbs) -WorkingDirectory $ProjectRoot | Out-Null
}
Write-Host ' 已拉起后端,等待首轮扫描(4.5 万条目大约十几秒)……'
$state = Wait-BackendReady -Port $port -TimeoutSec 180
if ($state.healthy -and $state.watching) {
Write-Ok "后端就绪:端口 $port,PID $($state.pid)"
return $true
}
Write-Warn2 "后端没有在预期时间内就绪。最近日志:$LogFile"
if ($state.httpError) { Write-Warn2 " HTTP:$($state.httpError)" }
if ($state.lastError) { Write-Warn2 " 快照:$($state.lastError)" }
return $false
}
function Stop-Backend {
$port = Get-Port
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($task) { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue }
$targets = New-Object System.Collections.Generic.List[int]
$procId = Get-ListenerProcessId -Port $port
if ($procId -gt 0 -and (Get-CommandLineOf -ProcessId $procId) -like '*archive-service.mjs*') {
$targets.Add($procId)
}
# 兜底:按命令行找漏网的 node / 宿主
foreach ($p in (Get-CimInstance Win32_Process -ErrorAction SilentlyContinue)) {
$cl = [string]$p.CommandLine
if (-not $cl) { continue }
if ($cl -like '*archive-service.mjs*') { $targets.Add([int]$p.ProcessId) }
elseif ($cl -like '*-Action run*' -and $cl -like '*archive-service.ps1*') { $targets.Add([int]$p.ProcessId) }
elseif ($cl -like '*archive-service-hidden.vbs*') { $targets.Add([int]$p.ProcessId) }
}
$unique = $targets | Sort-Object -Unique
if ($unique.Count -eq 0) {
Write-Warn2 '后端本来就没在运行。'
return
}
foreach ($t in $unique) { Stop-Process -Id $t -Force -ErrorAction SilentlyContinue }
Start-Sleep -Milliseconds 600
if (Get-ListenerProcessId -Port $port) {
Write-Warn2 "停止后端口 $port 仍被占用,请手动检查。"
} else {
Write-Ok "已停止后端(结束 $($unique.Count) 个进程)"
}
}
function Show-Status {
$port = Get-Port
$cfg = Get-ArchiveConfig
$state = Get-BackendState -Port $port
Write-Head '归档后端(数据库)'
Write-Item '归档根' "$($cfg.root)"
Write-Item '端口' "$port(仅本机 127.0.0.1)"
if ($state.listening) {
Write-Item '进程' "PID $($state.pid) 运行中"
} else {
Write-Item '进程' '未运行'
}
if ($state.healthy) {
Write-Item '快照' "已就绪 watching=$($state.watching)"
if ($state.totals) {
$gb = [math]::Round($state.totals.bytes / 1GB, 2)
Write-Item '索引' "$($state.totals.files) 文件 / $($state.totals.dirs) 目录 / $gb GB"
}
Write-Item '上次扫描' "$($state.lastScan)"
if ($state.lastError) { Write-Item '最近错误' "$($state.lastError)" }
} elseif ($state.listening) {
Write-Item '接口' "端口在听但 /status 不通:$($state.httpError)"
Write-Item '提示' '令牌可能是旧的,重启一次后端即可'
} else {
Write-Item '接口' '不可达(后端没在跑)'
}
$tokenAge = '无令牌文件'
if (Test-Path -LiteralPath $TokenFile) {
$t = Get-Item -LiteralPath $TokenFile
$tokenAge = "$($t.LastWriteTime)($(($t.Length)) 字节)"
}
Write-Item '令牌' $tokenAge
Write-Head '开机自启'
$installed = $false
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($task) {
$info = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue
Write-Item '计划任务' "$TaskName 状态=$($task.State)"
if ($info) { Write-Item '上次结果' "$($info.LastRunTime) code=$($info.LastTaskResult)" }
$installed = $true
}
if (Test-Path -LiteralPath $StartupLnk) {
Write-Item '启动快捷方式' $StartupLnk
$installed = $true
}
if ($installed) {
Write-Ok '已启用:登录时后台静默启动(不弹窗口)'
} else {
Write-Warn2 "未安装。执行 `"$($PSCommandPath) -Action install`" 可装上。"
}
Write-Host ''
}
function Show-Logs {
if (-not (Test-Path -LiteralPath $LogFile)) {
Write-Warn2 "还没有日志:$LogFile"
return
}
Write-Head "后端日志尾部($LogFile)"
Get-Content -LiteralPath $LogFile -Tail 30 -Encoding UTF8 | ForEach-Object { Write-Host " $_" }
Write-Host ''
}
function Invoke-BackendRun {
# 计划任务的真正入口:已经在跑就直接退出,避免重复实例抢端口
$port = Get-Port
if (Test-BackendListening -Port $port) {
Write-Host "归档后端已在运行(端口 $port),本次启动跳过。"
return
}
if (-not (Test-Path -LiteralPath $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null }
$node = Get-NodeExe
Set-Location -LiteralPath $ProjectRoot
& $node $ServiceJs *> $LogFile
}
switch ($Action) {
'status' { Show-Status }
'logs' { Show-Logs }
'install' { Install-Autostart; Write-Host ''; Show-Status }
'uninstall' { Uninstall-Autostart }
'start' { Start-Backend | Out-Null }
'stop' { Stop-Backend }
'restart' { Stop-Backend; Start-Sleep -Seconds 1; Start-Backend | Out-Null }
'ensure' {
# 明确给出跨进程可依赖的退出码:0 = 就绪,1 = 没能就绪
$ok = Start-Backend
if ($ok) { exit 0 } else { exit 1 }
}
'run' { Invoke-BackendRun }
}