diff --git a/docs/VDI云桌面完整方案-云管端.docx b/docs/VDI云桌面完整方案-云管端.docx
new file mode 100644
index 0000000..82808df
Binary files /dev/null and b/docs/VDI云桌面完整方案-云管端.docx differ
diff --git a/docs/VDI云桌面软件开发方案(1).docx b/docs/VDI云桌面软件开发方案(1).docx
new file mode 100644
index 0000000..3571d48
Binary files /dev/null and b/docs/VDI云桌面软件开发方案(1).docx differ
diff --git a/docs/VDI云终端产品方案-定版摘要(1).docx b/docs/VDI云终端产品方案-定版摘要(1).docx
new file mode 100644
index 0000000..85ce573
Binary files /dev/null and b/docs/VDI云终端产品方案-定版摘要(1).docx differ
diff --git a/docs/api-free-rdp3.md b/docs/api-free-rdp3.md
deleted file mode 100644
index 6011043..0000000
--- a/docs/api-free-rdp3.md
+++ /dev/null
@@ -1,33 +0,0 @@
-# FreeRDP 3 客户端 API 参考(Ubuntu 24.04 / freerdp3-dev)
-
-> 用途:kiosk-shell 内嵌 FreeRDP 客户端时使用,避免拉起外部进程(开发方案 3.2 节)。
-> 记录时间:2026-10-23
-
-## 包名
-- 运行时:`libfreerdp-client3-3`、`libfreerdp3-3`
-- 开发:`freerdp3-dev`(Ubuntu 24.04 提供 `freerdp3-dev` 与 `libfreerdp-client3-3-dev`)
-- pkg-config 名称:`freerdp3`、`freerdp3-client`(安装后以 `pkg-config --list-all | grep freerdp` 实测为准)
-
-## 关键头文件
-| 头文件 | 用途 |
-|---|---|
-| `freerdp/client.h` | 客户端主入口、freerdp_client_context_new / freerdp_client_context_free |
-| `freerdp/freerdp.h` | 核心上下文 |
-| `freerdp/settings.h` | 连接参数设置(freerdp_settings_new / set) |
-| `freerdp/channels/cliprdr.h` | 剪贴板通道 |
-| `freerdp/gdi/gfx.h` | GFX 图形管线(AVC420/AVC444/RFX 能力) |
-| `freerdp/crypto/tls.h` | TLS |
-
-## 最小连接流程(伪代码)
-```c
-freerdp_client_context* ctx = freerdp_client_context_new();
-RdpClientContext* cctx = ...;
-freerdp_settings_new(0); // 释放: freerdp_settings_free
-freerdp_set_pointer / freerdp_set_keyboard ... // 输入回调
-ctx->Connect = ...; // 事件表
-freerdp_client_context_new / connect
-```
-
-## 注意事项
-- `/gfx` 命令行语法在 3.32.1 上必须用 `|` 分隔的能力组合(如 `progressive|RFX|AVC420|AVC444`),
- 单独 `AVC420` 会解析失败。API 侧对应 `gfx` 能力位设置。
diff --git a/docs/freerdp3-embedding-notes.md b/docs/freerdp3-embedding-notes.md
deleted file mode 100644
index 0f4de08..0000000
--- a/docs/freerdp3-embedding-notes.md
+++ /dev/null
@@ -1,67 +0,0 @@
-# FreeRDP 3 内嵌客户端实现笔记(Ubuntu 24.04 / freerdp3-dev 3.32.1)
-
-> 对应《VDI 云桌面软件开发方案》3.2 / 3.3 节:以 libfreerdp3 API 内嵌(非拉起外部进程)。
-> 记录时间:2026-10-23,实测于 RK3528 盒子(Armbian / Ubuntu Noble / aarch64)
-
-## 1. API 结构(与 FreeRDP 2.x 差异很大)
-
-| 用途 | FreeRDP 3 写法 |
-|---|---|
-| 创建实例 | `freerdp* instance = freerdp_new();` |
-| 建立上下文 | `freerdp_context_new_ex(instance, settings)` |
-| 上下文 | `instance->context`(`rdpContext*`,公开结构体) |
-| 事件回调 | `instance->PostConnect` / `instance->PostDisconnect` / `instance->AuthenticateEx` |
-| 更新回调 | `instance->context->update->BitmapUpdate` / `->EndPaint` |
-| GDI | `gdi_init(instance, PIXEL_FORMAT_BGRX32)` |
-| 取帧 | `context->gdi->primary_buffer` + `context->gdi->stride` |
-
-设置项键名(与 2.x 不同):
-`FreeRDP_ServerHostname` / `FreeRDP_Username` / `FreeRDP_Password` / `FreeRDP_Domain` /
-`FreeRDP_DesktopWidth` / `FreeRDP_DesktopHeight` / `FreeRDP_ColorDepth` / `FreeRDP_Fullscreen` /
-`FreeRDP_IgnoreCertificate` / `FreeRDP_GfxProgressive` / `FreeRDP_GfxH264` / `FreeRDP_GfxAVC444` …
-
-## 2. 三个必须踩过才知道的坑
-
-### 坑一:gdi_init 必须在 PostConnect 里调用
-在 `freerdp_context_new_ex()` 之后立刻调用 `gdi_init()` 会 **SIGSEGV**
-(`gdi_init_ex` 段错误)。官方 x11 客户端(`client/X11/xf_client.c` 的 `xf_post_connect`)
-把 `gdi_init` 放在 PostConnect 回调内,因为 GDI 依赖连接协商后的桌面尺寸与颜色格式。
-
-### 坑二:不能用 instance->ContextSize 扩展 rdpContext
-常见做法是把自定义指针塞进扩展的 rdpContext,但 `gdi_init_ex()` 会按 `ContextSize`
-重新分配 context 缓冲区,扩展字段随即失效 → 段错误。
-改用静态注册表按 `freerdp*` 记录会话对象;GFX 通道回调则用官方的
-`RdpgfxClientContext.custom` 字段。
-
-### 坑三:NLA 认证必须传空字符串而非 nullptr
-Windows 端启用 NLA(CredSSP)时,认证回调里把密码置为 `nullptr` 会导致
-`SEC_E_NO_CREDENTIALS [0x8009030e]`。必须传 `strdup("")`。
-认证回调签名:
-`BOOL (*pAuthenticateEx)(freerdp*, char** username, char** password, char** domain, rdp_auth_reason)`
-
-## 3. /gfx 语法(命令行与设置项的对应)
-FreeRDP 3.32.1 的 `/gfx` **不接受单个管道名**,必须是 `|` 分隔的能力组合:
-
-```
-/gfx:progressive|RFX|AVC420|AVC444 ← 可解析
-/gfx:AVC420 ← parsing failed [-1000]
-/gfx:AVC444 ← parsing failed [-1000]
-```
-
-API 侧对应 `FreeRDP_GfxProgressive` / `FreeRDP_GfxH264` / `FreeRDP_GfxAVC444` 等布尔项。
-
-## 4. 像素格式
-`PIXEL_FORMAT_BGRX32` 的内存布局是 **B,G,R,X** 四字节/像素。
-QImage 没有对应格式,输出前需手动做 BGR→RGB 转换。
-注意 `PIXEL_FORMAT_*` 是计算宏 `FREERDP_PIXEL_FORMAT(...)`,**不能硬编码数值**。
-
-## 5. 后续演进(对应 freerdp-rk 分支)
-当前实现是 CPU 侧 GDI 缓冲 → Qt 绘制。开发方案 3.3 节要求改为:
-
-```text
-FreeRDP3 解复用(gfx 通道) → GStreamer h264parse → mppvideodec(VPU硬解, zero-copy DMABuf)
-→ waylandsink(DRM plane)
-```
-
-盒子已具备 `/dev/mpp_service`(VPU)与 `/dev/rga`,但缺 `mppvideodec` GStreamer 插件
-(Ubuntu 官方源没有,需 Rockchip BSP / Armbian 额外源或自研)。
diff --git a/docs/idd-install-helper.bat b/docs/idd-install-helper.bat
new file mode 100644
index 0000000..a7addb8
--- /dev/null
+++ b/docs/idd-install-helper.bat
@@ -0,0 +1,39 @@
+@echo off
+REM ============================================================
+REM MttVDD 虚拟显示驱动 - 一键安装(管理员运行)
+REM 用法:右键本文件 -> 以管理员身份运行
+REM ============================================================
+setlocal
+cd /d "%~dp0"
+
+echo [1/4] 关闭可能占用驱动的程序...
+sc stop SunshineService >nul 2>&1
+taskkill /f /im sunshine.exe >nul 2>&1
+
+echo [2/4] 安装驱动签名证书...
+certutil -addstore -f "Root" "Virtual_Display_Driver.cer"
+if errorlevel 1 (
+ echo [错误] 证书安装失败,请确认以管理员身份运行
+ pause
+ exit /b 1
+)
+
+echo [3/4] 安装 MttVDD 驱动...
+pnputil /add-driver "MttVDD.inf" /install
+if errorlevel 1 (
+ echo [警告] 驱动安装返回错误,尝试另一种方式...
+ rundll32.exe setupapi.dll,InstallHinfSection DefaultInstall 132 .\MttVDD.inf
+)
+
+echo [4/4] 显示当前虚拟显示器状态...
+pnputil /enum-devices /class Display
+
+echo.
+echo ============================================================
+echo 安装完成。请重启 Windows 后:
+echo 1) 设置 - 显示 - 找到 MttVDD 虚拟显示器
+echo 2) 分辨率选 1920x1080,刷新率选 60Hz
+echo 3) 重启 SunshineService 服务
+echo 重启方式:开始菜单搜索 services.msc -> SunshineService -> 重新启动
+echo ============================================================
+pause
diff --git a/docs/rdp-vs-moonlight-comparison-20261028.md b/docs/rdp-vs-moonlight-comparison-20261028.md
new file mode 100644
index 0000000..2a2f8ba
--- /dev/null
+++ b/docs/rdp-vs-moonlight-comparison-20261028.md
@@ -0,0 +1,82 @@
+# RDP 与 Moonlight 实测对比(2026-10-28)
+
+## 测试环境
+
+```text
+终端:RK3528 PRO DDR4,Armbian 25.05,aarch64,LightDM + Xorg
+显示器:1920×1440 @ 75Hz
+云桌面:VM 100(win10-test),Proxmox VE 9.2.21,节点 starhe
+终端到云桌面:192.168.1.188 -> 192.168.1.70,同一局域网 WiFi
+```
+
+服务端显示环境改造(本次关键前提):
+
+```text
+改造前:Microsoft Basic Display Adapter,1920×1080 @ 1Hz
+改造后:Virtual Display Driver 8.35.11.365,1920×1080 @ 60Hz
+```
+
+改造前 Sunshine 抓不到稳定帧(1Hz),改造后可正常编码。
+
+## 对比数据
+
+| 指标 | RDP(当前 kiosk-shell) | Moonlight 1080p30 | Moonlight 1080p60 |
+|---|---|---|---|
+| 协议/编码 | RDP GFX,CPU 软解 | H.264 / libx264 软编 | H.264 / libx264 软编 |
+| 分辨率 | 1920×1440 | 1920×1080 | 1920×1080 |
+| 终端 CPU | 约 111%(单核近满载) | 约 26% | 约 26% |
+| 终端内存 | 约 232 MB | 约 192 MB | 约 165 MB |
+| 终端温度 | 约 58~65℃ | 约 56℃ | 约 53℃ |
+| 实际帧率 | 无可靠统计(观感为低帧+卷帘) | 12.83 FPS | 22.79 FPS |
+| 网络丢包 | 未测 | 0.00% | 0.00% |
+| 网络抖动丢帧 | 未测 | 0.08% | 15.28% |
+| 网络延迟 | 未测 | 2 ms | 6 ms |
+| 主机处理延迟 | 未测 | 14.4 / 27.6 / 平均 18.1 ms | 18.8 / 78.3 / 平均 38.2 ms |
+| 平均渲染耗时 | 未测 | 3.34 ms | 46.26 ms |
+| 画面观感 | 卷帘、翻页 | 完整清晰,无撕裂 | 完整清晰,无撕裂 |
+
+## 结论
+
+1. **卷帘问题不是 RDP 协议的限制,是当前 RDP 客户端实现路径的问题。**
+ Moonlight 在同一台盒子、同一台云桌面、同一网络下画面完整清晰,无卷帘无撕裂,
+ 抓屏确认 1080p 画面文字锐利、任务栏时间正确。
+
+2. **Moonlight 的瓶颈在服务端,不在终端,也不���网络。**
+ 1080p30 时网络丢包 0.00%、延迟 2 ms、渲染 3.34 ms、终端 CPU 26%,
+ 但实际帧率仅 12.83 FPS;1080p60 时主机处理延迟升到平均 38.2 ms、渲染 46.26 ms。
+ 服务端当前使用 libx264 软件编码(VM 内无 NVIDIA 直通,`nvidia-smi` 不存在),
+ 4 核 CPU 同时承担 1080p 编码与桌面捕获,是帧率上限的真正原因。
+
+3. **RDP 路径的 CPU 代价约为 Moonlight 的 4 倍**(111% vs 26%),
+ 因为 RDP 走 `FreeRDP GDI -> CPU 内存复制 -> QImage/QPixmap -> QLabel -> Xorg`,
+ 而 Moonlight 只做一次解码和一次窗口呈现。
+
+4. **RK3528 VPU 硬解在当前 moonlight-qt 6.2.0 下不可用。**
+ `h264_v4l2m2m` / `hevc_v4l2m2m` / `hevc_v4l2request` 均报
+ `Could not find a valid device`,即使盒子存在 /dev/mpp_service、/dev/rga 和
+ /dev/dri/renderD128。需 Rockchip MPP FFmpeg 补丁或 V4L2 request 驱动。
+
+## 后续建议
+
+优先级从高到低:
+
+1. **服务端 GPU 直通**:将宿主机 GTX 965M(PCI 01:00.0,IOMMU Group 2 独占)
+ 直通给 VM 100,使 Sunshine 可用 NVENC 硬编码。预期把 1080p 帧率从 13 FPS
+ 提升到 60 FPS,同时释放服务端 CPU。需先确认内核 `intel_iommu=on`、
+ `vfio-pci` 已绑定,以及该笔记本 GPU 的 vBIOS ROM 提取。
+2. **消除帧率随请求升高而抖动**(60 FPS 时抖动丢帧 15.28%):服务端稳定后复测。
+3. **RK3528 硬解适配**:解决 V4L2 request/m2m 设备缺失,这是达成 4K 的前提。
+4. **协议选型建议**:
+ - 若 GPU 直通可行,Moonlight 在 CAD/3D/视频场景优于 RDP,建议按产品方案采用双协议:
+ 普通办公用 RDP,GPU/CAD/3D 用 Moonlight;
+ - 同时保留并优化 RDP 路径的 Weston + MPP/VPU + DMABUF/DRM 零拷贝改造,
+ 使 RDP 在办公场景达到可接受体验。
+5. 当前 Moonlight 未接 VPU,1080p 软件解码仅 26% CPU 但 4K 会超出能力,
+ 需在 4K 目标确定后优先解决硬解。
+
+## 附:本次变更
+
+- VM 100:安装 Virtual Display Driver(MttVDD),提供 1920×1080@60Hz 虚拟显示器。
+- VM 100:安装 Sunshine v2026.914.233613,Web UI 账号 admin / vdi12345。
+- 终端:安装 moonlight-qt 6.2.0,已与 Sunshine 完成配对。
+- 终端:kiosk-shell 自启动已恢复(~/.config/autostart/vdi-kiosk.desktop)。
diff --git a/docs/sunshine-vm-setup-for-colleague.md b/docs/sunshine-vm-setup-for-colleague.md
new file mode 100644
index 0000000..1e3e337
--- /dev/null
+++ b/docs/sunshine-vm-setup-for-colleague.md
@@ -0,0 +1,74 @@
+# Sunshine 显卡环境配置指引(转给操作 VM 的同事)
+
+## 背景
+
+我们在 VM 100(win10-test,192.168.1.70)里装了 Sunshine 用于 Moonlight 串流验证。当前卡在:VM 没有可用 GPU,Windows 只有「Microsoft 基本显示适配器」,分辨率 1024×768、刷新率 1Hz,Sunshine 无法按 30/60fps 捕获编码。
+
+不需要动 Proxmox,只需要在 Windows 里完成两件事:装虚拟显示器驱动 + 确认 Sunshine 配置。
+
+## 第 1 步:安装虚拟显示器驱动(IddSampleDriver)
+
+让 Windows 出现一个虚拟显卡,可自定义分辨率和刷新率:
+
+1. 下载虚拟显示驱动包(ParrotGeek 的 IddSampleDriver,或等效的「Virtual Display Driver」)。
+ 常用地址:https://github.com/itsmikethetech/Virtual-Display-Driver/releases
+ (如果 GitHub 打不开,用镜像:把链接前面加 https://gh-proxy.com/ )
+2. 解压后右键 `IddSampleDriver.inf` →「安装」。
+3. 重启 Windows。
+4. 装好后设备管理器会出现「IddSampleDriver Device」,显示设置里会多出一个虚拟显示器。
+5. 把虚拟显示器设置为:
+ - 分辨率:1920×1080(先用 1080p,稳定后再试 2K)
+ - 刷新率:60Hz(关键!当前 1Hz 是主要问题)
+ - 如果看到多显示器,把虚拟屏设为「扩展这些显示器」, Sunshine 默认抓主屏;也可以只保留虚拟屏。
+
+## 第 2 步:确认 NVIDIA 独显是否可用(如果宿主已直通/有驱动)
+
+打开 PowerShell 执行:
+
+```powershell
+nvidia-smi
+```
+
+- 如果能输出显卡信息(GTX 965M):很好,说明 VM 内能用 NVIDIA。
+ - 请安装 GeForce 驱动(如未装),然后告知我们,我们把 Sunshine 切到 NVENC 硬编码。
+- 如果提示找不到命令或没有 NVIDIA 设备:跳过此步,走纯软件编码 + 虚拟显示器路线(也够验证流畅度)。
+
+## 第 3 步:检查 Sunshine 服务状态
+
+Sunshine 已装在 `C:\Program Files\Sunshine\`,服务名 `SunshineService`。
+
+PowerShell 执行:
+
+```powershell
+Get-Service SunshineService
+Restart-Service SunshineService
+```
+
+重启服务后再看日志确认识别到新显示器:
+
+```powershell
+Get-Content "C:\Program Files\Sunshine\config\sunshine.log" -Tail 60
+```
+
+需要看到的关键行(类似):
+
+```
+Desktop resolution [1920x1080]
+Display refresh rate [60 Hz]
+```
+
+## 第 4 步:告知我们结果
+
+只需回三条信息:
+
+1. 虚拟显示器是否装好,分辨率/刷新率是多少;
+2. `nvidia-smi` 是否有输出;
+3. sunshine.log 最后几十行里的 Desktop resolution / Display refresh rate 数值。
+
+我们这边会立刻重新发起 Moonlight 串流测试并采集性能数据。
+
+## 备注
+
+- Web UI(可选):https://192.168.1.70:47990 账号 admin / 密码 vdi12345
+- 装驱动需要重启 VM,重启后 SunshineService 会自启,无需其他操作。
+- Sunshine 的串流端口 47984/47989/47990/48010 已确认开放,防火墙无需改动。
diff --git a/docs/vdd_settings.xml b/docs/vdd_settings.xml
new file mode 100644
index 0000000..7a1aaed
--- /dev/null
+++ b/docs/vdd_settings.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ 0
+ 0
+ 1920
+ 1080
+ 60
+
+ 0
+
+
+ 1920
+ 1080
+ 60
+
+
+
+
diff --git a/docs/web-console-mvp-plan-20261028.md b/docs/web-console-mvp-plan-20261028.md
new file mode 100644
index 0000000..d3e6b9a
--- /dev/null
+++ b/docs/web-console-mvp-plan-20261028.md
@@ -0,0 +1,287 @@
+# VDI Web 管理控制台 MVP 实施方案
+
+> 版本:v0.1(功能先行,UI 稿待项目经理提供)
+> 日期:2026-10-28
+> 依据:`VDI云桌面软件开发方案(1).docx` 第 2、4、5 节及 `VDI云桌面完整方案-云管端.docx` 管理平台模块表。
+
+## 1. 先明确两个“登录”
+
+本项目有两个不同的界面,必须分开:
+
+| 界面 | 使用者 | 运行位置 | 职责 |
+|---|---|---|---|
+| 终端 kiosk-shell | 云桌面最终用户 | RK3528 盒子本地全屏 | 首配、设备绑定、用户登录、选择/进入被授权桌面、会话状态 |
+| Web 管理控制台 console | 超级管理员、运维、只读人员 | PC 浏览器 | 终端、桌面池、用户绑定、GPU、监控、OTA、审计管理 |
+
+正式文档中的 `console/` 指第二项。它不应运行在盒子上,也不应直接发起 RDP/Moonlight;它通过平台 API 管理资源和下发策略。
+
+“盒子开机直连 Moonlight”只能作为当前串流技术验证,不是最终产品登录流程。最终终端流程应恢复为:
+
+```text
+盒子开机
+ -> kiosk-shell 全屏终端登录/首配页
+ -> vdt-agent 注册并获取终端身份
+ -> 用户登录或终端绑定校验
+ -> 平台返回该用户可用的桌面列表及短期连接信息
+ -> 用户选择桌面
+ -> kiosk-shell 按 protocol=rdp|moonlight 启动会话
+ -> 断开后回桌面列表/登录页
+```
+
+## 2. Web 控制台 MVP 目标
+
+第一版目标不是做漂亮的大屏,而是跑通一个可演示、可替换 UI、可接真实后端的后台系统:
+
+1. 用户能登录和退出;
+2. 根据角色控制菜单和按钮;
+3. 管理员能查看终端、桌面、用户及绑定关系;
+4. 能对终端下发命令、对桌面执行开关机/重置;
+5. 能查看基础状态、操作结果和审计记录;
+6. 后端未完成时使用 Mock API,后端接口上线后不改页面业务逻辑即可切换。
+
+## 3. 技术栈与工程结构
+
+按定版文档执行:Vue 3 + Vite + TypeScript 严格模式 + Element Plus。
+
+建议补充:Pinia(状态)、Vue Router、Axios、Vitest、Playwright、MSW(Mock API)。
+
+```text
+console/
+├─ src/
+│ ├─ api/ # OpenAPI 生成 SDK 的薄封装
+│ ├─ assets/
+│ ├─ components/ # 领域无关组件
+│ ├─ layouts/ # 登录布局、控制台布局
+│ ├─ mocks/ # MSW handlers 与演示数据
+│ ├─ router/ # 路由、鉴权守卫、RBAC meta
+│ ├─ stores/ # auth、app、notification
+│ ├─ types/ # 页面 ViewModel;API 类型由生成器管理
+│ ├─ views/
+│ │ ├─ login/
+│ │ ├─ dashboard/
+│ │ ├─ terminals/
+│ │ ├─ desktops/
+│ │ ├─ users/
+│ │ ├─ gpu/
+│ │ ├─ monitoring/
+│ │ ├─ ota/
+│ │ └─ audit/
+│ ├─ App.vue
+│ └─ main.ts
+├─ e2e/
+├─ public/
+├─ .env.development
+├─ .env.mock
+├─ package.json
+└─ vite.config.ts
+```
+
+约束:页面不能直接拼 URL 或写 Axios;所有网络调用只经过 `api/`。这样 Mock 与真实后端可无感切换。
+
+## 4. 页面与优先级
+
+### M0:登录与应用框架
+
+- 登录页:用户名、密码、登录按钮、错误状态、加载状态;
+- 控制台壳:侧边导航、顶部栏、面包屑、当前账号、退出;
+- 401 自动清理会话并回登录;
+- 403 权限提示;
+- 路由级 RBAC:`super_admin`、`operator`、`viewer`;
+- 开发环境使用 Mock 登录,生产环境只调用真实 API。
+
+### M1:核心演示闭环
+
+1. 仪表盘:在线终端、活动会话、运行桌面、告警数量;
+2. 终端管理:列表、搜索、状态筛选、详情、重启、重置会话;
+3. 桌面管理:列表、状态筛选、开机、关机、重启、重置;
+4. 用户与绑定:用户列表、用户详情、终端/桌面绑定与解绑;
+5. 审计日志:操作者、动作、目标、结果、时间、请求 ID。
+
+M1 完成后已经可以给项目经理演示完整业务流程。
+
+### M2:完整文档范围
+
+- 桌面池:模板、容量、回收策略;
+- GPU 池:节点/PCI 地址/型号/状态/绑定桌面;
+- 监控:会话在线数、FPS、RTT、节点水位、告警;
+- OTA 中心:版本、批次、灰度比例、进度、失败终端;
+- CSV 用户/绑定批量导入向导;
+- 配置模板与配置版本下发。
+
+## 5. 登录与权限方案
+
+### 登录接口
+
+```http
+POST /api/v1/auth/login
+Content-Type: application/json
+
+{
+ "username": "admin",
+ "password": "******"
+}
+```
+
+建议响应:
+
+```json
+{
+ "accessToken": "jwt",
+ "expiresIn": 3600,
+ "user": {
+ "id": "usr_001",
+ "username": "admin",
+ "displayName": "系统管理员",
+ "role": "super_admin"
+ }
+}
+```
+
+建议第一版 access token 仅存内存;刷新 token 使用 `HttpOnly + Secure + SameSite` Cookie。若后端尚未支持 refresh,MVP 可暂存 `sessionStorage`,但不要长期使用 `localStorage` 保存高权限令牌。
+
+### 权限矩阵
+
+| 能力 | super_admin | operator | viewer |
+|---|---:|---:|---:|
+| 查看所有页面 | 是 | 是 | 是 |
+| 终端远程命令 | 是 | 是 | 否 |
+| 桌面生命周期操作 | 是 | 是 | 否 |
+| 用户/绑定修改 | 是 | 否 | 否 |
+| GPU 绑定 | 是 | 否 | 否 |
+| OTA 发布/灰度 | 是 | 是 | 否 |
+| 只读监控/审计 | 是 | 是 | 是 |
+
+前端 RBAC 只是交互约束,真正授权必须由 `api-gateway` 再验证,不能只隐藏按钮。
+
+## 6. 第一版 API 契约
+
+文档已明确的接口优先沿用:
+
+```text
+POST /api/v1/auth/login
+GET /api/v1/users
+POST /api/v1/desktop/assign
+POST /api/v1/pool/{id}/action
+GET /api/v1/gpu/cards
+POST /api/v1/gpu/bind
+GET /api/v1/monitor/query
+```
+
+为控制台 MVP 补齐建议接口(需后端确认并写入 OpenAPI):
+
+```text
+GET /api/v1/auth/me
+POST /api/v1/auth/logout
+GET /api/v1/terminals
+GET /api/v1/terminals/{id}
+POST /api/v1/terminals/{id}/commands
+GET /api/v1/desktops
+GET /api/v1/desktops/{id}
+POST /api/v1/desktops/{id}/actions
+GET /api/v1/bindings
+DELETE /api/v1/bindings/{id}
+GET /api/v1/audit-logs
+```
+
+所有列表统一查询参数:`page`、`pageSize`、`keyword`、`status`、`sort`;统一响应:
+
+```json
+{
+ "items": [],
+ "page": 1,
+ "pageSize": 20,
+ "total": 0,
+ "requestId": "req_xxx"
+}
+```
+
+所有命令型接口必须返回 `commandId`,页面展示“已提交”,再轮询或通过 WebSocket/MQTT 网关显示最终结果,不能把 HTTP 200 当作设备已经执行成功。
+
+## 7. Mock 先行策略
+
+后端还没完成时,前端不应硬编码页面数据。使用 MSW 模拟真实 HTTP:
+
+- 固定 3 个测试账号,对应三角色;
+- 终端包含在线/离线/会话中/升级中;
+- 桌面包含运行/停止/错误/任务中;
+- 所有命令先进入 `pending`,1–3 秒后变成 `success` 或 `failed`;
+- 登录失败、401、403、网络超时、空列表都要有测试场景;
+- `VITE_API_MODE=mock|real` 切换数据源。
+
+项目经理 UI 稿到后,Mock 数据仍然可用于逐页视觉验收。
+
+## 8. 与终端登录流程的接口边界
+
+管理员 Web 控制台不直接把 VM 密码写进终端,也不让终端调用 Proxmox API。
+
+建议终端用户登录流程由 `auth-svc` 单独提供:
+
+```text
+POST /api/v1/terminal/session/login
+GET /api/v1/terminal/session/desktops
+POST /api/v1/terminal/session/connect-ticket
+```
+
+`connect-ticket` 返回短期、一次性连接信息:
+
+```json
+{
+ "desktopId": "desk_001",
+ "protocol": "moonlight",
+ "endpoint": "192.168.1.70",
+ "app": "Desktop",
+ "display": { "width": 1920, "height": 1080, "fps": 60 },
+ "ticket": "short-lived-token",
+ "expiresAt": "2026-10-28T16:30:00+08:00"
+}
+```
+
+这部分不是 `console/` 页面,但必须和 Web 控制台的“用户/终端/桌面绑定”共享同一套后端数据。
+
+## 9. UI 稿到来前的页面设计原则
+
+第一版只做稳定工作台,不做营销式页面:
+
+- 左侧导航 + 顶部工具栏 + 主内容区;
+- 列表优先,详情使用独立页或抽屉;
+- 状态使用图标/标签,不依赖单一颜色;
+- 危险操作二次确认;
+- 所有异步操作显示提交、执行中、成功、失败四态;
+- 页面级空态、错误态、无权限态必须完整;
+- 颜色、字号、间距集中为设计 token,避免 UI 稿到后逐页改 CSS;
+- 表格列、筛选区、表单字段以配置驱动,减少视觉改版成本。
+
+## 10. 验收标准
+
+M1 应达到:
+
+1. 三种角色都能登录,菜单/按钮权限正确;
+2. 刷新页面仍能恢复会话,令牌过期自动回登录;
+3. 终端、桌面、用户/绑定、审计五条流程可演示;
+4. 命令有执行中与最终结果,不出现“点击无反馈”;
+5. API 全走统一 SDK/适配层,无页面直连 Axios;
+6. Vitest 覆盖 auth store、路由守卫、权限判断;
+7. Playwright 覆盖登录、终端命令、桌面操作、用户绑定四条主路径;
+8. `npm run lint`、`npm run typecheck`、`npm run test`、`npm run build` 全通过。
+
+## 11. 推荐实施顺序
+
+```text
+第 1 步:初始化 console 工程、路由、布局、设计 token
+第 2 步:OpenAPI 初稿 + API SDK 生成 + MSW Mock
+第 3 步:登录、会话恢复、RBAC、错误处理
+第 4 步:终端管理 + 桌面管理
+第 5 步:用户与绑定 + 审计日志
+第 6 步:仪表盘聚合数据
+第 7 步:Playwright E2E 与生产构建
+第 8 步:项目经理 UI 稿到后替换视觉层
+第 9 步:接真实 Go API,关闭 Mock
+```
+
+## 12. 当前决策建议
+
+- 现在先开发 `console/` 的 M0 + M1,不等 UI 稿;
+- 同时为 `terminal/kiosk-shell` 恢复“登录/桌面列表后连接”的产品流程,不再把开机直连 Moonlight 当最终形态;
+- Web 控制台第一版只接 Mock,API 契约先和后端负责人确认;
+- Moonlight/RDP 统一抽象成终端连接票据的 `protocol` 字段,Web 控制台只管理策略,不直接操控串流客户端;
+- UI 稿到后只调整布局组件与 design tokens,业务路由、权限、API、测试保留。
diff --git a/tools/overnight-monitor.sh b/tools/overnight-monitor.sh
new file mode 100644
index 0000000..a2ec3de
--- /dev/null
+++ b/tools/overnight-monitor.sh
@@ -0,0 +1,29 @@
+#!/bin/sh
+set -u
+LOG_DIR="$HOME/vdi-terminal/logs"
+LOG_FILE="$LOG_DIR/overnight-$(date +%Y%m%d).log"
+mkdir -p "$LOG_DIR"
+log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "$*" >> "$LOG_FILE"; }
+log "monitor_start pid=$$ interval=60s"
+while :; do
+ now=$(date '+%Y-%m-%d %H:%M:%S %z')
+ shell_pid=$(pgrep -f '^/home/armbian/vdi-terminal/bin/kiosk-shell$' | head -n 1 || true)
+ monitor_pid=$(pgrep -f '^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor' | head -n 1 || true)
+ if [ -n "$shell_pid" ]; then
+ ps_line=$(ps -o pcpu=,pmem=,rss=,etime= -p "$shell_pid" | tr '\n' ' ')
+ else
+ ps_line="missing"
+ fi
+ temp=$(awk '{printf "%.1f", $1/1000}' /sys/class/thermal/thermal_zone0/temp 2>/dev/null || printf "unknown")
+ mem=$(awk '/MemTotal:|MemAvailable:|SwapFree:/{printf "%s=%sKB ",$1,$2}' /proc/meminfo 2>/dev/null || true)
+ heartbeat="missing"
+ if [ -f /tmp/vdi-kiosk.heartbeat ]; then
+ heartbeat=$(stat -c '%Y' /tmp/vdi-kiosk.heartbeat 2>/dev/null || printf 'unknown')
+ fi
+ rdp=$(ss -tnp 2>/dev/null | grep '192.168.1.70:3389' | tr '\n' ';' || true)
+ starts=$(grep -c '主进程已启动' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0)
+ exits=$(grep -c '主进程已退出' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0)
+ crashes=$(grep -ciE 'segmentation fault|bus error|double free|invalid pointer|aborted|terminate called' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0)
+ log "sample now=$now shell_pid=${shell_pid:-missing} monitor_pid=${monitor_pid:-missing} ps=[$ps_line] temp_c=$temp heartbeat_epoch=$heartbeat rdp=[$rdp] starts=$starts exits=$exits crashes=$crashes $mem"
+ sleep 60
+done
diff --git a/tools/pve_term_exec.py b/tools/pve_term_exec.py
new file mode 100644
index 0000000..cfa6e7c
--- /dev/null
+++ b/tools/pve_term_exec.py
@@ -0,0 +1,89 @@
+"""Run a command on the PVE node via the same VNC term channel the web console uses.
+
+Usage: python3 pve_term_exec.py ""
+Reads ticket from /tmp/pve_t.txt (refreshed automatically if expired).
+Prints the command output captured from the terminal stream.
+"""
+import base64
+import json
+import ssl
+import struct
+import sys
+import time
+import urllib.parse
+import urllib.request
+
+HOST = "https://192.168.1.51:8006"
+NODE = "starhe"
+CTX = ssl.create_default_context()
+CTX.check_hostname = False
+CTX.verify_mode = ssl.CERT_NONE
+
+
+def http(path, data=None, ticket=None, csrf=None):
+ body = urllib.parse.urlencode(data).encode() if data is not None else None
+ req = urllib.request.Request(HOST + path, body)
+ if ticket:
+ req.add_header("Cookie", "PVEAuthCookie=" + ticket)
+ if csrf:
+ req.add_header("CSRFPreventionToken", csrf)
+ with urllib.request.urlopen(req, context=CTX, timeout=30) as r:
+ raw = r.read()
+ return json.loads(raw)["data"] if raw else None
+
+
+def login():
+ d = http("/api2/json/access/ticket",
+ {"username": "root@pam", "password": "yx66668888"})
+ return d["ticket"], d["CSRFPreventionToken"]
+
+
+def run_cmd(command, timeout=60):
+ ticket, csrf = login()
+ # 1. open a term proxy (VNC channel to node shell)
+ r = http("/api2/json/nodes/%s/termproxy" % NODE, {}, ticket, csrf)
+ port, ticket_vnc = r["port"], r["ticket"]
+ user, realm_sep, _ = "root", "@", "pam"
+
+ import socket
+ sock = socket.create_connection(("192.168.1.51", port), timeout=10)
+ # PVE handshake: username@realm\n ticket\n binary handshake
+ sock.sendall(b"root@pam\n" + ticket_vnc.encode() + b"\n")
+ gh = sock.recv(16)
+ maj, mino = struct.unpack("BB", gh[0:2])
+ said = sock.recv(16 * 4)
+ # client sends its own handshake
+ ch = struct.pack("BB", maj, mino) + b"PVEVNC Client 00" + b"\x00" * 0
+ sock.sendall(ch)
+
+ # 2. run the command in the shell by typing it and reading output
+ marker = "PVEDONE%s" % int(time.time())
+ payload = command + " ; echo " + marker + "\n"
+ time.sleep(0.5)
+ sock.sendall(payload.encode())
+
+ buf = b""
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ try:
+ sock.settimeout(2)
+ chunk = sock.recv(65536)
+ if not chunk:
+ break
+ buf += chunk
+ if marker.encode() in buf.replace(b"\x1b", b"").replace(b"[?2004l", b""):
+ break
+ except socket.timeout:
+ if marker.encode() in buf:
+ break
+ continue
+ sock.close()
+ # strip terminal control chars roughly and the echoed command
+ text = buf.decode("utf-8", "replace")
+ text = text.replace(marker, "")
+ lines = [l for l in text.split("\r\n")]
+ return "\n".join(lines)
+
+
+if __name__ == "__main__":
+ print(run_cmd(sys.argv[1]))
diff --git a/tools/pve_vm.py b/tools/pve_vm.py
new file mode 100644
index 0000000..9104256
--- /dev/null
+++ b/tools/pve_vm.py
@@ -0,0 +1,55 @@
+import json
+import ssl
+import sys
+import urllib.parse
+import urllib.request
+
+HOST = "https://192.168.1.51:8006"
+CTX = ssl.create_default_context()
+CTX.check_hostname = False
+CTX.verify_mode = ssl.CERT_NONE
+
+
+def api(path, data=None, ticket=None, csrf=None, json_body=None):
+ body = None
+ headers = {}
+ if json_body is not None:
+ body = json.dumps(json_body).encode()
+ headers["Content-Type"] = "application/json"
+ elif data is not None:
+ body = urllib.parse.urlencode(data).encode()
+ req = urllib.request.Request(HOST + path, body, headers)
+ if ticket:
+ req.add_header("Cookie", "PVEAuthCookie=" + ticket)
+ if csrf:
+ req.add_header("CSRFPreventionToken", csrf)
+ with urllib.request.urlopen(req, context=CTX, timeout=40) as r:
+ raw = r.read()
+ return json.loads(raw)["data"] if raw else None
+
+
+def agent(ticket, csrf, command, timeout=90):
+ r = api("/api2/json/nodes/starhe/qemu/100/agent/exec",
+ json_body={"command": command}, ticket=ticket, csrf=csrf)
+ pid = r["pid"]
+ for _ in range(timeout // 2):
+ import time
+ time.sleep(2)
+ st = api("/api2/json/nodes/starhe/qemu/100/agent/exec-status?pid=%d" % pid,
+ ticket=ticket)
+ if st.get("exited"):
+ return st.get("out-data") or "", st.get("err-data") or ""
+ return "TIMEOUT", ""
+
+
+if __name__ == "__main__":
+ d = api("/api2/json/access/ticket",
+ {"username": "root@pam", "password": "yx66668888"})
+ ticket, csrf = d["ticket"], d["CSRFPreventionToken"]
+ ps = sys.argv[1]
+ out, err = agent(ticket, csrf, [
+ "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
+ "-NoProfile", "-Command", ps])
+ print(out)
+ if err:
+ print("ERR:", err[:300])
diff --git a/tools/xwd2png.py b/tools/xwd2png.py
new file mode 100644
index 0000000..f4c3bee
--- /dev/null
+++ b/tools/xwd2png.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+"""Convert an XWD dump (from xwd) into a PNG, without external image libraries.
+
+Usage: xwd -root -silent | python3 xwd2png.py out.png
+"""
+import struct
+import sys
+import zlib
+
+
+def read_header(data):
+ fields = struct.unpack(">25I", data[:100])
+ names = [
+ "header_size", "file_version", "pixmap_format", "pixmap_depth",
+ "pixmap_width", "pixmap_height", "xoffset", "byte_order",
+ "bitmap_unit", "bitmap_bit_order", "bitmap_pad", "bits_per_pixel",
+ "bytes_per_line", "visual_class", "red_mask", "green_mask",
+ "blue_mask", "bits_per_rgb", "colormap_entries", "ncolors",
+ "window_width", "window_height", "window_x", "window_y",
+ "window_bdrwidth",
+ ]
+ return dict(zip(names, fields))
+
+
+def write_png(path, width, height, rgb_rows):
+ raw = b"".join(b"\x00" + row for row in rgb_rows)
+
+ def chunk(tag, payload):
+ return (struct.pack(">I", len(payload)) + tag + payload +
+ struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
+
+ header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
+ png = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) +
+ chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b""))
+ with open(path, "wb") as handle:
+ handle.write(png)
+
+
+def main():
+ out_path = sys.argv[1] if len(sys.argv) > 1 else "out.png"
+ data = sys.stdin.buffer.read()
+ head = read_header(data)
+ width = head["pixmap_width"]
+ height = head["pixmap_height"]
+ bpp = head["bits_per_pixel"]
+ stride = head["bytes_per_line"]
+ offset = head["header_size"] + head["ncolors"] * 12
+ pixels = data[offset:]
+
+ if bpp == 32:
+ rows = []
+ for y in range(height):
+ line = pixels[y * stride:y * stride + width * 4]
+ out = bytearray(width * 3)
+ for x in range(width):
+ src = x * 4
+ dst = x * 3
+ out[dst] = line[src + 2]
+ out[dst + 1] = line[src + 1]
+ out[dst + 2] = line[src]
+ rows.append(bytes(out))
+ elif bpp == 24:
+ rows = []
+ for y in range(height):
+ line = pixels[y * stride:y * stride + width * 3]
+ out = bytearray(width * 3)
+ for x in range(width):
+ src = x * 3
+ dst = x * 3
+ out[dst] = line[src + 2]
+ out[dst + 1] = line[src + 1]
+ out[dst + 2] = line[src]
+ rows.append(bytes(out))
+ else:
+ sys.stderr.write("unsupported bits_per_pixel=%s\n" % bpp)
+ return 2
+
+ write_png(out_path, width, height, rows)
+ print("%s %dx%d" % (out_path, width, height))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())