功能:新增云管API与Web控制台并完成现场接入

This commit is contained in:
宋一鸣 2026-11-09 17:54:52 +08:00
parent 18a9ce995d
commit 3fb7b4da8c
79 changed files with 17367 additions and 3 deletions

12
.dockerignore Normal file
View File

@ -0,0 +1,12 @@
.git
**/node_modules
**/dist
**/test-results
**/playwright-report
**/.env
**/.env.local
**/*.exe
terminal
docs
tools
.local

12
.gitignore vendored
View File

@ -7,7 +7,19 @@ CMakeCache.txt
*.tmp
.zcode/
__pycache__/
*.py[cod]
~$*
test-output/
screenshots/
.DS_Store
.playwright-cli/
node_modules/
dist/
.env
.env.local
.env.*.local
playwright-report/
coverage/
test-results/
.local/
*.exe

2
console/.env.example Normal file
View File

@ -0,0 +1,2 @@
# api: connect the Go backend; mock: browser-only demo, no real operations.
VITE_API_MODE=api

View File

@ -1 +0,0 @@
# Keep the monorepo directory structure in Git.

13
console/Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM node:24-alpine AS build
WORKDIR /src
COPY console/package.json console/package-lock.json ./
RUN npm ci
COPY console/ ./
ARG VITE_DEMO_HINT=false
ENV VITE_API_MODE=api VITE_DEMO_HINT=$VITE_DEMO_HINT
RUN npm run build
FROM nginx:alpine
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist /usr/share/nginx/html
EXPOSE 80

21
console/README.md Normal file
View File

@ -0,0 +1,21 @@
# VDI Web 管理控制台
Vue 3、TypeScript、Vite、Element Plus、Pinia、Vue Router。使用者是管理员,与盒子上的 Qt 桌面选择页分开。
运行、演示账号和完整能力边界见[开发说明](../docs/cloud-platform-development.md)。
后台样式已按仓库 `ui/` 设计稿接入,支持深浅主题、桌面卡片与列表、远程会话页和窄屏导航;详见 [UI 接入记录](../docs/cloud-console-ui-20261109.md)。真实现场入口为 `http://127.0.0.1:5182`。
```text
src/api/ 从 OpenAPI 生成的类型与统一 HTTP 适配
src/stores/ 会话与平台数据
src/router/ 登录与角色路由守卫
src/layouts/ 控制台导航与定时刷新
src/views/ 第一阶段功能页面
src/components/ 共用状态等组件
src/styles/ UI 可替换的设计 token
src/mocks/ 开发专用 MSW 模拟接口
e2e/ Playwright 主路径验收
```
`npm run api:generate` 更新接口契约与类型;API 适配使用 openapi-fetch,页面不直接发网络请求。默认 `npm run dev` 通过 `/api` 代理到本机 `8088` 端口。开发时设 `VITE_API_MODE=mock` 则使用浏览器模拟接口,生产构建不会启用模拟 Worker。

102
console/e2e/console.spec.ts Normal file
View File

@ -0,0 +1,102 @@
import { test, expect, type Page } from '@playwright/test'
async function login(page: Page, username = 'admin') {
await page.goto('/login')
await page.getByRole('textbox', { name: '用户名', exact: true }).fill(username)
await page.getByRole('textbox', { name: '密码', exact: true }).fill('Demo@2026')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '运行总览' })).toBeVisible()
}
test.beforeAll(async ({ request }) => { const r = await request.get('/healthz'); expect(r.ok()).toBe(true); expect((await r.json()).mode).toBe('demo') })
test('登录失败、登录恢复与退出', async ({ page }) => {
await page.goto('/login')
await page.getByRole('textbox', { name: '用户名', exact: true }).fill('admin')
await page.getByRole('textbox', { name: '密码', exact: true }).fill('incorrect')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByText('用户名或密码错误')).toBeVisible()
await page.getByRole('textbox', { name: '密码', exact: true }).fill('Demo@2026')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '运行总览' })).toBeVisible()
await page.reload()
await expect(page.getByRole('heading', { name: '运行总览' })).toBeVisible()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page).toHaveURL(/\/login/)
})
test('桌面命令与最终任务结果', async ({ page }) => {
await login(page)
await page.getByRole('navigation').getByRole('link', { name: '桌面管理' }).click()
await page.getByRole('button', { name: '列表视图' }).click()
const row = page.getByRole('row').filter({ hasText: '国产办公桌面' })
await expect(row).toBeVisible()
const currentStopped = await row.getByText('已关机', { exact: true }).count()
const action = currentStopped ? '开机' : '关机'
await row.getByRole('button', { name: action, exact: true }).click()
await page.getByRole('button', { name: '确认提交', exact: true }).click()
await expect(page.getByText('任务已提交,请在任务与审计中查看最终结果')).toBeVisible()
await page.getByRole('navigation').getByRole('link', { name: '任务与审计' }).click()
const task = page.getByRole('row').filter({ hasText: '国产办公桌面' }).first()
await expect(task.getByText('成功', { exact: true })).toBeVisible()
await expect(task).toContainText('演示计算任务已完成')
})
test('创建用户、编辑多个桌面授权并持久化', async ({ page }) => {
await login(page)
await page.getByRole('navigation').getByRole('link', { name: '用户与授权' }).click()
await page.getByRole('button', { name: '新建用户', exact: true }).click()
const dialog = page.getByRole('dialog')
const name = `测试用户 ${Date.now()}`
await dialog.getByRole('textbox', { name: /姓名/ }).fill(name)
await dialog.getByRole('textbox', { name: /用户账号/ }).fill(`e2e.${Date.now()}`)
await dialog.getByRole('button', { name: '创建用户', exact: true }).click()
await page.getByRole('textbox', { name: '搜索用户' }).fill(name)
await page.getByRole('row').filter({ hasText: name }).getByRole('button', { name: '编辑授权' }).click()
await dialog.locator('label.el-checkbox').filter({ hasText: '设计专属桌面' }).click()
await expect(dialog.getByRole('checkbox', { name: /设计专属桌面/ })).toBeChecked()
await dialog.locator('label.el-checkbox').filter({ hasText: '研发办公桌面' }).click()
await dialog.getByRole('button', { name: '保存授权' }).click()
const row = page.getByRole('row').filter({ hasText: name })
await expect(row).toContainText('设计专属桌面')
await expect(row).toContainText('研发办公桌面')
await page.reload()
await page.getByRole('textbox', { name: '搜索用户' }).fill(name)
await expect(page.getByRole('row').filter({ hasText: name })).toContainText('设计专属桌面')
})
test('登记终端只显示一次激活密钥', async ({ page }) => {
await login(page)
await page.getByRole('navigation').getByRole('link', { name: '终端管理' }).click()
await page.getByRole('button', { name: '登记终端' }).click()
const dialog = page.getByRole('dialog')
const serial = `E2E-${Date.now()}`
await dialog.getByRole('textbox', { name: '设备 SN' }).fill(serial)
await dialog.getByRole('textbox', { name: '终端名称' }).fill('联调终端')
await dialog.getByRole('button', { name: '登记并生成激活信息' }).click()
await expect(page.getByTestId('activation-secret')).toContainText('activate_')
await page.getByRole('button', { name: '已保存,关闭' }).click()
await page.getByRole('textbox', { name: '搜索终端' }).fill(serial)
await expect(page.getByRole('row').filter({ hasText: serial })).toContainText('待激活')
})
for (const username of ['viewer', 'operator']) test(`${username} 权限边界`, async ({ page }) => {
await login(page, username)
await page.getByRole('navigation').getByRole('link', { name: '用户与授权' }).click()
await expect(page.getByRole('button', { name: '新建用户' })).toHaveCount(0)
await expect(page.getByRole('button', { name: '编辑授权' })).toHaveCount(0)
await page.goto('/settings')
await expect(page.getByText('无访问权限', { exact: true })).toBeVisible()
await page.goto('/desktops')
await expect(page.getByRole('heading', { name: '桌面管理' })).toBeVisible()
await page.getByRole('button', { name: '列表视图' }).click()
if (username === 'viewer') await expect(page.getByRole('button', { name: '关机', exact: true })).toHaveCount(0)
else await expect(page.getByRole('button', { name: '关机', exact: true }).first()).toBeVisible()
})
test('空列表、刷新异常及 401 处理', async ({ page }) => {
await login(page)
await page.getByRole('navigation').getByRole('link', { name: '桌面管理' }).click()
await page.getByRole('textbox', { name: '搜索桌面' }).fill('不存在的桌面')
await expect(page.getByText('暂无符合条件的桌面')).toBeVisible()
await page.route('**/api/v1/overview', route => route.fulfill({ status: 503, json: { code: 'unavailable', message: '测试服务暂不可用', requestId: 'test-offline' } }))
await page.getByRole('navigation').getByRole('link', { name: '云桌面总览', exact: true }).click()
await page.getByRole('button', { name: '刷新数据' }).click()
await expect(page.getByText('数据刷新失败,当前信息可能已过期')).toBeVisible()
await page.unroute('**/api/v1/overview')
await page.evaluate(() => sessionStorage.setItem('vdi.accessToken', 'invalid-token'))
await page.reload()
await expect(page).toHaveURL(/\/login/)
})

40
console/e2e/live.spec.ts Normal file
View File

@ -0,0 +1,40 @@
import {test,expect} from '@playwright/test'
import {readFileSync,mkdirSync} from 'node:fs'
const env=Object.fromEntries(readFileSync('../.local/live/.env','utf8').trim().split(/\r?\n/).map(line=>{ const i=line.indexOf('='); return [line.slice(0,i),line.slice(i+1)] }))
function required(key: string) { const value=env[key]; if (!value) throw new Error(`Missing live configuration: ${key}`); return value }
test('现场真实资源、设备在线、日志采集与下载',async({page,request})=>{
const health=await request.get('/healthz'); expect((await health.json()).mode).toBe('production')
await page.goto('/login')
await expect(page.getByText(/演示账号/)).toHaveCount(0)
await page.getByRole('textbox',{name:'用户名',exact:true}).fill(required('VDI_ADMIN_USERNAME'))
await page.getByRole('textbox',{name:'密码',exact:true}).fill(required('VDI_ADMIN_PASSWORD'))
await page.getByRole('button',{name:'登录',exact:true}).click()
await expect(page.getByRole('heading',{name:'运行总览'})).toBeVisible()
await expect(page.getByText('starhe',{exact:true})).toBeVisible()
mkdirSync('../.local/live/screenshots',{recursive:true})
await page.screenshot({path:'../.local/live/screenshots/overview.png',fullPage:true,animations:'disabled'})
await page.getByRole('navigation').getByRole('link',{name:'桌面管理'}).click()
await expect(page.getByRole('heading',{name:'桌面管理',exact:true})).toBeVisible()
await page.getByRole('button',{name:'列表视图'}).click()
for(const name of ['Windows 10','Ubuntu','麒麟']) await expect(page.getByRole('row').filter({hasText:name})).toBeVisible()
await page.screenshot({path:'../.local/live/screenshots/desktops.png',fullPage:true,animations:'disabled'})
await page.getByRole('navigation').getByRole('link',{name:'终端管理'}).click()
const row=page.getByRole('row').filter({hasText:'ee621cae7199a2e9'})
await expect(row.getByText('在线',{exact:true})).toBeVisible()
await row.getByRole('button',{name:'详情',exact:true}).click()
await expect(page.getByText('qt-platform/0.2.0',{exact:true})).toBeVisible()
await page.getByRole('button',{name:'收集日志',exact:true}).click()
await page.getByRole('button',{name:'确认提交',exact:true}).click()
await page.getByRole('dialog',{name:'终端详情'}).locator('button.el-drawer__close-btn').click()
await page.getByRole('navigation').getByRole('link',{name:'任务与审计'}).click()
const task=page.getByRole('row').filter({hasText:'收集日志'}).first()
await expect(task.getByText('成功',{exact:true})).toBeVisible()
await task.getByRole('button').click()
const download=page.waitForEvent('download')
await page.getByRole('button',{name:'下载诊断日志'}).click()
const artifact=await download
const path='../.local/live/device-diagnostics.json'; await artifact.saveAs(path)
const diagnostic=JSON.parse(readFileSync(path,'utf8'))
expect(diagnostic.agentVersion).toBe('qt-platform/0.2.0'); expect(diagnostic.bootId).toBeTruthy(); expect(diagnostic.uptimeSeconds).toBeGreaterThan(0)
await page.screenshot({path:'../.local/live/screenshots/diagnostics.png',fullPage:true,animations:'disabled'})
})

84
console/e2e/ui.spec.ts Normal file
View File

@ -0,0 +1,84 @@
import { test, expect, type Page } from '@playwright/test'
// Browser fixtures exercise UI states without sending commands to PVE or a terminal.
async function fixtures(page: Page, role = 'super_admin', state = 'connected') {
const now = new Date().toISOString()
const desktop = { id: 'ui-desk', name: '界面测试桌面', os: 'Ubuntu', node: 'ui-node', vmid: 9000, pool: '', powerState: 'running', serviceState: 'unknown', provider: 'proxmox', endpoint: 'desktop.example', app: 'Desktop', width: 2560, height: 1440, fps: 45, updatedAt: now }
const terminal = { id: 'ui-terminal', sn: 'UI-TEST-DEVICE', name: '界面测试终端', group: '测试组', status: 'online', agentVersion: 'ui-fixture', lastSeen: now, sessionDesktopId: desktop.id, sessionState: state, configVersion: 1, fps: null, rtt: null }
const commands: unknown[] = []
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'ui-fixture-token'))
await page.route('**/api/v1/**', async route => {
const request = route.request(), endpoint = new URL(request.url()).pathname.replace('/api/v1', '')
if (request.method() === 'POST' && endpoint.endsWith('/commands')) {
commands.push(request.postDataJSON())
await route.fulfill({ status: 202, json: { id: 'ui-command', kind: 'terminal', action: 'reset-session', targetId: terminal.id, targetName: terminal.name, actor: 'ui-admin', status: 'queued', message: '等待回执', providerRef: '', createdAt: now, updatedAt: now } })
return
}
if (endpoint === '/auth/me') { await route.fulfill({ json: { id: 'ui-admin', username: 'ui-admin', displayName: '界面测试管理员', role } }); return }
if (endpoint === '/settings') { await route.fulfill({ json: { mode: 'production', computeProvider: 'proxmox', pveConfigured: true, terminalTransport: 'https-poll', capabilities: [] } }); return }
if (endpoint === '/overview') { await route.fulfill({ json: { terminalCount: 1, onlineTerminals: 1, desktopCount: 1, runningDesktops: 1, activeSessions: state === 'connected' ? 1 : 0, failedTasks: 0, mode: 'production' } }); return }
const lists: Record<string, unknown[]> = {
'/desktops': [desktop], '/terminals': [terminal], '/users': [{ id: 'ui-user', username: 'ui-user', displayName: '测试使用者', enabled: true, terminalId: terminal.id, desktopIds: [desktop.id] }],
'/resources': [{ id: 'ui-node', name: 'ui-node', status: 'online', cpu: .2, memory: .4, storage: .3, provider: 'proxmox', updatedAt: now }], '/tasks': [], '/audit-logs': [],
}
if (endpoint in lists) { const items = lists[endpoint] || []; await route.fulfill({ json: { items, page: 1, pageSize: 100, total: items.length, requestId: 'ui-fixture' } }); return }
await route.fulfill({ status: 404, json: { code: 'fixture_not_found', message: '测试接口未定义', requestId: 'ui-fixture' } })
})
return commands
}
test('远程会话保留缺失指标,取消断开不发命令,确认后使用现有接口', async ({ page }, testInfo) => {
const commands = await fixtures(page)
await page.setViewportSize({ width: 1440, height: 1000 })
await page.goto('/sessions')
await expect(page.getByRole('heading', { name: '会话信息', exact: true })).toBeVisible()
await expect(page.getByText('桌面正在终端显示')).toBeVisible()
await expect(page.getByText('暂无数据', { exact: true })).toHaveCount(2)
await expect(page.getByText('尚未采集', { exact: true })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('session-fixture-dark.png'), fullPage: true, animations: 'disabled' })
await page.getByRole('button', { name: '切换浅色主题' }).click()
await page.screenshot({ path: testInfo.outputPath('session-fixture-light.png'), fullPage: true, animations: 'disabled' })
await page.getByRole('button', { name: '断开连接', exact: true }).click()
await page.getByRole('button', { name: '取消', exact: true }).click()
expect(commands).toHaveLength(0)
await page.getByRole('button', { name: '断开连接', exact: true }).click()
await page.getByRole('button', { name: '确认提交', exact: true }).click()
await expect(page.getByText('断开命令已提交,等待终端回执')).toBeVisible()
expect(commands).toEqual([{ action: 'reset-session' }])
})
test('只读角色可查看会话,没有断开按钮与系统设置入口', async ({ page }) => {
await fixtures(page, 'viewer')
await page.goto('/sessions')
await expect(page.getByRole('heading', { name: '会话信息', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '断开连接', exact: true })).toHaveCount(0)
await expect(page.getByRole('navigation', { name: '主导航' }).getByRole('link', { name: '系统设置' })).toHaveCount(0)
await page.goto('/settings')
await expect(page.getByText('无访问权限', { exact: true })).toBeVisible()
})
test('连接预约不显示为已连接,未上报指标保持为空', async ({ page }) => {
await fixtures(page, 'operator', 'connecting')
await page.goto('/sessions')
await expect(page.getByText('终端正在建立连接')).toBeVisible()
await expect(page.getByText('0 个已连接', { exact: true })).toBeVisible()
await expect(page.getByText('1 个连接中', { exact: true })).toBeVisible()
await expect(page.getByText('暂无数据', { exact: true })).toHaveCount(2)
})
test('深浅主题跨页面和刷新保留,窄屏导航可用', async ({ page }) => {
await fixtures(page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/overview')
await expect(page.getByRole('heading', { name: '运行总览' })).toBeVisible()
await page.getByRole('button', { name: '切换浅色主题' }).click()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
await page.getByRole('button', { name: '打开导航' }).click()
await page.getByRole('navigation', { name: '主导航' }).getByRole('link', { name: '桌面管理' }).click()
await expect(page.getByRole('heading', { name: '桌面管理', exact: true })).toBeVisible()
await expect(page.locator('.sidebar')).not.toHaveClass(/is-open/)
await page.reload()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
await expect(page.locator('.desktop-card')).toHaveCount(1)
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
})

11
console/eslint.config.js Normal file
View File

@ -0,0 +1,11 @@
import js from '@eslint/js'
import ts from 'typescript-eslint'
import vue from 'eslint-plugin-vue'
import globals from 'globals'
export default ts.config(
{ ignores: ['dist/**', 'public/mockServiceWorker.js', 'playwright-report/**', 'test-results/**', 'src/api/schema.d.ts'] },
js.configs.recommended,
...ts.configs.recommended,
...vue.configs['flat/recommended'],
{ languageOptions: { globals: { ...globals.browser, ...globals.node }, parserOptions: { parser: ts.parser } }, rules: { 'vue/multi-word-component-names': 'off', 'vue/max-attributes-per-line': 'off', 'vue/html-self-closing': 'off', 'vue/singleline-html-element-content-newline': 'off', 'vue/html-indent': 'off', 'vue/html-closing-bracket-newline': 'off', 'vue/first-attribute-linebreak': 'off', 'vue/attributes-order': 'off' } },
)

2
console/index.html Normal file
View File

@ -0,0 +1,2 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>VDI 云桌面管理平台</title></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>

4690
console/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
console/package.json Normal file
View File

@ -0,0 +1,52 @@
{
"name": "vdi-console",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
"test:e2e": "playwright test",
"api:generate": "node ../tools/generate-platform-openapi.mjs && openapi-typescript ../docs/openapi/platform.v1.json -o src/api/schema.d.ts",
"mock:init": "msw init public --save"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"element-plus": "^2.14.8",
"openapi-fetch": "^0.17.0",
"pinia": "^3.0.3",
"vue": "^3.5.45",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@eslint/js": "^9.39.0",
"@playwright/test": "^1.58.2",
"@types/node": "^24.10.0",
"@vitejs/plugin-vue": "^6.0.10",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.39.0",
"eslint-plugin-vue": "^10.5.1",
"globals": "^16.5.0",
"msw": "^2.12.7",
"openapi-typescript": "^7.13.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.3",
"vite": "^7.2.2",
"vitest": "^5.1.0",
"vue-tsc": "^3.1.4"
},
"msw": {
"workerDirectory": [
"public"
]
},
"overrides": {
"@typescript-eslint/typescript-estree": {
"minimatch": "11.0.1"
}
}
}

View File

@ -0,0 +1,11 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
testIgnore: '**/live.spec.ts',
fullyParallel: false,
workers: 1,
timeout: 30000,
expect: { timeout: 12000 },
use: { baseURL: process.env.VDI_E2E_URL || 'http://127.0.0.1:5180', trace: 'retain-on-failure', screenshot: 'only-on-failure' },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'], ...(process.platform === 'win32' ? { channel: 'msedge' } : {}) } }],
})

View File

@ -0,0 +1,5 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir:'./e2e', testMatch:'live.spec.ts', workers:1, timeout:45000,
expect:{timeout:15000}, use:{baseURL:'http://127.0.0.1:5182',...(process.platform==='win32'?{channel:'msedge'}:{})},
})

View File

@ -0,0 +1,361 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.15.0'
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get('content-type')
?.toLowerCase()
.startsWith('text/event-stream')
// Clone the response so both the client and the library could consume it.
const responseClone = isEventStreamResponse ? null : response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
},
},
},
responseClone && responseClone.body
? [serializedRequest.body, responseClone.body]
: [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}

1
console/src/App.vue Normal file
View File

@ -0,0 +1 @@
<template><router-view /></template>

74
console/src/api/index.ts Normal file
View File

@ -0,0 +1,74 @@
import createClient from 'openapi-fetch'
import type { paths, components } from './schema'
export type Admin = components['schemas']['Administrator']
export type Desktop = components['schemas']['Desktop']
export type Terminal = components['schemas']['Terminal']
export type User = components['schemas']['User']
export type Resource = components['schemas']['Resource']
export type Task = components['schemas']['Task']
export type Audit = components['schemas']['Audit']
export type Overview = components['schemas']['Overview']
export type Settings = components['schemas']['Settings']
export type BindingInput = components['schemas']['BindingInput']
export type ImportInput = components['schemas']['ImportInput']
export type EnrollmentInput = components['schemas']['EnrollmentInput']
export type DiscoveredVM = components['schemas']['DiscoveredVM']
export type DesktopAction = components['schemas']['DesktopAction']['action']
export type TerminalAction = components['schemas']['TerminalAction']['action']
let accessToken = ''
export function setAccessToken(value: string) { accessToken = value }
export class ApiError extends Error {
constructor(message: string, public status = 0, public code = 'network_error', public requestId = '') { super(message) }
}
const client = createClient<paths>({ baseUrl: '/api/v1', fetch: async request => {
const timeout = AbortSignal.timeout(15000)
try { return await fetch(new Request(request, { signal: AbortSignal.any([request.signal, timeout]) })) }
catch { throw new ApiError('连接服务失败或请求超时,请检查网络后重试') }
} })
client.use({
onRequest({ request }) { if (accessToken) request.headers.set('Authorization', `Bearer ${accessToken}`) },
onResponse({ request, response }) { if (response.status === 401 && !request.url.endsWith('/auth/login')) window.dispatchEvent(new Event('vdi:unauthorized')) },
})
type Result<T> = { data?: T; error?: components['schemas']['Error']; response: Response }
async function result<T>(promise: Promise<Result<T>>): Promise<T> {
const r = await promise
if (r.error) throw new ApiError(r.error.message, r.response.status, r.error.code, r.error.requestId)
if (!r.response.ok) throw new ApiError(`请求失败(${r.response.status})`, r.response.status)
return r.data as T
}
const commandHeaders = () => ({ 'Idempotency-Key': crypto.randomUUID() })
const query = { page: 1, pageSize: 100 }
export const api = {
login: (username: string, password: string) => result(client.POST('/auth/login', { body: { username, password } })),
me: () => result(client.GET('/auth/me')),
logout: () => result(client.POST('/auth/logout')),
overview: () => result(client.GET('/overview')),
settings: () => result(client.GET('/settings')),
resources: () => result(client.GET('/resources', { params: { query } })),
desktops: (page = 1) => result(client.GET('/desktops', { params: { query: { ...query, page } } })),
terminals: (page = 1) => result(client.GET('/terminals', { params: { query: { ...query, page } } })),
users: (page = 1) => result(client.GET('/users', { params: { query: { ...query, page } } })),
tasks: (page = 1) => result(client.GET('/tasks', { params: { query: { ...query, page } } })),
taskDiagnostics: (id: string) => result(client.GET('/tasks/{id}/diagnostics', { params: { path: { id } } })),
audits: (page = 1) => result(client.GET('/audit-logs', { params: { query: { ...query, page } } })),
discoverVMs: () => result(client.GET('/resources/vms', { params: { query } })),
sync: () => result(client.POST('/resources/sync', { params: { header: commandHeaders() } })),
importDesktop: (body: ImportInput) => result(client.POST('/desktops/import', { body })),
desktopAction: (id: string, action: DesktopAction) => result(client.POST('/desktops/{id}/actions', { params: { path: { id }, header: commandHeaders() }, body: { action } })),
enroll: (body: EnrollmentInput) => result(client.POST('/terminals', { body })),
terminalCommand: (id: string, action: TerminalAction) => result(client.POST('/terminals/{id}/commands', { params: { path: { id }, header: commandHeaders() }, body: { action } })),
createUser: (body: { username: string; displayName: string }) => result(client.POST('/users', { body })),
updateBindings: (id: string, body: BindingInput) => result(client.PUT('/users/{id}/bindings', { params: { path: { id } }, body })),
}
export async function allPages<T>(loader: (page: number) => Promise<{ items: T[]; total: number }>): Promise<T[]> {
const first = await loader(1)
const items = [...first.items]
for (let page = 2; items.length < first.total && first.items.length > 0; page++) {
const next = await loader(page)
if (!next.items.length) break
items.push(...next.items)
}
return items
}
export const errorMessage = (e: unknown) => e instanceof ApiError ? `${e.message}${e.requestId ? `(请求 ${e.requestId})` : ''}` : e instanceof Error ? e.message : '操作失败,请重试'

3732
console/src/api/schema.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Ubuntu</title><path d="M17.61.455a3.41 3.41 0 0 0-3.41 3.41 3.41 3.41 0 0 0 3.41 3.41 3.41 3.41 0 0 0 3.41-3.41 3.41 3.41 0 0 0-3.41-3.41zM12.92.8C8.923.777 5.137 2.941 3.148 6.451a4.5 4.5 0 0 1 .26-.007 4.92 4.92 0 0 1 2.585.737A8.316 8.316 0 0 1 12.688 3.6 4.944 4.944 0 0 1 13.723.834 11.008 11.008 0 0 0 12.92.8zm9.226 4.994a4.915 4.915 0 0 1-1.918 2.246 8.36 8.36 0 0 1-.273 8.303 4.89 4.89 0 0 1 1.632 2.54 11.156 11.156 0 0 0 .559-13.089zM3.41 7.932A3.41 3.41 0 0 0 0 11.342a3.41 3.41 0 0 0 3.41 3.409 3.41 3.41 0 0 0 3.41-3.41 3.41 3.41 0 0 0-3.41-3.41zm2.027 7.866a4.908 4.908 0 0 1-2.915.358 11.1 11.1 0 0 0 7.991 6.698 11.234 11.234 0 0 0 2.422.249 4.879 4.879 0 0 1-.999-2.85 8.484 8.484 0 0 1-.836-.136 8.304 8.304 0 0 1-5.663-4.32zm11.405.928a3.41 3.41 0 0 0-3.41 3.41 3.41 3.41 0 0 0 3.41 3.41 3.41 3.41 0 0 0 3.41-3.41 3.41 3.41 0 0 0-3.41-3.41z"/></svg>

After

Width:  |  Height:  |  Size: 948 B

View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ value: string }>()
const labels: Record<string, string> = { running: '运行中', stopped: '已关机', online: '在线', offline: '离线', unregistered: '待激活', unknown: '未检测', ready: '已就绪', queued: '排队中', success: '成功', failed: '失败', timeout: '结果未确认', submitted: '已提交', denied: '无权限', demo: '演示', proxmox: 'Proxmox', enabled: '启用', disabled: '停用' }
const type = computed(() => ['online', 'success', 'ready', 'enabled', 'running', 'connected'].includes(props.value) ? 'success' : ['failed', 'denied'].includes(props.value) ? 'danger' : ['queued', 'timeout', 'unregistered', '执行中', 'connecting'].includes(props.value) ? 'warning' : 'info')
</script>
<template><el-tag :type="type" effect="light" size="small">{{ labels[value] || value }}</el-tag></template>

View File

@ -0,0 +1,13 @@
<script setup lang="ts">
import ubuntu from '../assets/ubuntu.svg'
import kylin from '../assets/kylinsoft.png'
defineProps<{ os: string }>()
</script>
<template>
<img v-if="os === 'Ubuntu'" :src="ubuntu" alt="Ubuntu" class="system-logo" />
<img v-else-if="os === '麒麟'" :src="kylin" alt="麒麟" class="system-logo" />
<span v-else class="windows-mark" role="img" aria-label="Windows"><i /><i /><i /><i /></span>
</template>
<style scoped>
.system-logo{width:28px;height:28px;object-fit:contain}.windows-mark{display:grid;grid-template-columns:repeat(2,11px);gap:3px;transform:perspective(80px) rotateY(-9deg);width:25px;height:25px}.windows-mark i{display:block;background:currentColor}
</style>

View File

@ -0,0 +1,8 @@
<script setup lang="ts">
import { Moon, Sunny } from '@element-plus/icons-vue'
import { useTheme } from '../composables/useTheme'
const { dark, toggle } = useTheme()
</script>
<template>
<el-button class="theme-toggle" :aria-label="dark ? '切换浅色主题' : '切换深色主题'" :title="dark ? '切换浅色主题' : '切换深色主题'" @click="toggle"><el-icon :size="18"><Sunny v-if="dark" /><Moon v-else /></el-icon></el-button>
</template>

View File

@ -0,0 +1,20 @@
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { errorMessage } from '../api'
import { usePlatform } from '../stores/platform'
export function useAction() {
const busy = ref(false)
async function run(work: () => Promise<unknown>, message = '操作已完成') {
if (busy.value) return false
busy.value = true
try { await work(); ElMessage.success(message); await usePlatform().refresh(); return true }
catch (e) { ElMessage.error(errorMessage(e)); return false }
finally { busy.value = false }
}
async function confirm(title: string, message: string) {
try { await ElMessageBox.confirm(message, title, { type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '取消' }); return true }
catch { return false }
}
return { busy, run, confirm }
}

View File

@ -0,0 +1,19 @@
import { computed, ref } from 'vue'
const key = 'vdi.theme'
const saved = (() => { try { return localStorage.getItem(key) } catch { return null } })()
const theme = ref<'dark' | 'light'>(saved === 'light' ? 'light' : 'dark')
function apply() {
document.documentElement.dataset.theme = theme.value
document.documentElement.style.colorScheme = theme.value
}
apply()
export function useTheme() {
const dark = computed(() => theme.value === 'dark')
function toggle() {
theme.value = dark.value ? 'light' : 'dark'
apply()
try { localStorage.setItem(key, theme.value) } catch { /* The theme still works without browser storage. */ }
}
return { theme, dark, toggle }
}

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Monitor, Menu, Cpu, User, List, Setting, DataAnalysis, Connection, ArrowRight, SwitchButton, Close, Refresh } from '@element-plus/icons-vue'
import { useAuth } from '../stores/auth'
import { usePlatform } from '../stores/platform'
import ThemeToggle from '../components/ThemeToggle.vue'
import brandMark from '../assets/brand-mark.svg'
const auth = useAuth(), data = usePlatform(), route = useRoute(), router = useRouter()
const items = [
{ path: '/overview', title: '云桌面总览', icon: DataAnalysis, group: 'work' },
{ path: '/desktops', title: '桌面管理', icon: Monitor, group: 'work' },
{ path: '/sessions', title: '远程会话', icon: Connection, group: 'work' },
{ path: '/terminals', title: '终端管理', icon: Menu, group: 'work' },
{ path: '/resources', title: '计算资源', icon: Cpu, group: 'manage' },
{ path: '/users', title: '用户与授权', icon: User, group: 'manage' },
{ path: '/tasks', title: '任务与审计', icon: List, group: 'manage' },
{ path: '/settings', title: '系统设置', icon: Setting, group: 'manage' },
]
const roles: Record<string, string> = { super_admin: '超级管理员', operator: '运维人员', viewer: '只读人员' }
const pending = computed(() => data.tasks.filter(t => t.status === 'queued' || t.status === 'running').length)
const avatar = computed(() => (auth.user?.displayName || auth.user?.username || '管').slice(0, 1))
const mockMode = import.meta.env.VITE_API_MODE === 'mock'
const navOpen = ref(false)
watch(() => route.path, () => { navOpen.value = false })
let timer: ReturnType<typeof setInterval>
onMounted(() => { void data.refresh(); timer = setInterval(() => { if (!document.hidden) void data.refresh() }, 5000) })
onUnmounted(() => clearInterval(timer))
async function logout() { try { await auth.logout() } catch { /* Local session is cleared even if the network is unavailable. */ } data.clear(); await router.replace('/login') }
</script>
<template>
<div class="console-shell">
<button v-if="navOpen" class="nav-scrim" aria-label="关闭导航遮罩" @click="navOpen = false" />
<aside id="console-navigation" class="sidebar" :class="{ 'is-open': navOpen }">
<div class="brand-row"><router-link to="/overview" class="brand"><img :src="brandMark" alt="" /><span>百工万象<small>VDI CLOUD</small></span></router-link><el-button class="mobile-close" text aria-label="关闭导航" @click="navOpen = false"><el-icon><Close /></el-icon></el-button></div>
<div class="organization"><small>云桌面管理平台</small><strong>VDI Cloud · 管理控制台</strong><span><i :class="{ connected: data.settings?.pveConfigured }" />{{ data.settings?.pveConfigured ? 'Proxmox 已接入' : '等待接入计算资源' }}</span></div>
<nav aria-label="主导航"><template v-for="group in [{ id: 'work', label: '云桌面工作台' }, { id: 'manage', label: '资源与管理' }]" :key="group.id"><div class="nav-label">{{ group.label }}</div><router-link v-for="item in items.filter(i => i.group === group.id && (i.path !== '/settings' || auth.canAdmin))" :key="item.path" :to="item.path" :class="{ active: route.path === item.path }" :aria-current="route.path === item.path ? 'page' : undefined"><el-icon :size="18"><component :is="item.icon" /></el-icon><span>{{ item.title }}</span><span v-if="item.path === '/tasks' && pending" class="nav-count">{{ pending }}</span></router-link></template></nav>
<div class="sidebar-bottom"><div class="profile"><div class="avatar">{{ avatar }}</div><div class="profile-name"><strong>{{ auth.user?.displayName }}</strong><small>{{ roles[auth.user?.role || ''] }}</small></div><el-button text aria-label="退出登录" title="退出登录" @click="logout"><el-icon :size="18"><SwitchButton /></el-icon></el-button></div><div class="sidebar-footnote">云 · 管 · 端<span>VDI Cloud</span></div></div>
</aside>
<div class="workspace">
<header class="topbar"><div class="topbar-left"><el-button class="mobile-menu" aria-label="打开导航" :aria-expanded="navOpen" aria-controls="console-navigation" @click="navOpen = !navOpen"><el-icon :size="20"><Menu /></el-icon></el-button><div class="breadcrumbs"><span>VDI CLOUD</span><el-icon :size="11"><ArrowRight /></el-icon><span>{{ route.meta.title }}</span></div></div><div class="topbar-right"><span v-if="mockMode || data.settings?.mode === 'demo'" class="environment is-demo"><i />{{ mockMode ? '模拟接口' : '演示环境' }}</span><span v-else-if="data.settings?.mode === 'production'" class="environment"><i />现场联调环境</span><el-button class="header-refresh" text aria-label="刷新平台数据" title="刷新平台数据" @click="data.refresh"><el-icon :size="18"><Refresh /></el-icon></el-button><ThemeToggle /></div></header>
<main id="main-content" class="page-container"><el-alert v-if="data.error" class="notice" type="error" :closable="false" show-icon title="数据刷新失败,当前信息可能已过期"><template #default>{{ data.error }} <el-button link type="primary" @click="data.refresh">重新加载</el-button></template></el-alert><div v-if="data.loading" class="panel"><el-skeleton :rows="8" animated /></div><router-view v-else /></main>
<footer class="workspace-footer"><span><span class="refresh-dot" />{{ data.updatedAt ? `最近刷新 ${data.updatedAt}` : '等待加载' }}<span class="refresh-note"> · 每 5 秒更新</span></span><router-link to="/tasks">{{ pending ? `${pending} 个任务等待回执` : '查看任务与审计' }} <el-icon><ArrowRight /></el-icon></router-link></footer>
</div>
</div>
</template>
<style scoped>
.console-shell{display:grid;grid-template-columns:248px minmax(0,1fr);min-height:100vh}.sidebar{background:var(--vdi-panel);border-right:1px solid var(--vdi-border);display:flex;flex-direction:column;padding:28px 18px 18px;position:sticky;top:0;height:100vh;overflow-y:auto;z-index:20}.brand-row{display:flex;align-items:center;justify-content:space-between}.brand{display:flex;gap:10px;align-items:center;color:var(--vdi-text);padding:0 10px;font-weight:750;font-size:21px;letter-spacing:1px}.brand img{width:34px;height:34px;object-fit:contain}.brand small{display:block;color:var(--vdi-muted);font-size:9px;font-weight:500;letter-spacing:2.2px;margin-top:5px}.organization{background:var(--vdi-panel-2);border:1px solid var(--vdi-border);border-radius:16px;padding:15px 14px;margin:26px 0 20px}.organization small{color:var(--vdi-muted);display:block;margin-bottom:8px;font-size:11px}.organization strong{font-size:13px;display:block;font-weight:600}.organization>span{display:flex;align-items:center;gap:6px;color:var(--vdi-muted);font-size:10px;margin-top:12px}.organization i,.environment i{width:6px;height:6px;display:inline-block;background:var(--vdi-muted);border-radius:50%}.organization i.connected{background:var(--vdi-ok)}.nav-label{font-size:10px;letter-spacing:1.4px;color:var(--vdi-muted);margin:18px 14px 12px}nav a{display:flex;align-items:center;gap:12px;padding:13px 15px;color:var(--vdi-muted);border-radius:12px;margin-bottom:6px;font-size:14px;min-height:47px}nav a:hover{background:var(--vdi-panel-2);color:var(--vdi-text)}nav a.active{background:var(--vdi-accent-soft);color:var(--vdi-accent);font-weight:650}.nav-count{margin-left:auto;font-size:11px;background:var(--vdi-brand);color:white;min-width:19px;text-align:center;border-radius:5px;padding:2px}.sidebar-bottom{margin-top:auto;padding-top:32px}.profile{background:var(--vdi-accent-soft);border-radius:16px;padding:12px;display:flex;align-items:center;gap:10px}.avatar{width:36px;height:36px;border-radius:11px;flex-shrink:0;background:linear-gradient(145deg,#f5ca91,#c97235);display:grid;place-items:center;color:#fff;font-weight:650}.profile-name{min-width:0;flex:1}.profile strong{font-size:13px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.profile small{display:block;font-size:11px;color:var(--vdi-muted);margin-top:3px}.profile .el-button{padding:4px;color:var(--vdi-muted)}.sidebar-footnote{display:flex;justify-content:space-between;color:var(--vdi-muted);font-size:10px;margin:18px 4px 0}.workspace{min-width:0;display:flex;flex-direction:column}.topbar{min-height:82px;padding:20px 42px 0;display:flex;justify-content:space-between;align-items:center;gap:16px}.topbar-left,.topbar-right,.breadcrumbs{display:flex;align-items:center;gap:16px}.breadcrumbs{font-size:11px;color:var(--vdi-muted);letter-spacing:.5px;gap:12px}.topbar-right{gap:12px}.environment{display:flex;align-items:center;gap:7px;font-size:11px;color:var(--vdi-muted)}.environment i{background:var(--vdi-ok)}.environment.is-demo i{background:var(--vdi-warn)}.header-refresh{color:var(--vdi-muted)}.page-container{padding:16px 42px 32px;flex:1;min-width:0;width:100%;max-width:1920px;margin:0 auto}.workspace-footer{padding:16px 42px;display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:11px;color:var(--vdi-muted)}.workspace-footer a{display:flex;gap:5px;align-items:center}.refresh-dot{width:5px;height:5px;border-radius:50%;background:var(--vdi-ok);display:inline-block;margin-right:6px}.mobile-menu,.mobile-close{display:none}.nav-scrim{display:none}
@media(max-width:1200px){.console-shell{grid-template-columns:222px minmax(0,1fr)}.sidebar{padding:25px 14px 16px}.brand{font-size:20px;padding:0 6px}.page-container{padding:18px 26px 26px}.topbar{padding:18px 26px 0}.workspace-footer{padding:16px 26px}.organization{padding:14px 12px}}
@media(max-width:760px){.console-shell{grid-template-columns:minmax(0,1fr)}.sidebar{position:fixed;left:0;top:0;bottom:0;width:248px;transform:translateX(-100%);transition:transform .2s ease}.sidebar.is-open{transform:translateX(0)}.mobile-menu,.mobile-close{display:inline-flex}.mobile-menu{padding:8px;width:38px;height:38px;min-height:38px}.mobile-close{color:var(--vdi-muted);padding:5px}.nav-scrim{display:block;position:fixed;inset:0;background:rgb(0 0 0 / 50%);border:0;z-index:19}.page-container{padding:20px 18px}.topbar{padding:16px 18px 0;min-height:68px}.topbar-left,.topbar-right{gap:10px}.breadcrumbs{gap:7px;font-size:10px}.environment{font-size:10px}.header-refresh{display:none}.workspace-footer{padding:16px 18px}.organization{margin-top:25px}}
@media(max-width:410px){.breadcrumbs>span:first-child{display:none}.breadcrumbs>.el-icon{display:none}.refresh-note{display:none}.environment{font-size:9px}.page-container{padding:18px 14px}.topbar{padding-left:14px;padding-right:14px}}
@media(prefers-reduced-motion:reduce){.sidebar{transition:none}}
</style>

21
console/src/main.ts Normal file
View File

@ -0,0 +1,21 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import './styles/tokens.css'
import App from './App.vue'
import { router } from './router'
import { useAuth } from './stores/auth'
import { usePlatform } from './stores/platform'
async function start() {
if (import.meta.env.DEV && import.meta.env.VITE_API_MODE === 'mock') {
const { worker } = await import('./mocks/browser')
await worker.start({ onUnhandledRequest: 'bypass' })
}
const app = createApp(App); app.use(createPinia()); app.use(router); app.use(ElementPlus, { locale: zhCn })
window.addEventListener('vdi:unauthorized', () => { useAuth().clear(); usePlatform().clear(); if (router.currentRoute.value.path !== '/login') void router.replace('/login') })
await router.isReady(); app.mount('#app')
}
void start()

View File

@ -0,0 +1,3 @@
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)

View File

@ -0,0 +1,47 @@
import { http, HttpResponse } from 'msw'
import type { Admin, Desktop, Terminal, User, Task, Resource, Audit } from '../api'
const now = () => new Date().toISOString()
const administrators: Record<string, Admin> = {
admin: { id: 'mock-admin', username: 'admin', displayName: '系统管理员', role: 'super_admin' },
operator: { id: 'mock-operator', username: 'operator', displayName: '运维人员', role: 'operator' },
viewer: { id: 'mock-viewer', username: 'viewer', displayName: '只读人员', role: 'viewer' },
}
const desktops: Desktop[] = [
{ id: 'desk_mock_windows', name: '设计专属桌面', os: 'Windows', node: 'demo-node-a', vmid: 201, pool: '设计专属池', powerState: 'running', serviceState: 'unknown', provider: 'demo', endpoint: '', app: 'Desktop', width: 2560, height: 1440, fps: 30, updatedAt: now() },
{ id: 'desk_mock_ubuntu', name: '研发办公桌面', os: 'Ubuntu', node: 'demo-node-a', vmid: 202, pool: '研发专属池', powerState: 'running', serviceState: 'unknown', provider: 'demo', endpoint: '', app: 'Desktop', width: 2560, height: 1440, fps: 30, updatedAt: now() },
{ id: 'desk_mock_kylin', name: '国产办公桌面', os: '麒麟', node: 'demo-node-a', vmid: 203, pool: '通用办公池', powerState: 'stopped', serviceState: 'unknown', provider: 'demo', endpoint: '', app: 'Desktop', width: 2560, height: 1440, fps: 30, updatedAt: now() },
]
const terminals: Terminal[] = [{ id: 'term_mock_001', sn: 'DEMO-RK-001', name: '设计工位 01', group: '设计部', status: 'offline', agentVersion: '演示版本', lastSeen: null, sessionDesktopId: null, configVersion: 1, fps: null, rtt: null }]
const users: User[] = [{ id: 'user_mock_wang', username: 'wang.demo', displayName: '王工', enabled: true, terminalId: 'term_mock_001', desktopIds: ['desk_mock_windows'] }]
const tasks: Task[] = [], audits: Audit[] = []
const resources: Resource[] = [{ id: 'demo-node-a', name: 'demo-node-a', status: 'online', cpu: 0.24, memory: 0.48, storage: 0.35, provider: 'demo', updatedAt: now() }]
const revoked = new Set<string>()
const keyFor = (r: Request) => r.headers.get('Authorization')?.replace('Bearer mock-', '') || ''
const admin = (r: Request) => !revoked.has(keyFor(r)) ? administrators[keyFor(r)] : undefined
const error = (status: number, message: string) => HttpResponse.json({ code: `mock_${status}`, message, requestId: `mock-${crypto.randomUUID()}` }, { status })
function guard(r: Request, write = false, adminOnly = false) { const a = admin(r); if (!a) return error(401, '登录已失效,请重新登录'); if ((write && a.role === 'viewer') || (adminOnly && a.role !== 'super_admin')) return error(403, '当前账号没有该操作权限') }
function list<T>(request: Request, items: T[]) { const q = new URL(request.url).searchParams; const page = Math.max(1, Number(q.get('page')) || 1), pageSize = Math.min(100, Math.max(1, Number(q.get('pageSize')) || 20)); return HttpResponse.json({ items: items.slice((page - 1) * pageSize, page * pageSize), page, pageSize, total: items.length, requestId: `mock-${crypto.randomUUID()}` }) }
function audit(r: Request, action: string, target: string, result = 'success') { audits.unshift({ id: crypto.randomUUID(), actor: admin(r)?.username || '', action, target, result, requestId: crypto.randomUUID(), createdAt: now() }) }
function submit(request: Request, kind: string, action: string, targetId: string, targetName: string, finish: () => void) {
if (tasks.some(t => t.targetId === targetId && ['queued', 'running'].includes(t.status))) return error(409, '该对象已有任务执行中')
const t: Task = { id: `task_mock_${crypto.randomUUID()}`, kind, action, targetId, targetName, actor: admin(request)?.username || '', status: 'queued', message: '模拟任务已提交', providerRef: '', createdAt: now(), updatedAt: now() }
tasks.unshift(t); audit(request, action, targetName, 'submitted')
setTimeout(() => { t.status = 'running'; t.message = '模拟执行中'; t.updatedAt = now() }, 400)
setTimeout(() => { t.status = kind === 'terminal' ? 'failed' : 'success'; t.message = kind === 'terminal' ? '模拟终端离线,未执行' : '模拟任务已完成;没有操作真实资源'; t.updatedAt = now(); if (t.status === 'success') finish(); audit(request, action, targetName, t.status) }, 1500)
return HttpResponse.json(t, { status: 202 })
}
export const handlers = [
http.post('/api/v1/auth/login', async ({ request }) => { const b = await request.json() as { username: string; password: string }; const a = administrators[b.username]; if (!a || b.password !== 'Demo@2026') return error(401, '用户名或密码错误'); revoked.delete(b.username); audit(new Request(request.url, { headers: { Authorization: `Bearer mock-${b.username}` } }), 'login', b.username); return HttpResponse.json({ accessToken: `mock-${b.username}`, expiresIn: 3600, user: a }) }),
http.get('/api/v1/auth/me', ({ request }) => guard(request) || HttpResponse.json(admin(request))),
http.post('/api/v1/auth/logout', ({ request }) => { const e = guard(request); if (e) return e; revoked.add(keyFor(request)); return new HttpResponse(null, { status: 204 }) }),
http.get('/api/v1/settings', ({ request }) => guard(request) || HttpResponse.json({ mode: 'demo', computeProvider: 'demo', pveConfigured: false, terminalTransport: 'mock', capabilities: ['auth', 'resources', 'desktops', 'terminals', 'bindings', 'tasks', 'audit'] })),
http.get('/api/v1/overview', ({ request }) => guard(request) || HttpResponse.json({ terminalCount: terminals.length, onlineTerminals: 0, desktopCount: desktops.length, runningDesktops: desktops.filter(d => d.powerState === 'running').length, activeSessions: 0, failedTasks: tasks.filter(t => ['failed', 'timeout'].includes(t.status)).length, mode: 'demo' })),
...([['desktops', desktops], ['terminals', terminals], ['users', users], ['resources', resources], ['tasks', tasks], ['audit-logs', audits]] as const).map(([path, data]) => http.get(`/api/v1/${path}`, ({ request }) => guard(request) || list(request, data as unknown[]))),
http.post('/api/v1/desktops/:id/actions', async ({ request, params }) => { const e = guard(request, true); if (e) return e; const d = desktops.find(d => d.id === params.id); if (!d) return error(404, '桌面不存在'); const b = await request.json() as { action: string }; if (!['start', 'shutdown', 'reboot'].includes(b.action)) return error(400, '不支持的操作'); return submit(request, 'desktop', b.action, d.id, d.name, () => { d.powerState = b.action === 'shutdown' ? 'stopped' : 'running'; d.updatedAt = now() }) }),
http.post('/api/v1/terminals', async ({ request }) => { const e = guard(request, true, true); if (e) return e; const b = await request.json() as { sn: string; name: string; group: string }; if (terminals.some(t => t.sn === b.sn)) return error(409, 'SN 已登记'); const id = `term_mock_${crypto.randomUUID()}`; terminals.push({ ...b, id, status: 'unregistered', agentVersion: '', lastSeen: null, sessionDesktopId: null, configVersion: 1, fps: null, rtt: null }); audit(request, 'enroll_terminal', id); return HttpResponse.json({ id, activationSecret: `mock-only-${crypto.randomUUID()}` }, { status: 201 }) }),
http.post('/api/v1/terminals/:id/commands', async ({ request, params }) => { const e = guard(request, true); if (e) return e; const t = terminals.find(t => t.id === params.id); if (!t) return error(404, '终端不存在'); const b = await request.json() as { action: string }; return submit(request, 'terminal', b.action, t.id, t.name, () => {}) }),
http.post('/api/v1/users', async ({ request }) => { const e = guard(request, true, true); if (e) return e; const b = await request.json() as { username: string; displayName: string }; if (users.some(u => u.username === b.username)) return error(409, '账号已存在'); const u: User = { ...b, id: `user_mock_${crypto.randomUUID()}`, enabled: true, terminalId: null, desktopIds: [] }; users.push(u); audit(request, 'create_user', u.id); return HttpResponse.json(u, { status: 201 }) }),
http.put('/api/v1/users/:id/bindings', async ({ request, params }) => { const e = guard(request, true, true); if (e) return e; const u = users.find(u => u.id === params.id); if (!u) return error(404, '用户不存在'); const b = await request.json() as { terminalId: string | null; desktopIds: string[]; enabled: boolean }; if (b.terminalId && users.some(x => x.id !== u.id && x.terminalId === b.terminalId)) return error(409, '终端已关联其他用户'); Object.assign(u, b); audit(request, 'update_bindings', u.id); return HttpResponse.json(u) }),
http.get('/api/v1/resources/vms', ({ request }) => guard(request) || error(503, '模拟环境未接入 Proxmox')),
http.post('/api/v1/resources/sync', ({ request }) => guard(request, true, true) || error(503, '模拟环境未接入 Proxmox')),
]

View File

@ -0,0 +1,25 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuth } from '../stores/auth'
export const router = createRouter({ history: createWebHistory(), routes: [
{ path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } },
{ path: '/', component: () => import('../layouts/ConsoleLayout.vue'), children: [
{ path: '', redirect: '/overview' },
{ path: 'overview', component: () => import('../views/Overview.vue'), meta: { title: '总览' } },
{ path: 'resources', component: () => import('../views/Resources.vue'), meta: { title: '计算资源' } },
{ path: 'desktops', component: () => import('../views/Desktops.vue'), meta: { title: '桌面管理' } },
{ path: 'sessions', component: () => import('../views/Sessions.vue'), meta: { title: '远程会话' } },
{ path: 'terminals', component: () => import('../views/Terminals.vue'), meta: { title: '终端管理' } },
{ path: 'users', component: () => import('../views/Users.vue'), meta: { title: '用户与授权' } },
{ path: 'tasks', component: () => import('../views/Tasks.vue'), meta: { title: '任务与审计' } },
{ path: 'settings', component: () => import('../views/Settings.vue'), meta: { title: '系统设置', adminOnly: true } },
{ path: 'forbidden', component: () => import('../views/Forbidden.vue'), meta: { title: '无访问权限' } },
{ path: ':pathMatch(.*)*', component: () => import('../views/NotFound.vue'), meta: { title: '页面不存在' } },
] },
] })
router.beforeEach(async to => {
const auth = useAuth(); await auth.restore()
if (!to.meta.public && !auth.user) return { path: '/login', query: { redirect: to.fullPath } }
if (to.path === '/login' && auth.user) return '/overview'
if (to.meta.adminOnly && !auth.canAdmin) return '/forbidden'
})

View File

@ -0,0 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { api, setAccessToken, type Admin } from '../api'
import { useAuth } from './auth'
vi.mock('../api', () => ({ api: { login: vi.fn(), me: vi.fn(), logout: vi.fn() }, setAccessToken: vi.fn() }))
let storage: Map<string, string>
const viewer: Admin = { id: 'viewer', username: 'viewer', displayName: '只读', role: 'viewer' }
beforeEach(() => { vi.clearAllMocks(); storage = new Map(); vi.stubGlobal('sessionStorage', { getItem: (k: string) => storage.get(k) ?? null, setItem: (k: string, v: string) => storage.set(k, v), removeItem: (k: string) => storage.delete(k) }); setActivePinia(createPinia()) })
describe('管理员会话', () => {
it('只读角色登录后仍没有写权限', async () => { vi.mocked(api.login).mockResolvedValue({ accessToken: 'jwt', expiresIn: 3600, user: viewer }); const auth = useAuth(); await auth.login('viewer', 'password'); expect(auth.canOperate).toBe(false); expect(auth.canAdmin).toBe(false); expect(storage.get('vdi.accessToken')).toBe('jwt') })
it('同时触发恢复只请求一次;令牌失效清除会话', async () => { storage.set('vdi.accessToken', 'expired'); vi.mocked(api.me).mockRejectedValue(new Error('expired')); const auth = useAuth(); await Promise.all([auth.restore(), auth.restore()]); expect(api.me).toHaveBeenCalledTimes(1); expect(auth.user).toBeNull(); expect(storage.has('vdi.accessToken')).toBe(false); expect(setAccessToken).toHaveBeenLastCalledWith('') })
it('退出接口失败也移除本地登录凭据', async () => { vi.mocked(api.login).mockResolvedValue({ accessToken: 'jwt', expiresIn: 3600, user: viewer }); vi.mocked(api.logout).mockRejectedValue(new Error('offline')); const auth = useAuth(); await auth.login('viewer', 'password'); await expect(auth.logout()).rejects.toThrow('offline'); expect(auth.token).toBe(''); expect(auth.user).toBeNull(); expect(storage.has('vdi.accessToken')).toBe(false) })
})

View File

@ -0,0 +1,27 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api, setAccessToken, type Admin } from '../api'
const tokenKey = 'vdi.accessToken'
export const useAuth = defineStore('auth', () => {
const user = ref<Admin | null>(null)
const token = ref(sessionStorage.getItem(tokenKey) || '')
const initialized = ref(false)
let restoring: Promise<void> | undefined
setAccessToken(token.value)
const canOperate = computed(() => !!user.value && user.value.role !== 'viewer')
const canAdmin = computed(() => user.value?.role === 'super_admin')
function clear() { user.value = null; token.value = ''; initialized.value = true; sessionStorage.removeItem(tokenKey); setAccessToken('') }
async function login(username: string, password: string) {
const data = await api.login(username, password)
token.value = data.accessToken; user.value = data.user; initialized.value = true
setAccessToken(token.value); sessionStorage.setItem(tokenKey, token.value)
}
async function restore() {
if (initialized.value) return
if (!restoring) restoring = (async () => { try { if (token.value) user.value = await api.me() } catch { clear() } finally { initialized.value = true } })()
await restoring
}
async function logout() { try { await api.logout() } finally { clear() } }
return { user, token, initialized, canOperate, canAdmin, login, restore, clear, logout }
})

View File

@ -0,0 +1,29 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api, allPages, errorMessage, type Desktop, type Terminal, type User, type Task, type Audit, type Resource, type Settings, type Overview } from '../api'
export const usePlatform = defineStore('platform', () => {
const desktops = ref<Desktop[]>([]), terminals = ref<Terminal[]>([]), users = ref<User[]>([])
const tasks = ref<Task[]>([]), audits = ref<Audit[]>([]), resources = ref<Resource[]>([])
const settings = ref<Settings | null>(null), overview = ref<Overview | null>(null)
const loading = ref(false), error = ref(''), updatedAt = ref('')
let inFlight: Promise<void> | undefined
let generation = 0
function refresh() {
if (inFlight) return inFlight
loading.value = !updatedAt.value
const startedGeneration = generation
inFlight = (async () => {
try {
const [d, t, u, k, a, r, s, o] = await Promise.all([allPages(api.desktops), allPages(api.terminals), allPages(api.users), allPages(api.tasks), allPages(api.audits), api.resources(), api.settings(), api.overview()])
if (startedGeneration !== generation) return
desktops.value = d; terminals.value = t; users.value = u; tasks.value = k; audits.value = a; resources.value = r.items; settings.value = s; overview.value = o
updatedAt.value = new Date().toLocaleTimeString('zh-CN'); error.value = ''
} catch (e) { if (startedGeneration === generation) error.value = errorMessage(e) }
finally { if (startedGeneration === generation) { loading.value = false; inFlight = undefined } }
})()
return inFlight
}
function clear() { generation++; inFlight = undefined; loading.value = false; desktops.value = []; terminals.value = []; users.value = []; tasks.value = []; audits.value = []; resources.value = []; settings.value = null; overview.value = null; error.value = ''; updatedAt.value = '' }
return { desktops, terminals, users, tasks, audits, resources, settings, overview, loading, error, updatedAt, refresh, clear }
})

View File

@ -0,0 +1,74 @@
:root {
--vdi-bg:#f5f7fa; --vdi-panel:#fff; --vdi-panel-2:#f0f3f7;
--vdi-text:#1b2533; --vdi-muted:#64748b; --vdi-border:#e5e9ef;
--vdi-brand:#bd713b; --vdi-accent:#965421; --vdi-accent-soft:#fff0e4;
--vdi-ok:#16845e; --vdi-warn:#9a6b10; --vdi-danger:#ce4652;
--vdi-radius:18px; --vdi-shadow:0 16px 40px rgb(27 37 51 / 6%);
--el-color-primary:var(--vdi-brand); --el-color-primary-light-3:#ce966f;
--el-color-primary-light-5:#ddb495; --el-color-primary-light-7:#ebd1bd;
--el-color-primary-light-8:#f3e2d5; --el-color-primary-light-9:#fcf3ed;
--el-color-primary-dark-2:#a9602e;
--el-color-success:#16845e; --el-color-success-light-3:#5fa98c;
--el-color-success-light-5:#91c2ad; --el-color-success-light-7:#c4ded2;
--el-color-success-light-8:#d9ebe3; --el-color-success-light-9:#eaf5ef;
--el-color-warning:#9a6b10; --el-color-warning-light-5:#cfb78b;
--el-color-warning-light-7:#e6d7bb; --el-color-warning-light-8:#efe5d4;
--el-color-warning-light-9:#faf4e8;
--el-color-danger:#ce4652; --el-color-danger-light-5:#e59ca3;
--el-color-danger-light-7:#f0c4c8; --el-color-danger-light-8:#f5dadd;
--el-color-danger-light-9:#fcf0f1;
--el-color-info:#64748b; --el-color-info-light-5:#a6b0bd;
--el-color-info-light-7:#cdd3db; --el-color-info-light-8:#e0e4e9;
--el-color-info-light-9:#f0f3f7;
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
color:var(--vdi-text); background:var(--vdi-bg); font-synthesis:none; text-rendering:optimizeLegibility;
}
:root[data-theme="dark"] {
--vdi-bg:#151d27; --vdi-panel:#1d2733; --vdi-panel-2:#253241;
--vdi-text:#edf1f6; --vdi-muted:#a5b2c3; --vdi-border:#314050;
--vdi-accent:#f1ad62; --vdi-accent-soft:#493323;
--vdi-ok:#36ca94; --vdi-warn:#efb744; --vdi-danger:#f07881;
--vdi-shadow:0 18px 45px rgb(0 0 0 / 14%);
--el-color-primary-light-3:#ce966f; --el-color-primary-light-5:#88603f;
--el-color-primary-light-7:#5c422f; --el-color-primary-light-8:#493323;
--el-color-primary-light-9:#352c27;
--el-color-success:#36ca94; --el-color-success-light-3:#2fa77e;
--el-color-success-light-5:#24644f; --el-color-success-light-7:#20453f;
--el-color-success-light-8:#203a37; --el-color-success-light-9:#1d3332;
--el-color-warning:#efb744; --el-color-warning-light-5:#77643c;
--el-color-warning-light-7:#514735; --el-color-warning-light-8:#423c30;
--el-color-warning-light-9:#34332e;
--el-color-danger:#f07881; --el-color-danger-light-5:#85505a;
--el-color-danger-light-7:#5c3d47; --el-color-danger-light-8:#49333d;
--el-color-danger-light-9:#372c35;
--el-color-info:#a5b2c3; --el-color-info-light-5:#58697d;
--el-color-info-light-7:#3c4c60; --el-color-info-light-8:#314050;
--el-color-info-light-9:#253241;
}
:root {
--el-bg-color:var(--vdi-panel); --el-bg-color-page:var(--vdi-bg); --el-bg-color-overlay:var(--vdi-panel);
--el-text-color-primary:var(--vdi-text); --el-text-color-regular:var(--vdi-text);
--el-text-color-secondary:var(--vdi-muted); --el-text-color-placeholder:var(--vdi-muted);
--el-text-color-disabled:var(--vdi-muted);
--el-border-color:var(--vdi-border); --el-border-color-light:var(--vdi-border);
--el-border-color-lighter:var(--vdi-border); --el-border-color-extra-light:var(--vdi-border);
--el-border-color-dark:var(--vdi-border); --el-border-color-darker:var(--vdi-muted);
--el-fill-color:var(--vdi-panel-2); --el-fill-color-light:var(--vdi-panel-2);
--el-fill-color-lighter:var(--vdi-panel-2); --el-fill-color-extra-light:var(--vdi-panel-2);
--el-fill-color-blank:var(--vdi-panel); --el-fill-color-dark:var(--vdi-border);
--el-fill-color-darker:var(--vdi-border); --el-fill-color-disabled:var(--vdi-panel-2);
--el-disabled-bg-color:var(--vdi-panel-2); --el-disabled-text-color:var(--vdi-muted);
--el-disabled-border-color:var(--vdi-border);
--el-border-radius-base:10px; --el-border-radius-small:7px;
--el-mask-color:rgb(0 0 0 / 55%); --el-box-shadow-light:var(--vdi-shadow);
--el-box-shadow:var(--vdi-shadow); --el-font-family:inherit;
}
*{box-sizing:border-box}body{margin:0;min-width:320px}button,input{font:inherit}button:focus-visible,a:focus-visible{outline:2px solid var(--vdi-accent);outline-offset:4px}a{color:var(--vdi-accent);text-decoration:none}h1,h2,h3,p{margin:0}h1{font-size:29px;line-height:1.35;font-weight:750;letter-spacing:.4px}h2{font-size:17px;font-weight:650}h3{font-size:15px;font-weight:600}
.page-heading{display:flex;gap:16px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-bottom:25px}.subtext{font-size:13px;color:var(--vdi-muted);margin-top:7px;line-height:1.7}.eyebrow{font-size:11px;color:var(--vdi-muted);letter-spacing:1.8px;margin-bottom:9px}.panel{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:var(--vdi-radius);padding:22px;margin-bottom:20px;min-width:0;box-shadow:var(--vdi-shadow)}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:20px}.panel-heading a{font-size:12px}
.toolbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:20px}.toolbar .el-input{width:260px}.toolbar .el-select{width:150px}.actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.actions .el-button+.el-button{margin-left:0}.mono{font-family:ui-monospace,monospace;font-size:12px;overflow-wrap:anywhere}.secondary{font-size:12px;color:var(--vdi-muted);line-height:1.7}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:18px;margin-bottom:22px}.stat{display:block;width:100%;border:1px solid var(--vdi-border);border-radius:var(--vdi-radius);background:var(--vdi-panel);padding:22px;text-align:left;color:inherit;cursor:pointer;box-shadow:var(--vdi-shadow)}.stat:hover{border-color:var(--vdi-brand)}.stat strong{display:block;font-size:32px;font-weight:750;margin:12px 0 9px;line-height:1.2}.stat span{color:var(--vdi-muted);font-size:12px}.stat-label{font-size:13px;color:var(--vdi-muted);display:flex;justify-content:space-between;align-items:center}.stat-caption{font-size:12px;color:var(--vdi-muted)}.stat-unit{font-size:14px;font-weight:500;margin-left:4px}.two-columns{display:grid;grid-template-columns:1.35fr .85fr;gap:20px}.row{display:flex;justify-content:space-between;gap:18px;align-items:center;flex-wrap:wrap;margin:16px 0}.tone-ok{color:var(--vdi-ok)}.tone-warn{color:var(--vdi-warn)}.tone-danger{color:var(--vdi-danger)}.health-dot{display:inline-block;width:8px;height:8px;background:currentColor;border-radius:50%;margin-right:9px;flex-shrink:0}
.el-button{min-height:39px;padding:10px 16px;font-weight:500;--el-button-hover-text-color:var(--vdi-accent);--el-button-hover-bg-color:var(--vdi-accent-soft);--el-button-hover-border-color:var(--vdi-brand)}.el-button--primary{--el-button-text-color:#fff;--el-button-hover-text-color:#fff;--el-button-hover-bg-color:#ce824a;--el-button-hover-border-color:#ce824a;--el-button-active-bg-color:#a9602e;--el-button-active-border-color:#a9602e;--el-button-disabled-text-color:rgb(255 255 255 / 70%);--el-button-disabled-bg-color:#825a3d;--el-button-disabled-border-color:#825a3d}.el-button.is-link,.el-button.is-text{min-height:30px;padding:5px 8px}.el-button.is-link{color:var(--vdi-accent)}.el-button.is-disabled.is-link{color:var(--vdi-muted);opacity:.6}.el-button-group .el-button{min-height:36px}.theme-toggle.el-button{width:42px;height:42px;padding:0;flex-shrink:0;background:var(--vdi-panel-2);border-radius:11px}
.el-table{--el-table-header-bg-color:var(--vdi-panel-2);--el-table-bg-color:var(--vdi-panel);--el-table-tr-bg-color:var(--vdi-panel);--el-table-row-hover-bg-color:var(--vdi-panel-2);--el-table-border-color:var(--vdi-border);--el-table-header-text-color:var(--vdi-muted);--el-table-text-color:var(--vdi-text);border-radius:11px}.el-table th.el-table__cell{font-weight:500;padding:14px 0;font-size:12px}.el-table td.el-table__cell{padding:17px 0}.el-table .cell{padding:0 14px}.el-table__empty-text{color:var(--vdi-muted)}.el-pagination{margin-top:22px;flex-wrap:wrap;gap:8px;justify-content:flex-end;--el-pagination-button-bg-color:var(--vdi-panel-2);--el-pagination-hover-color:var(--vdi-accent)}.el-dialog{max-width:calc(100vw - 32px);border:1px solid var(--vdi-border);border-radius:18px;padding:24px}.el-dialog__title,.el-drawer__title{color:var(--vdi-text);font-weight:650}.el-drawer{max-width:100vw;border-left:1px solid var(--vdi-border)}.el-drawer__header{margin-bottom:24px;color:var(--vdi-text)}.el-drawer__body{padding:24px}.el-descriptions__cell{overflow-wrap:anywhere}.el-descriptions__label.el-descriptions__cell.is-bordered-label{background:var(--vdi-panel-2);color:var(--vdi-muted);font-weight:500}.el-descriptions__content.el-descriptions__cell.is-bordered-content{background:var(--vdi-panel);color:var(--vdi-text)}.el-input__wrapper,.el-select__wrapper{min-height:39px;border-radius:10px;background:var(--vdi-panel-2)}.el-input__wrapper.is-focus,.el-select__wrapper.is-focused{box-shadow:0 0 0 1px var(--vdi-brand) inset}.el-tabs__item.is-active,.el-tabs__item:hover{color:var(--vdi-accent)}.el-tabs__active-bar{background:var(--vdi-brand)}.el-progress-bar__outer{background:var(--vdi-panel-2)}.el-progress__text{color:var(--vdi-muted);font-size:12px!important}.el-tag{border-radius:7px;height:auto;min-height:24px;padding:3px 8px;line-height:1.4}.el-message-box{border-radius:18px;max-width:calc(100vw - 32px)}.el-alert{border-radius:12px}.el-switch{--el-switch-on-color:var(--vdi-brand)}.el-checkbox__input.is-checked+.el-checkbox__label{color:var(--vdi-accent)}
.secret{white-space:pre-wrap;overflow-wrap:anywhere;padding:16px;border:1px dashed var(--vdi-brand);background:var(--vdi-accent-soft);border-radius:10px}.notice{margin-bottom:20px}.empty-note{padding:26px 0;text-align:center;color:var(--vdi-muted)}.empty-panel{display:grid;place-items:center;min-height:230px;text-align:center;padding:32px}.empty-icon{display:grid;place-items:center;width:58px;height:58px;background:var(--vdi-panel-2);color:var(--vdi-accent);border-radius:18px;margin:0 auto 18px}.section-kicker{font-size:10px;letter-spacing:1.6px;color:var(--vdi-muted);margin-bottom:12px}
@media(min-width:1600px){.stats{gap:22px}.panel{padding:26px}}
@media(max-width:1100px){.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.two-columns{grid-template-columns:1fr}}
@media(max-width:600px){h1{font-size:24px}.panel{padding:16px;border-radius:14px}.stats{gap:12px}.stat{padding:18px}.stat strong{font-size:28px}.toolbar .el-input,.toolbar .el-select{width:100%}.el-pagination{justify-content:flex-start}.el-dialog{padding:20px}.el-drawer__body{padding:18px}.page-heading{gap:14px;margin-bottom:20px}.actions{gap:8px}}

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { Grid, List, Search, Plus, ArrowRight, Monitor } from '@element-plus/icons-vue'
import { api, type Desktop, type DesktopAction, type DiscoveredVM, type ImportInput } from '../api'
import { useAuth } from '../stores/auth'
import { usePlatform } from '../stores/platform'
import { useAction } from '../composables/useAction'
import StatusTag from '../components/StatusTag.vue'
import SystemIcon from '../components/SystemIcon.vue'
const auth = useAuth(), data = usePlatform(), { busy, run, confirm } = useAction()
const keyword = ref(''), status = ref(''), page = ref(1), selectedId = ref(''), importVisible = ref(false)
const view = ref<'cards' | 'table'>('cards')
const selected = computed(() => data.desktops.find(d => d.id === selectedId.value))
const filtered = computed(() => data.desktops.filter(d => `${d.name} ${d.vmid}`.toLowerCase().includes(keyword.value.toLowerCase()) && (!status.value || status.value === d.powerState)))
const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10))
const form = reactive<ImportInput>({ name: '', os: 'Windows', node: '', vmid: 100, endpoint: '' })
const discovered = ref<DiscoveredVM[]>([]), vmKey = ref('')
const labels = { start: '开机', shutdown: '正常关机', reboot: '重启桌面' }
function activeTask(d: Desktop) { return data.tasks.some(t => t.targetId === d.id && ['queued', 'running'].includes(t.status)) }
async function action(d: Desktop, a: DesktopAction) {
const description = a === 'start' ? '提交开机任务。开机后仍需确认连接服务是否就绪。' : '将中断该桌面的连接,请确认使用者已保存工作。'
if (await confirm(`${labels[a]} · ${d.name}`, description)) await run(() => api.desktopAction(d.id, a), '任务已提交,请在任务与审计中查看最终结果')
}
async function openImport() { if (await run(async () => { discovered.value = (await api.discoverVMs()).items }, '已获取可纳管的 VM')) importVisible.value = true }
function chooseVM(key: string) { const v = discovered.value.find(v => `${v.node}/${v.vmid}` === key); if (v) { form.node = v.node; form.vmid = v.vmid; form.name = v.name } }
async function submitImport() { if (!form.name.trim() || !form.node) return; if (await run(() => api.importDesktop(form), '桌面已纳管')) importVisible.value = false }
</script>
<template>
<div class="page-heading"><div><h1>桌面管理</h1><p class="subtext">统一管理 Windows / Ubuntu / 麒麟云桌面与授权</p></div><el-button v-if="auth.canAdmin" type="primary" :loading="busy" :disabled="!data.settings?.pveConfigured" @click="openImport"><el-icon style="margin-right:7px"><Plus /></el-icon>纳管已有 VM</el-button></div>
<el-alert v-if="!data.settings?.pveConfigured" class="notice" type="info" title="当前可操作演示桌面;配置 Proxmox 后可纳管真实 VM。" :closable="false" show-icon />
<section class="panel desktop-browser" :class="{ 'card-browser': view === 'cards' }">
<div class="desktop-summary"><div><h2>云桌面资源</h2><p class="subtext">已纳管 {{ data.desktops.length }} 台桌面 · 按系统与电源状态查看</p></div><div class="summary-chips"><span>全部 <b>{{ data.desktops.length }}</b></span><span><i class="health-dot tone-ok" />运行中 <b>{{ data.desktops.filter(d => d.powerState === 'running').length }}</b></span><span>未授权 <b>{{ data.desktops.filter(d => !data.users.some(u => u.enabled && u.desktopIds.includes(d.id))).length }}</b></span></div></div>
<div class="toolbar desktop-toolbar"><el-input v-model="keyword" aria-label="搜索桌面" placeholder="搜索名称 / VMID" clearable @input="page = 1"><template #prefix><el-icon><Search /></el-icon></template></el-input><el-select v-model="status" aria-label="电源状态筛选" placeholder="全部电源状态" clearable @change="page = 1"><el-option label="运行中" value="running" /><el-option label="已关机" value="stopped" /></el-select><span class="secondary result-count">共 {{ filtered.length }} 台</span><el-button-group class="view-switch" aria-label="桌面展示方式"><el-button :type="view === 'cards' ? 'primary' : 'default'" aria-label="卡片视图" :aria-pressed="view === 'cards'" @click="view = 'cards'"><el-icon><Grid /></el-icon></el-button><el-button :type="view === 'table' ? 'primary' : 'default'" aria-label="列表视图" :aria-pressed="view === 'table'" @click="view = 'table'"><el-icon><List /></el-icon></el-button></el-button-group></div>
<div v-if="view === 'cards' && rows.length" class="desktop-grid"><article v-for="d in rows" :key="d.id" class="desktop-card" :data-desktop-id="d.id"><div class="desktop-cover" :class="{ ubuntu: d.os === 'Ubuntu', kylin: d.os === '麒麟' }"><div class="os-icon"><SystemIcon :os="d.os" /></div><span class="desktop-vmid">VM {{ d.vmid }}</span><div class="cover-grid" /></div><div class="desktop-body"><div class="desktop-name-row"><h2>{{ d.name }}</h2><StatusTag :value="d.powerState" /></div><p class="subtext desktop-os">{{ d.os }} · {{ d.node }}</p><div class="desktop-chips"><span>{{ d.width }} × {{ d.height }}</span><span>{{ d.fps }} FPS 请求</span><span>{{ d.provider === 'proxmox' ? 'Proxmox' : '演示资源' }}</span></div><dl class="desktop-meta"><div><dt>授权用户</dt><dd>{{ data.users.filter(u => u.enabled && u.desktopIds.includes(d.id)).map(u => u.displayName).join('、') || '尚未分配' }}</dd></div><div><dt>连接服务</dt><dd>{{ d.serviceState === 'ready' ? '已就绪' : '尚未检测' }}</dd></div></dl><div class="desktop-card-actions"><el-button link @click="selectedId = d.id">查看详情<el-icon><ArrowRight /></el-icon></el-button><el-button v-if="auth.canOperate" :type="d.powerState === 'stopped' ? 'primary' : 'default'" :disabled="busy || activeTask(d)" @click="action(d, d.powerState === 'stopped' ? 'start' : 'shutdown')">{{ activeTask(d) ? '任务执行中' : d.powerState === 'stopped' ? '开机' : '正常关机' }}</el-button></div></div></article></div>
<div v-else-if="view === 'cards'" class="panel empty-panel"><div><div class="empty-icon"><el-icon :size="28"><Monitor /></el-icon></div><h3>暂无符合条件的桌面</h3><p class="subtext">调整筛选条件,或先纳管已有虚拟机。</p></div></div>
<el-table v-else :data="rows" empty-text="暂无符合条件的桌面"><el-table-column label="桌面 / VMID" min-width="175"><template #default="{ row }"><el-button link type="primary" @click="selectedId = row.id">{{ row.name }}</el-button><div class="secondary">VM {{ row.vmid }} · {{ row.provider === 'demo' ? '演示资源' : 'Proxmox' }}</div></template></el-table-column><el-table-column prop="os" label="系统" width="100" /><el-table-column label="节点 / 池" min-width="150"><template #default="{ row }">{{ row.node }}<div class="secondary">{{ row.pool || '未入池' }}</div></template></el-table-column><el-table-column label="电源 / 连接服务" width="155"><template #default="{ row }"><StatusTag :value="row.powerState" /><div class="secondary" style="margin-top:5px">连接服务:{{ row.serviceState === 'ready' ? '就绪' : '未检测' }}</div></template></el-table-column><el-table-column label="操作" min-width="200"><template #default="{ row }"><div class="actions"><el-button link type="primary" @click="selectedId = row.id">详情</el-button><el-button v-if="auth.canOperate" link type="primary" :disabled="busy || activeTask(row)" @click="action(row, row.powerState === 'stopped' ? 'start' : 'shutdown')">{{ row.powerState === 'stopped' ? '开机' : '关机' }}</el-button><el-button v-if="auth.canOperate" link type="primary" :disabled="busy || row.powerState !== 'running' || activeTask(row)" @click="action(row, 'reboot')">重启</el-button><el-tag v-if="activeTask(row)" size="small">任务中</el-tag></div></template></el-table-column></el-table>
<el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="total, prev, pager, next" />
</section>
<el-drawer :model-value="!!selected" title="桌面详情" size="560px" @close="selectedId = ''"><template v-if="selected"><h2>{{ selected.name }}</h2><el-descriptions :column="1" border style="margin-top:22px"><el-descriptions-item label="节点 / VMID">{{ selected.node }} / {{ selected.vmid }}</el-descriptions-item><el-descriptions-item label="电源状态"><StatusTag :value="selected.powerState" /></el-descriptions-item><el-descriptions-item label="连接服务">{{ selected.serviceState === 'ready' ? '已就绪' : '尚未检测,不代表不可用' }}</el-descriptions-item><el-descriptions-item label="连接地址">{{ selected.endpoint || '未配置' }}</el-descriptions-item><el-descriptions-item label="连接参数">{{ selected.width }} × {{ selected.height }} / {{ selected.fps }} FPS</el-descriptions-item><el-descriptions-item label="授权用户">{{ data.users.filter(u => u.desktopIds.includes(selected!.id)).map(u => u.displayName).join('、') || '未分配' }}</el-descriptions-item></el-descriptions><p class="subtext" style="margin:20px 0">还原镜像和模板发放将在后续阶段接入,本版只提供开关机与重启。</p><div class="actions" v-if="auth.canOperate"><el-button :disabled="busy || activeTask(selected) || selected.powerState === 'running'" @click="action(selected, 'start')">开机</el-button><el-button :disabled="busy || activeTask(selected) || selected.powerState === 'stopped'" @click="action(selected, 'shutdown')">正常关机</el-button><el-button :disabled="busy || activeTask(selected) || selected.powerState !== 'running'" @click="action(selected, 'reboot')">重启桌面</el-button></div></template></el-drawer>
<el-dialog v-model="importVisible" title="纳管已有 VM" width="520px"><el-form label-position="top" @submit.prevent="submitImport"><el-form-item label="选择 VM" required><el-select v-model="vmKey" placeholder="选择节点和虚拟机" @change="chooseVM"><el-option v-for="v in discovered" :key="`${v.node}/${v.vmid}`" :value="`${v.node}/${v.vmid}`" :label="`${v.node} / ${v.vmid} / ${v.name}`" /></el-select></el-form-item><el-form-item label="桌面名称" required><el-input v-model="form.name" maxlength="80" /></el-form-item><el-form-item label="操作系统"><el-select v-model="form.os"><el-option v-for="os in (['Windows', 'Ubuntu', '麒麟'] as const)" :key="os" :value="os" /></el-select></el-form-item><el-form-item label="桌面连接地址(可后续补充)"><el-input v-model="form.endpoint" placeholder="VM 的 IP 或主机名" /></el-form-item><p class="subtext">纳管不会修改 VM;使用 Moonlight 前仍需完成 Sunshine 配对。</p><div class="actions" style="justify-content:flex-end;margin-top:22px"><el-button @click="importVisible = false">取消</el-button><el-button type="primary" native-type="submit" :loading="busy" :disabled="!form.node || !form.name.trim()">确认纳管</el-button></div></el-form></el-dialog>
</template>
<style scoped>
.desktop-summary{display:flex;align-items:center;justify-content:space-between;gap:20px;flex-wrap:wrap;padding-bottom:22px;margin-bottom:22px;border-bottom:1px solid var(--vdi-border)}.summary-chips{display:flex;gap:9px;flex-wrap:wrap}.summary-chips>span{background:var(--vdi-panel-2);border:1px solid var(--vdi-border);border-radius:8px;padding:8px 11px;color:var(--vdi-muted);font-size:11px;display:flex;align-items:center;gap:5px}.summary-chips b{color:var(--vdi-text);font-weight:600;margin-left:3px}.summary-chips .health-dot{margin-right:0;width:6px;height:6px}.view-switch{margin-left:auto;display:flex;flex-shrink:0}.view-switch .el-button{padding:8px 11px}.card-browser.panel{background:transparent;border:0;box-shadow:none;padding:0}.card-browser .desktop-summary{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:18px;padding:22px;margin-bottom:18px;box-shadow:var(--vdi-shadow)}.desktop-toolbar{margin-bottom:20px}.desktop-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px}.desktop-card{background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:18px;box-shadow:var(--vdi-shadow);overflow:hidden;min-width:0}.desktop-cover{height:103px;padding:20px;background:linear-gradient(125deg,#a95e2e,#d89553);color:white;position:relative;display:flex;align-items:center;justify-content:space-between;overflow:hidden}.desktop-cover:after{content:"";position:absolute;width:160px;height:160px;border:24px solid rgb(255 255 255 / 9%);border-radius:50%;right:-32px;top:-85px}.desktop-cover.ubuntu{background:linear-gradient(125deg,#65558f,#9c8acc)}.desktop-cover.kylin{background:linear-gradient(125deg,#247a71,#67b39f)}.os-icon{width:44px;height:44px;background:rgb(255 255 255 / 18%);border:1px solid rgb(255 255 255 / 12%);border-radius:12px;display:grid;place-items:center;position:relative;z-index:1}.desktop-vmid{position:relative;z-index:1;font-size:10px;letter-spacing:1px;border:1px solid rgb(255 255 255 / 30%);background:rgb(255 255 255 / 8%);padding:5px 7px;border-radius:6px}.cover-grid{position:absolute;inset:0;background-image:linear-gradient(90deg,rgb(255 255 255 / 4%) 1px,transparent 1px),linear-gradient(rgb(255 255 255 / 4%) 1px,transparent 1px);background-size:22px 22px}.desktop-body{padding:21px 20px 17px}.desktop-name-row{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}.desktop-name-row h2{font-size:17px;overflow-wrap:anywhere}.desktop-name-row .el-tag{flex-shrink:0;font-size:10px}.desktop-os{font-size:12px;margin-top:8px}.desktop-chips{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.desktop-chips span{font-size:10px;background:var(--vdi-panel-2);border:1px solid var(--vdi-border);padding:5px 7px;border-radius:7px;color:var(--vdi-muted)}.desktop-meta{margin:0;font-size:11px}.desktop-meta>div{display:flex;justify-content:space-between;gap:12px;margin:13px 0}.desktop-meta dt{color:var(--vdi-muted);flex-shrink:0}.desktop-meta dd{margin:0;text-align:right;overflow-wrap:anywhere}.desktop-card-actions{display:flex;justify-content:space-between;align-items:center;gap:6px;margin-top:18px;border-top:1px solid var(--vdi-border);padding-top:17px}.desktop-card-actions .el-button{font-size:11px;padding:9px 12px}.desktop-card-actions .el-button.is-link{padding-left:0}.desktop-card-actions .el-icon{margin-left:4px}
@media(min-width:1700px){.desktop-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}
@media(max-width:1200px){.desktop-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:600px){.desktop-grid{grid-template-columns:1fr}.card-browser .desktop-summary{padding:18px}.desktop-summary{gap:16px}.result-count{display:none}.view-switch{margin-left:0}.desktop-toolbar{gap:12px}.desktop-toolbar .el-input{flex:1;min-width:190px}.desktop-toolbar .el-select{width:calc(100% - 95px)}.desktop-toolbar .el-input{order:-2;flex-basis:100%}.desktop-body{padding:21px}.desktop-cover{height:95px}}
</style>

View File

@ -0,0 +1 @@
<template><el-result icon="warning" title="无访问权限" sub-title="当前角色无权访问该页面"><template #extra><router-link to="/overview"><el-button type="primary">返回总览</el-button></router-link></template></el-result></template>

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Monitor, Connection, Lock, ArrowRight } from '@element-plus/icons-vue'
import { useAuth } from '../stores/auth'
import { errorMessage } from '../api'
import ThemeToggle from '../components/ThemeToggle.vue'
import brandMark from '../assets/brand-mark.svg'
const auth = useAuth(), router = useRouter(), route = useRoute()
const form = reactive({ username: '', password: '' }), busy = ref(false), error = ref('')
const demo = import.meta.env.VITE_API_MODE === 'mock' || import.meta.env.VITE_DEMO_HINT === 'true'
async function submit() {
if (!form.username.trim() || !form.password) { error.value = '请输入用户名和密码'; return }
busy.value = true; error.value = ''
try { await auth.login(form.username.trim(), form.password); const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/') && !route.query.redirect.startsWith('//') ? route.query.redirect : '/overview'; await router.replace(redirect) }
catch (e) { error.value = errorMessage(e) }
finally { busy.value = false }
}
</script>
<template>
<main class="login-page"><header class="login-header"><a href="/" class="login-brand"><img :src="brandMark" alt="" /><span>百工万象<small>VDI CLOUD</small></span></a><ThemeToggle /></header><div class="login-main"><section class="login-intro"><div class="eyebrow">VDI CLOUD / MANAGEMENT PLATFORM</div><h1>让每一台桌面<br>有序连接<span>。</span></h1><p>从桌面资源到终端运维,<br>让云端的每一次连接,尽在掌握。</p><div class="intro-capabilities"><span><el-icon><Monitor /></el-icon>桌面管理</span><span><el-icon><Connection /></el-icon>终端接入</span><span><el-icon><Lock /></el-icon>安全授权</span></div><div class="login-art" aria-hidden="true"><div class="art-orbit orbit-one" /><div class="art-orbit orbit-two" /><div class="art-screen screen-back"><div class="art-dots"><i /><i /><i /></div><div class="art-rows"><b /><b /><b /></div></div><div class="art-screen screen-front"><div class="art-dots"><i /><i /><i /></div><div class="art-logo"><img :src="brandMark" alt="" /><div>VDI CLOUD<small>连接工作新方式</small></div></div><div class="art-progress"><i /></div></div><div class="art-badge"><i />云 · 管 · 端</div></div></section><section class="login-form-area"><div class="login-card"><div class="section-kicker">WELCOME BACK</div><h2>登录管理控制台</h2><p class="subtext">使用平台管理员账号,开启工作。</p><el-alert v-if="error" class="notice" style="margin-top:24px" type="error" :title="error" :closable="false" show-icon /><el-form label-position="top" @submit.prevent="submit"><el-form-item label="用户名"><el-input v-model="form.username" name="username" autocomplete="username" size="large" placeholder="请输入用户名" :disabled="busy" /></el-form-item><el-form-item label="密码"><el-input v-model="form.password" name="password" type="password" autocomplete="current-password" size="large" show-password placeholder="请输入密码" :disabled="busy" /></el-form-item><el-button type="primary" size="large" native-type="submit" :loading="busy" class="login-submit">登录<el-icon><ArrowRight /></el-icon></el-button></el-form><p class="login-security"><el-icon><Lock /></el-icon>仅限已授权的平台管理人员访问</p><p v-if="demo" class="demo-hint">演示账号:admin / operator / viewer<br>演示密码:Demo@2026<br><span>仅用于演示模式,正式环境使用独立账号。</span></p></div></section></div><footer class="login-footer"><span>百工万象 · VDI Cloud</span><span>云端服务 / 管理控制台 / 云终端</span></footer></main>
</template>
<style scoped>
.login-page{min-height:100vh;display:flex;flex-direction:column;background:radial-gradient(ellipse at 8% 60%,var(--vdi-accent-soft),transparent 53%),var(--vdi-bg)}.login-header{padding:30px 5%;display:flex;justify-content:space-between;align-items:center}.login-brand{display:flex;align-items:center;gap:11px;color:var(--vdi-text);font-size:23px;font-weight:750;letter-spacing:1px}.login-brand img{width:36px;height:36px}.login-brand small{display:block;color:var(--vdi-muted);font-size:9px;letter-spacing:2.7px;font-weight:500;margin-top:4px}.login-main{flex:1;display:grid;grid-template-columns:1.15fr 1fr;align-items:center;gap:8%;width:min(1160px,88%);margin:35px auto}.login-intro{position:relative;padding-top:12px}.eyebrow{font-size:11px;letter-spacing:2px;margin-bottom:24px}.login-intro h1{font-size:48px;line-height:1.45;letter-spacing:1px;font-weight:750}.login-intro h1 span{color:var(--vdi-brand)}.login-intro>p{color:var(--vdi-muted);line-height:1.9;margin-top:20px;font-size:15px}.intro-capabilities{display:flex;gap:23px;margin-top:25px;flex-wrap:wrap}.intro-capabilities>span{display:flex;align-items:center;gap:7px;color:var(--vdi-muted);font-size:12px}.intro-capabilities .el-icon{color:var(--vdi-accent)}.login-form-area{display:flex;justify-content:center}.login-card{width:440px;max-width:100%;background:var(--vdi-panel);border:1px solid var(--vdi-border);padding:40px;border-radius:22px;box-shadow:var(--vdi-shadow)}.login-card h2{font-size:25px;margin-bottom:10px}.section-kicker{color:var(--vdi-accent);font-size:10px;letter-spacing:2px;margin-bottom:14px}.login-card .el-form{margin-top:34px}.login-card :deep(.el-form-item){margin-bottom:25px}.login-card :deep(.el-input__wrapper){min-height:46px;background:var(--vdi-bg)}.login-submit{width:100%;height:46px;margin-top:7px;font-weight:600}.login-submit .el-icon{margin-left:12px}.login-security{display:flex;justify-content:center;align-items:center;gap:6px;font-size:11px;color:var(--vdi-muted);margin-top:23px}.demo-hint{font-size:12px;line-height:1.9;color:var(--vdi-muted);margin-top:24px;padding-top:20px;border-top:1px solid var(--vdi-border)}.demo-hint span{font-size:11px}.login-footer{display:flex;justify-content:space-between;padding:25px 5%;color:var(--vdi-muted);font-size:11px;gap:16px;flex-wrap:wrap}.login-art{position:relative;width:360px;height:200px;margin-top:32px}.art-orbit{position:absolute;border:1px solid var(--vdi-border);border-radius:50%;width:340px;height:135px;bottom:4px;left:0;transform:rotate(-14deg)}.orbit-two{transform:rotate(15deg);width:300px;bottom:17px;left:20px}.art-screen{position:absolute;background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:13px;box-shadow:var(--vdi-shadow);overflow:hidden}.screen-back{width:220px;height:133px;left:8px;top:9px;transform:rotate(-7deg)}.screen-front{width:247px;height:152px;left:89px;top:40px;transform:rotate(5deg)}.art-dots{display:flex;gap:4px;padding:11px;border-bottom:1px solid var(--vdi-border);background:var(--vdi-panel-2)}.art-dots i{width:4px;height:4px;border-radius:50%;background:var(--vdi-muted);opacity:.5}.art-rows{padding:20px 18px}.art-rows b{display:block;height:7px;background:var(--vdi-border);margin-bottom:9px;width:65%;border-radius:3px}.art-rows b:nth-child(2){width:88%}.art-rows b:nth-child(3){width:43%;background:var(--vdi-brand);opacity:.6}.art-logo{display:flex;gap:13px;align-items:center;padding:26px 25px 17px;font-size:14px;letter-spacing:1px;font-weight:600}.art-logo img{width:33px;height:33px}.art-logo small{font-size:9px;display:block;color:var(--vdi-muted);letter-spacing:1px;margin-top:6px;font-weight:400}.art-progress{margin:0 25px;height:4px;background:var(--vdi-panel-2);border-radius:4px}.art-progress i{display:block;height:100%;width:68%;background:linear-gradient(90deg,var(--vdi-brand),#f1ad62);border-radius:4px}.art-badge{position:absolute;right:0;bottom:0;background:var(--vdi-panel);border:1px solid var(--vdi-border);border-radius:9px;padding:9px 14px;font-size:10px;box-shadow:var(--vdi-shadow)}.art-badge i{width:5px;height:5px;border-radius:50%;display:inline-block;background:var(--vdi-ok);margin-right:6px}
@media(max-width:950px){.login-main{gap:4%;width:92%}.login-intro h1{font-size:39px}.login-card{padding:30px}.login-art{transform:scale(.85);transform-origin:left top;margin-bottom:-25px}.intro-capabilities{gap:14px}}
@media(max-width:700px){.login-header{padding:22px}.login-main{grid-template-columns:1fr;margin:24px auto 45px;gap:28px;width:min(440px,calc(100% - 36px))}.login-intro{padding-top:0}.login-intro h1{font-size:32px}.login-intro h1 br{display:none}.login-intro>p,.intro-capabilities,.login-art{display:none}.eyebrow{margin-bottom:14px;font-size:9px;letter-spacing:1.4px}.login-card{padding:28px 24px;width:100%}.login-card h2{font-size:23px}.login-footer{padding:20px 22px;font-size:10px}.login-footer>span:last-child{display:none}.login-security{font-size:10px}.login-brand{font-size:20px}}
</style>

View File

@ -0,0 +1 @@
<template><el-result icon="info" title="页面不存在" sub-title="请通过左侧菜单选择功能"><template #extra><router-link to="/overview"><el-button type="primary">返回总览</el-button></router-link></template></el-result></template>

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Monitor, Connection, Warning, Refresh, Download, Cpu, ArrowRight, CircleCheck, Clock } from '@element-plus/icons-vue'
import { usePlatform } from '../stores/platform'
import StatusTag from '../components/StatusTag.vue'
const data = usePlatform(), router = useRouter()
const active = computed(() => data.terminals.filter(t => t.status === 'online' && t.sessionDesktopId && t.sessionState === 'connected'))
const averageRTT = computed(() => {
const samples = active.value.flatMap(t => t.rtt == null ? [] : [t.rtt])
return samples.length ? Math.round(samples.reduce((a, b) => a + b, 0) / samples.length * 10) / 10 : null
})
const healthyNodes = computed(() => data.resources.filter(n => n.status === 'online' && Date.now() - Date.parse(n.updatedAt) <= 90000).length)
const recent = computed(() => data.tasks.slice(0, 5))
const taskLabels: Record<string, string> = { start: '开机', shutdown: '正常关机', reboot: '重启', 'reset-session': '重置会话', 'collect-logs': '收集日志', sync: '同步资源' }
const stats = computed(() => [
{ title: '运行桌面', value: data.overview?.runningDesktops ?? 0, total: `/ ${data.overview?.desktopCount ?? 0}`, caption: '已纳管桌面的实际电源状态', path: '/desktops', icon: Monitor, warning: false },
{ title: '活跃会话', value: data.overview?.activeSessions ?? 0, total: '个', caption: '终端已确认画面就绪', path: '/sessions', icon: Connection, warning: false },
{ title: '网络 RTT', value: averageRTT.value ?? '—', total: averageRTT.value === null ? '' : 'ms', caption: averageRTT.value === null ? '当前无活动会话指标' : '活动会话采样均值', path: '/sessions', icon: Clock, warning: false },
{ title: '异常任务', value: data.overview?.failedTasks ?? 0, total: '个', caption: '最近 24 小时失败或结果未确认', path: '/tasks', icon: Warning, warning: (data.overview?.failedTasks ?? 0) > 0 },
])
function exportReport() {
const lines = [
['VDI Cloud 运行快照', new Date().toLocaleString('zh-CN')],
['指标', '数值'], ['运行桌面', String(data.overview?.runningDesktops ?? '')],
['桌面总数', String(data.overview?.desktopCount ?? '')], ['在线终端', String(data.overview?.onlineTerminals ?? '')],
['终端总数', String(data.overview?.terminalCount ?? '')], ['活动连接', String(data.overview?.activeSessions ?? '')],
['网络 RTT(ms)', averageRTT.value === null ? '暂无数据' : String(averageRTT.value)],
['最近24小时异常任务', String(data.overview?.failedTasks ?? '')],
]
const url = URL.createObjectURL(new Blob(['\ufeff' + lines.map(row => row.map(v => `"${v.replace(/"/g, '""')}"`).join(',')).join('\r\n')], { type: 'text/csv;charset=utf-8;' }))
const link = document.createElement('a'); link.href = url; link.download = `vdi-overview-${new Date().toISOString().slice(0, 10)}.csv`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
}
</script>
<template>
<div class="page-heading"><div><h1>运行总览</h1><p class="subtext">云桌面、终端连接与资源健康,一处掌握。</p></div><div class="actions"><el-button @click="exportReport"><el-icon><Download /></el-icon><span>导出快照</span></el-button><el-button type="primary" @click="data.refresh"><el-icon><Refresh /></el-icon><span>刷新数据</span></el-button></div></div>
<div class="stats"><button v-for="stat in stats" :key="stat.title" class="stat" @click="router.push(stat.path)"><div class="stat-label">{{ stat.title }}<el-icon :size="18"><component :is="stat.icon" /></el-icon></div><strong :class="{ 'tone-warn': stat.warning }">{{ stat.value }}<span class="stat-unit">{{ stat.total }}</span></strong><div class="stat-caption">{{ stat.caption }}</div></button></div>
<div class="two-columns overview-grid">
<section class="panel nodes-panel"><div class="panel-heading"><div><h2>计算资源水位</h2><p class="subtext">当前节点负载 · 每 30 秒同步 PVE</p></div><router-link to="/resources">资源详情 <el-icon><ArrowRight /></el-icon></router-link></div><div v-if="!data.resources.length" class="empty-panel"><div><div class="empty-icon"><el-icon :size="27"><Cpu /></el-icon></div><h3>尚未同步计算资源</h3><p class="subtext">接入 Proxmox 后查看节点使用情况</p></div></div><div v-for="node in data.resources" :key="node.id" class="node-block"><div class="node-heading"><div class="node-name"><div class="node-icon"><el-icon :size="21"><Cpu /></el-icon></div><div><strong>{{ node.name }}</strong><small>{{ node.provider === 'demo' ? '演示节点' : 'Proxmox 计算节点' }}</small></div></div><el-tag v-if="Date.now() - Date.parse(node.updatedAt) > 90000 && node.provider !== 'demo'" type="warning">数据过期</el-tag><StatusTag v-else :value="node.status" /></div><div class="node-meters"><div v-for="meter in [{ label: 'CPU 使用率', value: node.cpu }, { label: '内存使用率', value: node.memory }, { label: '存储使用率', value: node.storage }]" :key="meter.label"><div class="meter-label"><span>{{ meter.label }}</span><strong>{{ Math.min(100, Math.round(meter.value * 100)) }}<small>%</small></strong></div><el-progress :percentage="Math.min(100, Math.round(meter.value * 100))" :show-text="false" :stroke-width="8" :color="meter.value >= .85 ? 'var(--vdi-warn)' : 'var(--vdi-brand)'" /></div></div><p class="secondary node-sync">最近同步 {{ new Date(node.updatedAt).toLocaleString('zh-CN') }}</p></div></section>
<section class="panel health-panel"><div class="panel-heading"><h2>资源健康度</h2><span class="secondary">当前快照</span></div><div class="health-row"><span><i class="health-dot" :class="healthyNodes === data.resources.length && data.resources.length ? 'tone-ok' : 'tone-warn'" />计算节点</span><strong>{{ healthyNodes }} / {{ data.resources.length }}</strong></div><div class="health-row"><span><i class="health-dot" :class="data.overview?.onlineTerminals ? 'tone-ok' : 'tone-warn'" />在线终端</span><router-link to="/terminals">{{ data.overview?.onlineTerminals ?? 0 }} / {{ data.overview?.terminalCount ?? 0 }}</router-link></div><div class="health-row"><span><i class="health-dot" :class="data.desktops.filter(d => !data.users.some(u => u.enabled && u.desktopIds.includes(d.id))).length ? 'tone-warn' : 'tone-ok'" />未授权桌面</span><router-link to="/users">{{ data.desktops.filter(d => !data.users.some(u => u.enabled && u.desktopIds.includes(d.id))).length }} 台</router-link></div><div class="health-row"><span><i class="health-dot tone-ok" />当前账号</span><span class="tone-ok"><el-icon><CircleCheck /></el-icon> 已认证</span></div><router-link to="/tasks" class="pending-summary"><el-icon :size="19"><Clock /></el-icon><div><strong>{{ data.tasks.filter(t => ['queued', 'running'].includes(t.status)).length }} 个任务等待回执</strong><small>查看任务进度与执行结果</small></div><el-icon><ArrowRight /></el-icon></router-link></section>
</div>
<section class="panel"><div class="panel-heading"><div><h2>最近任务</h2><p class="subtext">每次操作都有进度和结果记录</p></div><router-link to="/tasks">查看全部 <el-icon><ArrowRight /></el-icon></router-link></div><el-table :data="recent" empty-text="尚无执行任务"><el-table-column prop="targetName" label="操作目标" min-width="150" /><el-table-column label="操作" min-width="120"><template #default="{ row }">{{ taskLabels[row.action] || row.action }}</template></el-table-column><el-table-column label="状态" width="125"><template #default="{ row }"><StatusTag :value="row.status === 'running' ? '执行中' : row.status" /></template></el-table-column><el-table-column prop="message" label="执行结果" min-width="250" /></el-table></section>
</template>
<style scoped>
.actions .el-icon{margin-right:7px}.stat-unit{color:var(--vdi-muted);font-size:14px;font-weight:500}.node-block+.node-block{border-top:1px solid var(--vdi-border);padding-top:22px;margin-top:22px}.node-heading{display:flex;justify-content:space-between;align-items:center;margin:24px 0 27px}.node-name{display:flex;align-items:center;gap:12px}.node-name strong{font-size:15px}.node-name small{display:block;color:var(--vdi-muted);font-size:11px;margin-top:4px}.node-icon{display:grid;place-items:center;width:42px;height:42px;background:var(--vdi-panel-2);border-radius:12px;color:var(--vdi-accent)}.node-meters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px}.meter-label{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:16px}.meter-label>span{font-size:12px;color:var(--vdi-muted)}.meter-label strong{font-size:18px}.meter-label strong small{font-size:11px;font-weight:400;margin-left:2px}.node-sync{margin-top:29px}.health-row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:18px 0;border-bottom:1px solid var(--vdi-border);font-size:13px}.health-row>span:first-child{display:flex;align-items:center}.health-row strong{font-size:14px}.health-row .tone-ok{display:flex;align-items:center;gap:5px}.pending-summary{display:flex;align-items:center;gap:12px;background:var(--vdi-panel-2);border-radius:12px;padding:15px;margin-top:21px;color:var(--vdi-text)}.pending-summary>.el-icon:first-child{color:var(--vdi-accent)}.pending-summary>div{flex:1}.pending-summary strong{font-size:12px;font-weight:500}.pending-summary small{display:block;font-size:11px;color:var(--vdi-muted);margin-top:5px}.panel-heading>a{display:flex;gap:4px;align-items:center}
@media(max-width:600px){.node-meters{grid-template-columns:1fr;gap:20px}.node-heading{margin-top:18px}.node-sync{margin-top:20px}.meter-label{margin-bottom:10px}.overview-grid{gap:0}}
</style>

View File

@ -0,0 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import { api } from '../api'
import { useAuth } from '../stores/auth'
import { usePlatform } from '../stores/platform'
import { useAction } from '../composables/useAction'
import StatusTag from '../components/StatusTag.vue'
const auth = useAuth(), data = usePlatform(), { busy, run } = useAction()
const syncing = computed(() => data.tasks.some(t => t.kind === 'resource' && ['queued', 'running'].includes(t.status)))
const stale = (date: string, provider: string) => provider !== 'demo' && Date.now() - Date.parse(date) > 90000
</script>
<template>
<div class="page-heading"><div><h1>计算资源</h1><p class="subtext">计算节点与资源同步状态</p></div><el-button v-if="auth.canAdmin" type="primary" :disabled="!data.settings?.pveConfigured || syncing" :loading="busy" @click="run(() => api.sync(), '同步任务已提交')">{{ syncing ? '同步中' : '同步 Proxmox' }}</el-button></div>
<el-alert v-if="!data.settings?.pveConfigured" type="info" class="notice" :closable="false" title="尚未接入 Proxmox" description="由管理员在服务端设置访问地址、API Token 和 CA 证书后重启平台。当前演示节点不代表真实资源。" show-icon />
<section class="panel"><el-table :data="data.resources" empty-text="尚无资源,配置接入后同步节点"><el-table-column prop="name" label="节点" min-width="150" /><el-table-column label="状态" width="125"><template #default="{ row }"><el-tag v-if="stale(row.updatedAt, row.provider)" type="warning">数据过期</el-tag><StatusTag v-else :value="row.status" /></template></el-table-column><el-table-column label="CPU" min-width="140"><template #default="{ row }"><el-progress :percentage="Math.min(100, Math.round(row.cpu * 100))" /></template></el-table-column><el-table-column label="内存" min-width="140"><template #default="{ row }"><el-progress :percentage="Math.min(100, Math.round(row.memory * 100))" /></template></el-table-column><el-table-column label="存储" min-width="140"><template #default="{ row }"><el-progress :percentage="Math.min(100, Math.round(row.storage * 100))" /></template></el-table-column><el-table-column label="来源 / 最近同步" min-width="190"><template #default="{ row }"><StatusTag :value="row.provider" /><div class="secondary">{{ new Date(row.updatedAt).toLocaleString('zh-CN') }}</div></template></el-table-column></el-table></section>
</template>

View File

@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Connection, Monitor, Refresh, VideoCamera, ArrowRight } from '@element-plus/icons-vue'
import { usePlatform } from '../stores/platform'
import { useAuth } from '../stores/auth'
import { useAction } from '../composables/useAction'
import { api, type Terminal } from '../api'
const data = usePlatform(), auth = useAuth(), { busy, run, confirm } = useAction()
const selectedId = ref('')
const sessions = computed(() => data.terminals.filter(t => t.status === 'online' && t.sessionDesktopId && ['connected', 'connecting'].includes(t.sessionState || '')))
const selected = computed(() => sessions.value.find(t => t.id === selectedId.value) || sessions.value[0])
const desktop = computed(() => data.desktops.find(d => d.id === selected.value?.sessionDesktopId))
const user = computed(() => data.users.find(u => u.terminalId === selected.value?.id))
const pending = (id: string) => data.tasks.some(t => t.targetId === id && ['queued', 'running'].includes(t.status))
async function disconnect(terminal: Terminal) {
if (await confirm('断开远程会话', `将结束 ${terminal.name} 当前的串流连接,云桌面继续运行。`)) await run(() => api.terminalCommand(terminal.id, 'reset-session'), '断开命令已提交,等待终端回执')
}
</script>
<template>
<div class="page-heading"><div><h1>远程会话</h1><p class="subtext">查看终端的连接状态与实际上报的会话指标</p></div><el-button @click="data.refresh"><el-icon><Refresh /></el-icon><span style="margin-left:7px">刷新会话</span></el-button></div>
<div class="session-summary"><span><i class="health-dot tone-ok" />{{ sessions.filter(t => t.sessionState === 'connected').length }} 个已连接</span><span><i class="health-dot tone-warn" />{{ sessions.filter(t => t.sessionState === 'connecting').length }} 个连接中</span><router-link to="/terminals">管理终端 <el-icon><ArrowRight /></el-icon></router-link></div>
<div v-if="selected" class="session-layout"><section class="panel session-workspace"><div class="panel-heading"><div><h2>{{ desktop?.name || '云桌面' }}</h2><p class="subtext">{{ selected.name }} · {{ user?.displayName || '设备绑定模式' }}</p></div><el-tag :type="selected.sessionState === 'connected' ? 'success' : 'warning'">{{ selected.sessionState === 'connected' ? '已连接' : '连接中' }}</el-tag></div><div class="preview-unavailable"><div class="preview-grid" /><div class="preview-content"><div class="preview-symbol"><el-icon :size="42"><Monitor /></el-icon></div><h3>{{ selected.sessionState === 'connected' ? '桌面正在终端显示' : '终端正在建立连接' }}</h3><p>管理控制台尚未接入远程画面预览</p><span>{{ desktop?.os }} · {{ desktop?.width }} × {{ desktop?.height }}</span></div><div class="preview-caption"><el-icon><VideoCamera /></el-icon>画面预览未接入</div></div><div class="session-device-strip"><span><el-icon><Connection /></el-icon>{{ selected.name }}</span><span class="mono">{{ selected.sn }}</span></div></section><section class="panel session-details"><div class="section-kicker">SESSION DETAILS</div><h2>会话信息</h2><p class="subtext">{{ desktop?.name || '云桌面' }}</p><dl><div><dt>连接状态</dt><dd :class="selected.sessionState === 'connected' ? 'tone-ok' : 'tone-warn'">{{ selected.sessionState === 'connected' ? '已连接' : '连接中' }}</dd></div><div><dt>桌面使用者</dt><dd>{{ user?.displayName || '未关联' }}</dd></div><div><dt>分辨率</dt><dd>{{ desktop ? `${desktop.width} × ${desktop.height}` : '暂无数据' }}</dd></div><div><dt>实际帧率</dt><dd>{{ selected.sessionState === 'connected' && selected.fps !== null ? `${selected.fps} FPS` : '暂无数据' }}</dd></div><div><dt>网络 RTT</dt><dd>{{ selected.sessionState === 'connected' && selected.rtt !== null ? `${selected.rtt} ms` : '暂无数据' }}</dd></div><div><dt>GPU / 显存</dt><dd class="secondary">尚未采集</dd></div><div><dt>最近心跳</dt><dd class="heartbeat-time">{{ selected.lastSeen ? new Date(selected.lastSeen).toLocaleTimeString('zh-CN') : '暂无数据' }}</dd></div></dl><p class="subtext session-note">网络 RTT 为设备采样值,不代表整体输入延迟。</p><router-link to="/terminals" class="details-link"><el-button style="width:100%">查看终端详情</el-button></router-link><el-button v-if="auth.canOperate" type="danger" plain class="disconnect-button" :disabled="busy || pending(selected.id)" @click="disconnect(selected)">{{ pending(selected.id) ? '等待命令回执' : '断开连接' }}</el-button></section></div>
<section v-else class="panel empty-session"><div class="empty-icon"><el-icon :size="30"><Connection /></el-icon></div><h2>当前没有活动会话</h2><p class="subtext">在终端选择已授权的云桌面并连接后,会话会显示在这里。</p><router-link to="/terminals"><el-button type="primary" style="margin-top:24px">查看终端</el-button></router-link></section>
<section v-if="sessions.length > 1" class="panel"><div class="panel-heading"><h2>全部会话</h2><span class="secondary">{{ sessions.length }} 个连接</span></div><div class="session-list"><button v-for="t in sessions" :key="t.id" class="session-option" :class="{ active: selected?.id === t.id }" @click="selectedId = t.id"><el-icon :size="20"><Monitor /></el-icon><div><strong>{{ data.desktops.find(d => d.id === t.sessionDesktopId)?.name || '云桌面' }}</strong><span>{{ t.name }}</span></div><span :class="t.sessionState === 'connected' ? 'tone-ok' : 'tone-warn'">{{ t.sessionState === 'connected' ? '已连接' : '连接中' }}</span></button></div></section>
</template>
<style scoped>
.session-summary{display:flex;gap:24px;align-items:center;flex-wrap:wrap;font-size:12px;color:var(--vdi-muted);margin-bottom:22px}.session-summary>span{display:flex;align-items:center}.session-summary>a{margin-left:auto;display:flex;gap:5px;align-items:center}.session-layout{display:grid;grid-template-columns:minmax(0,1fr) 310px;gap:20px}.session-workspace{padding:22px 22px 0;overflow:hidden}.preview-unavailable{position:relative;background:var(--vdi-bg);border:1px solid var(--vdi-border);border-radius:14px;min-height:350px;aspect-ratio:16/10;display:grid;place-items:center;overflow:hidden}.preview-grid{position:absolute;inset:0;background-image:linear-gradient(var(--vdi-border) 1px,transparent 1px),linear-gradient(90deg,var(--vdi-border) 1px,transparent 1px);background-size:35px 35px;opacity:.18;mask-image:radial-gradient(ellipse at center,#000,transparent 75%)}.preview-content{text-align:center;position:relative;padding:24px}.preview-symbol{width:96px;height:80px;background:var(--vdi-panel-2);border:1px solid var(--vdi-border);border-radius:20px;display:grid;place-items:center;margin:0 auto 24px;color:var(--vdi-accent)}.preview-content h3{font-size:18px}.preview-content p{color:var(--vdi-muted);font-size:13px;margin-top:12px}.preview-content>span{display:block;font-size:11px;color:var(--vdi-muted);margin-top:17px}.preview-caption{position:absolute;bottom:17px;left:19px;font-size:10px;color:var(--vdi-muted);display:flex;align-items:center;gap:6px}.session-device-strip{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:18px 0;flex-wrap:wrap;color:var(--vdi-muted);font-size:11px}.session-device-strip>span:first-child{display:flex;gap:7px;align-items:center}.session-details dl{margin:22px 0 0}.session-details dl>div{display:flex;justify-content:space-between;align-items:center;gap:14px;font-size:12px;padding:15px 0;border-bottom:1px solid var(--vdi-border)}.session-details dt{color:var(--vdi-muted)}.session-details dd{margin:0;font-weight:500;text-align:right}.session-note{font-size:11px;margin:20px 0}.details-link{display:block;margin-top:24px}.disconnect-button{width:100%;margin-top:10px}.disconnect-button.el-button{margin-left:0}.empty-session{text-align:center;padding:85px 28px;min-height:410px}.empty-session .subtext{max-width:400px;margin:12px auto 0}.session-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.session-option{border:1px solid var(--vdi-border);background:var(--vdi-panel-2);border-radius:12px;padding:17px;display:flex;gap:14px;align-items:center;text-align:left;color:var(--vdi-text);cursor:pointer}.session-option.active{border-color:var(--vdi-brand);background:var(--vdi-accent-soft)}.session-option>div{flex:1}.session-option strong{font-size:13px}.session-option>div>span{display:block;font-size:11px;color:var(--vdi-muted);margin-top:5px}.session-option>span{font-size:11px}
@media(max-width:1100px){.session-layout{grid-template-columns:1fr}.session-list{grid-template-columns:1fr}.preview-unavailable{min-height:260px}}
@media(max-width:600px){.empty-session{padding:60px 22px}.preview-unavailable{min-height:280px;aspect-ratio:auto}.session-workspace{padding:16px 16px 0}.session-summary{gap:15px}.preview-content h3{font-size:16px}.preview-content p{font-size:12px}}
</style>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Connection, Lock, Setting, CircleCheck, Clock } from '@element-plus/icons-vue'
import { usePlatform } from '../stores/platform'
import { useAuth } from '../stores/auth'
const data = usePlatform(), auth = useAuth(), section = ref('platform')
const sections = [{ id: 'platform', title: '平台接入', icon: Connection }, { id: 'permissions', title: '管理员权限', icon: Lock }, { id: 'capabilities', title: '功能范围', icon: Setting }]
const planned = ['镜像模板与克隆发放', '专属 / 公共桌面池', 'GPU 资源绑定', '历史指标与告警', '终端策略下发', 'OTA 灰度与回滚']
</script>
<template>
<div class="page-heading"><div><h1>系统设置</h1><p class="subtext">平台接入状态、访问权限与当前功能范围</p></div></div>
<div class="settings-layout"><nav class="panel settings-nav" aria-label="设置分类"><button v-for="item in sections" :key="item.id" :class="{ active: section === item.id }" :aria-pressed="section === item.id" @click="section = item.id"><el-icon :size="17"><component :is="item.icon" /></el-icon>{{ item.title }}</button></nav><div class="settings-content"><section v-if="section === 'platform'" class="panel settings-panel"><div class="section-kicker">PLATFORM CONNECTION</div><h2>平台接入</h2><p class="subtext">统一查看管理服务、计算资源与终端通道</p><div class="setting-row"><div><strong>运行环境</strong><p>使用独立数据库保存平台业务数据</p></div><el-tag :type="data.settings?.mode === 'demo' ? 'warning' : 'success'">{{ data.settings?.mode === 'demo' ? '演示环境' : '现场联调环境' }}</el-tag></div><div class="setting-row"><div><strong>Proxmox 计算平台</strong><p>连接状态以最近同步任务结果为准</p></div><span :class="data.settings?.pveConfigured ? 'tone-ok' : 'secondary'"><i class="health-dot" />{{ data.settings?.pveConfigured ? '已配置' : '尚未配置' }}</span></div><div class="setting-row"><div><strong>终端管理通道</strong><p>设备注册、心跳、命令领取与执行回执</p></div><span class="setting-value">HTTPS</span></div><div class="setting-row"><div><strong>业务数据存储</strong><p>账号、设备、授权关系、任务与审计</p></div><span class="setting-value">PostgreSQL</span></div><div class="setting-row"><div><strong>当前管理员</strong><p>{{ auth.user?.displayName }}</p></div><span class="setting-value">{{ auth.user?.username }}</span></div><p class="settings-footnote">接入地址与访问凭据由服务端配置管理。</p></section><section v-else-if="section === 'permissions'" class="panel settings-panel"><div class="section-kicker">ACCESS CONTROL</div><h2>管理员权限</h2><p class="subtext">三种管理角色,按职责控制访问与操作</p><el-table :data="[{ name: '超级管理员', query: '允许', operate: '允许', assign: '允许' }, { name: '运维人员', query: '允许', operate: '允许', assign: '不允许' }, { name: '只读人员', query: '允许', operate: '不允许', assign: '不允许' }]" style="margin-top:26px"><el-table-column prop="name" label="角色" min-width="120" /><el-table-column prop="query" label="查看资源" min-width="110" /><el-table-column prop="operate" label="设备 / 桌面操作" min-width="145" /><el-table-column prop="assign" label="授权 / 接入" min-width="120" /></el-table><p class="settings-footnote">管理员账号与桌面使用者分开管理。管理员账号维护页面尚未开放。</p></section><section v-else class="panel settings-panel"><div class="section-kicker">PLATFORM CAPABILITIES</div><h2>功能范围</h2><p class="subtext">当前已接通的能力与后续开发范围</p><div class="capability-group"><h3>当前可用</h3><div class="capability-grid"><span v-for="name in ['登录与角色权限', '计算资源同步', '已有桌面纳管与电源管理', '终端注册与心跳', '用户桌面授权', '远程会话状态与重置', '诊断日志采集', '任务跟踪与操作审计']" :key="name"><el-icon class="tone-ok"><CircleCheck /></el-icon>{{ name }}</span></div></div><div class="capability-group"><h3>待接入</h3><div class="capability-grid"><span v-for="name in planned" :key="name"><el-icon class="secondary"><Clock /></el-icon>{{ name }}</span></div></div></section></div></div>
</template>
<style scoped>
.settings-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:20px;align-items:start}.settings-nav{padding:12px;display:grid;gap:5px}.settings-nav button{display:flex;gap:11px;align-items:center;border:0;padding:14px 15px;border-radius:11px;background:transparent;color:var(--vdi-muted);font-size:13px;text-align:left;cursor:pointer}.settings-nav button.active{background:var(--vdi-accent-soft);color:var(--vdi-accent);font-weight:600}.settings-nav button:hover{color:var(--vdi-accent)}.settings-panel{padding:28px}.setting-row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:24px 0;border-bottom:1px solid var(--vdi-border);font-size:12px}.setting-row strong{font-size:13px;font-weight:550}.setting-row p{font-size:11px;color:var(--vdi-muted);margin-top:7px;line-height:1.7}.setting-row>span{flex-shrink:0}.setting-row .health-dot{width:6px;height:6px;margin-right:6px}.setting-value{color:var(--vdi-muted);font-size:12px}.settings-footnote{color:var(--vdi-muted);font-size:11px;line-height:1.8;margin-top:22px}.capability-group{margin-top:28px;padding-top:24px;border-top:1px solid var(--vdi-border)}.capability-group h3{font-size:13px;margin-bottom:20px}.capability-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;font-size:12px}.capability-grid>span{display:flex;align-items:center;gap:9px}
@media(max-width:1100px){.settings-layout{grid-template-columns:1fr}.settings-nav{display:flex;flex-wrap:wrap;margin-bottom:0}.settings-nav button{flex:1;white-space:nowrap;padding:12px;justify-content:center}.settings-panel{padding:22px}}
@media(max-width:600px){.settings-nav{gap:3px;padding:8px}.settings-nav button{gap:6px;padding:10px 8px;font-size:11px}.settings-panel{padding:19px}.setting-row{gap:15px}.setting-row p{max-width:180px}.setting-row>span{font-size:10px}.capability-grid{grid-template-columns:1fr}}
</style>

View File

@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { usePlatform } from '../stores/platform'
import StatusTag from '../components/StatusTag.vue'
import { api, errorMessage } from '../api'
import { useAuth } from '../stores/auth'
import { ElMessage } from 'element-plus'
const auth = useAuth()
async function downloadDiagnostics(id: string) {
try {
const diagnostic = await api.taskDiagnostics(id)
const url = URL.createObjectURL(new Blob([JSON.stringify(diagnostic, null, 2)], { type: 'application/json' }))
const link = document.createElement('a'); link.href = url; link.download = `${id}-diagnostics.json`; link.click(); URL.revokeObjectURL(url)
} catch(e) { ElMessage.error(errorMessage(e)) }
}
const data = usePlatform(), tab = ref('tasks'), keyword = ref(''), status = ref(''), page = ref(1), selectedId = ref('')
const selected = computed(() => data.tasks.find(t => t.id === selectedId.value))
const labels: Record<string, string> = { start: '开机', shutdown: '正常关机', reboot: '重启', 'reset-session': '重置会话', 'collect-logs': '收集日志', sync: '同步资源', login: '登录', logout: '退出登录', create_user: '创建用户', enroll_terminal: '登记终端', update_bindings: '更新授权', import_desktop: '纳管桌面' }
const filtered = computed(() => data.tasks.filter(t => `${t.targetName} ${t.id} ${t.actor}`.toLowerCase().includes(keyword.value.toLowerCase()) && (!status.value || t.status === status.value)))
const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10))
const auditFiltered = computed(() => data.audits.filter(a => `${a.actor} ${a.target} ${a.action}`.toLowerCase().includes(keyword.value.toLowerCase())))
const auditRows = computed(() => auditFiltered.value.slice((page.value - 1) * 10, page.value * 10))
</script>
<template>
<div class="page-heading"><div><h1>任务与审计</h1><p class="subtext">提交记录、执行回执和最终结果分开显示</p></div><el-button @click="data.refresh">刷新</el-button></div>
<section class="panel">
<el-tabs v-model="tab" @tab-change="page = 1; keyword = ''; status = ''"><el-tab-pane label="执行任务" name="tasks" /><el-tab-pane label="操作审计" name="audit" /></el-tabs><div class="toolbar"><el-input v-model="keyword" placeholder="搜索目标 / 操作者 / 标识" aria-label="搜索任务" clearable @input="page = 1" /><el-select v-if="tab === 'tasks'" v-model="status" aria-label="任务状态筛选" placeholder="全部任务状态" clearable @change="page = 1"><el-option v-for="s in [{ value: 'queued', label: '排队中' }, { value: 'running', label: '执行中' }, { value: 'success', label: '成功' }, { value: 'failed', label: '失败' }, { value: 'timeout', label: '结果未确认' }]" :key="s.value" :label="s.label" :value="s.value" /></el-select></div>
<el-table v-if="tab === 'tasks'" :data="rows" empty-text="尚无执行任务"><el-table-column label="操作目标 / 任务" min-width="200"><template #default="{ row }"><el-button link type="primary" @click="selectedId = row.id">{{ row.targetName }}</el-button><div class="mono secondary">{{ row.id.slice(0, 18) }}…</div></template></el-table-column><el-table-column label="操作" width="110"><template #default="{ row }">{{ labels[row.action] || row.action }}</template></el-table-column><el-table-column label="状态" width="125"><template #default="{ row }"><StatusTag :value="row.status === 'running' ? '执行中' : row.status" /></template></el-table-column><el-table-column prop="actor" label="操作者" width="110" /><el-table-column label="提交时间" min-width="170"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString('zh-CN') }}</template></el-table-column><el-table-column prop="message" label="执行结果" min-width="240" /></el-table>
<el-table v-else :data="auditRows" empty-text="尚无审计记录"><el-table-column prop="actor" label="操作者" width="100" /><el-table-column label="操作" min-width="130"><template #default="{ row }">{{ labels[row.action] || row.action }}</template></el-table-column><el-table-column prop="target" label="对象" min-width="180" /><el-table-column label="结果" width="125"><template #default="{ row }"><StatusTag :value="row.result" /></template></el-table-column><el-table-column label="时间" min-width="175"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString('zh-CN') }}</template></el-table-column><el-table-column prop="requestId" label="请求标识" min-width="180" show-overflow-tooltip /></el-table>
<el-pagination v-model:current-page="page" :total="tab === 'tasks' ? filtered.length : auditFiltered.length" :page-size="10" layout="total, prev, pager, next" />
</section>
<el-drawer :model-value="!!selected" title="任务详情" size="580px" @close="selectedId = ''"><el-descriptions v-if="selected" :column="1" border><el-descriptions-item label="任务 ID"><span class="mono">{{ selected.id }}</span></el-descriptions-item><el-descriptions-item label="目标">{{ selected.targetName }}</el-descriptions-item><el-descriptions-item label="操作">{{ labels[selected.action] || selected.action }}</el-descriptions-item><el-descriptions-item label="状态"><StatusTag :value="selected.status === 'running' ? '执行中' : selected.status" /></el-descriptions-item><el-descriptions-item label="结果">{{ selected.message }}</el-descriptions-item><el-descriptions-item label="操作者">{{ selected.actor }}</el-descriptions-item><el-descriptions-item label="PVE 任务标识"><span class="mono">{{ selected.providerRef || '无' }}</span></el-descriptions-item><el-descriptions-item label="更新时间">{{ new Date(selected.updatedAt).toLocaleString('zh-CN') }}</el-descriptions-item></el-descriptions><el-button v-if="selected && auth.canOperate && selected.action === 'collect-logs' && selected.status === 'success'" style="margin-top:20px" @click="downloadDiagnostics(selected.id)">下载诊断日志</el-button><p class="subtext" style="margin-top:22px">结果未确认时先核对实际设备状态,再决定是否重新提交。系统不会自动重放操作。</p></el-drawer>
</template>

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { api, type Terminal, type TerminalAction } from '../api'
import { useAuth } from '../stores/auth'
import { usePlatform } from '../stores/platform'
import { useAction } from '../composables/useAction'
import StatusTag from '../components/StatusTag.vue'
const auth = useAuth(), data = usePlatform(), { busy, run, confirm } = useAction()
const keyword = ref(''), status = ref(''), page = ref(1), selectedId = ref(''), createVisible = ref(false), secret = ref(''), activationVisible = ref(false)
const form = reactive({ sn: '', name: '', group: '' })
const filtered = computed(() => data.terminals.filter(t => `${t.name} ${t.sn}`.toLowerCase().includes(keyword.value.toLowerCase()) && (!status.value || status.value === t.status)))
const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10))
const selected = computed(() => data.terminals.find(t => t.id === selectedId.value))
const labels = { reboot: '重启盒子', 'reset-session': '重置会话', 'collect-logs': '收集日志' }
const desktopName = (id: string | null) => data.desktops.find(d => d.id === id)?.name || '无活动连接'
const pending = (id: string) => data.tasks.some(t => t.targetId === id && ['queued', 'running'].includes(t.status))
async function command(t: Terminal, action: TerminalAction) {
const note = t.status === 'online' ? '将向终端提交命令并等待设备回执。' : '终端当前不在线,命令可能超时;不会显示为已执行成功。'
if (await confirm(`${labels[action]} · ${t.name}`, note)) await run(() => api.terminalCommand(t.id, action), '命令已提交,等待终端回执')
}
async function enroll() { if (!form.sn.trim() || !form.name.trim()) return; if (await run(async () => { const r = await api.enroll(form); secret.value = r.activationSecret }, '终端已登记')) { createVisible.value = false; activationVisible.value = true; form.sn = ''; form.name = ''; form.group = '' } }
</script>
<template>
<div class="page-heading"><div><h1>终端管理</h1><p class="subtext">设备身份、在线心跳与远程命令</p></div><el-button v-if="auth.canAdmin" type="primary" @click="createVisible = true">登记终端</el-button></div>
<section class="panel"><div class="toolbar"><el-input v-model="keyword" placeholder="搜索终端名称 / SN" aria-label="搜索终端" clearable @input="page = 1" /><el-select v-model="status" placeholder="全部网络状态" aria-label="终端网络状态筛选" clearable @change="page = 1"><el-option label="在线" value="online" /><el-option label="离线" value="offline" /><el-option label="待激活" value="unregistered" /></el-select><span class="secondary">共 {{ filtered.length }} 台</span></div><el-table :data="rows" empty-text="暂无终端,先登记设备并完成激活"><el-table-column label="终端 / SN" min-width="185"><template #default="{ row }"><el-button link type="primary" @click="selectedId = row.id">{{ row.name }}</el-button><div class="mono secondary">{{ row.sn }}</div></template></el-table-column><el-table-column prop="group" label="分组" width="100" /><el-table-column label="网络状态" width="100"><template #default="{ row }"><StatusTag :value="row.status" /></template></el-table-column><el-table-column label="当前连接" min-width="150"><template #default="{ row }">{{ desktopName(row.sessionDesktopId) }}</template></el-table-column><el-table-column label="最近心跳" min-width="170"><template #default="{ row }">{{ row.lastSeen ? new Date(row.lastSeen).toLocaleString('zh-CN') : '尚未注册' }}</template></el-table-column><el-table-column label="操作" min-width="165"><template #default="{ row }"><div class="actions"><el-button link type="primary" @click="selectedId = row.id">详情</el-button><el-button v-if="auth.canOperate" link type="primary" :disabled="busy || pending(row.id)" @click="command(row, 'reboot')">重启盒子</el-button></div></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="total, prev, pager, next" /></section>
<el-drawer :model-value="!!selected" title="终端详情" size="560px" @close="selectedId = ''"><template v-if="selected"><h2>{{ selected.name }}</h2><el-descriptions :column="1" border style="margin-top:22px"><el-descriptions-item label="设备 SN">{{ selected.sn }}</el-descriptions-item><el-descriptions-item label="网络状态"><StatusTag :value="selected.status" /></el-descriptions-item><el-descriptions-item label="Agent 版本">{{ selected.agentVersion || '尚未上报' }}</el-descriptions-item><el-descriptions-item label="配置版本">v{{ selected.configVersion }}</el-descriptions-item><el-descriptions-item label="当前连接">{{ desktopName(selected.sessionDesktopId) }}</el-descriptions-item><el-descriptions-item label="帧率">{{ selected.status === 'online' && selected.fps !== null ? `${selected.fps} FPS` : '暂无数据' }}</el-descriptions-item><el-descriptions-item label="网络 RTT">{{ selected.status === 'online' && selected.rtt !== null ? `${selected.rtt} ms` : '暂无数据' }}</el-descriptions-item></el-descriptions><p class="subtext" style="margin:20px 0">重置会话只结束终端连接,不会还原或关机云桌面。网络 RTT 不等于整体输入延迟。</p><div class="actions" v-if="auth.canOperate"><el-button :disabled="busy || pending(selected.id)" @click="command(selected, 'reboot')">重启盒子</el-button><el-button :disabled="busy || pending(selected.id)" @click="command(selected, 'reset-session')">重置会话</el-button><el-button :disabled="busy || pending(selected.id)" @click="command(selected, 'collect-logs')">收集日志</el-button></div></template></el-drawer>
<el-dialog v-model="createVisible" title="登记终端" width="480px"><el-form label-position="top" @submit.prevent="enroll"><el-form-item label="设备 SN" required><el-input v-model="form.sn" maxlength="100" /></el-form-item><el-form-item label="终端名称" required><el-input v-model="form.name" maxlength="80" /></el-form-item><el-form-item label="分组"><el-input v-model="form.group" maxlength="80" /></el-form-item><div class="actions" style="justify-content:flex-end"><el-button @click="createVisible = false">取消</el-button><el-button type="primary" native-type="submit" :loading="busy" :disabled="!form.sn.trim() || !form.name.trim()">登记并生成激活信息</el-button></div></el-form></el-dialog>
<el-dialog v-model="activationVisible" title="终端激活信息" width="530px" @closed="secret = ''"><p class="subtext" style="margin-bottom:16px">激活密钥只在此处显示一次,交给设备管理员配置。设备注册后即失效。</p><div class="secret mono" data-testid="activation-secret">{{ secret }}</div><template #footer><el-button type="primary" @click="activationVisible = false">已保存,关闭</el-button></template></el-dialog>
</template>

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { api, type User } from '../api'
import { useAuth } from '../stores/auth'
import { usePlatform } from '../stores/platform'
import { useAction } from '../composables/useAction'
import StatusTag from '../components/StatusTag.vue'
const auth = useAuth(), data = usePlatform(), { busy, run } = useAction()
const keyword = ref(''), page = ref(1), createVisible = ref(false), bindingVisible = ref(false), selectedId = ref('')
const createForm = reactive({ username: '', displayName: '' })
const binding = reactive({ terminalId: null as string | null, desktopIds: [] as string[], enabled: true })
const filtered = computed(() => data.users.filter(u => `${u.username} ${u.displayName}`.toLowerCase().includes(keyword.value.toLowerCase())))
const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10))
const terminalName = (id: string | null) => data.terminals.find(t => t.id === id)?.name || '未关联'
const desktopName = (id: string) => data.desktops.find(d => d.id === id)?.name || id
function edit(u: User) { selectedId.value = u.id; binding.terminalId = u.terminalId; binding.desktopIds = [...u.desktopIds]; binding.enabled = u.enabled; bindingVisible.value = true }
async function create() { if (!createForm.username.trim() || !createForm.displayName.trim()) return; if (await run(() => api.createUser(createForm), '用户已创建')) { createVisible.value = false; createForm.username = ''; createForm.displayName = '' } }
async function save() { if (await run(() => api.updateBindings(selectedId.value, binding), '桌面授权已保存')) bindingVisible.value = false }
const terminalTaken = (id: string) => data.users.some(u => u.id !== selectedId.value && u.terminalId === id)
</script>
<template>
<div class="page-heading"><div><h1>用户与授权</h1><p class="subtext">每位用户可获得多个桌面;每台终端关联一位使用者</p></div><el-button v-if="auth.canAdmin" type="primary" @click="createVisible = true">新建用户</el-button></div>
<section class="panel"><div class="toolbar"><el-input v-model="keyword" placeholder="搜索姓名 / 用户账号" aria-label="搜索用户" clearable @input="page = 1" /><span class="secondary">共 {{ filtered.length }} 位</span></div><el-table :data="rows" empty-text="暂无终端用户"><el-table-column label="用户 / 账号" min-width="160"><template #default="{ row }">{{ row.displayName }}<div class="secondary">{{ row.username }}</div></template></el-table-column><el-table-column label="关联终端" min-width="160"><template #default="{ row }">{{ terminalName(row.terminalId) }}</template></el-table-column><el-table-column label="可用桌面" min-width="220"><template #default="{ row }"><div v-for="id in row.desktopIds" :key="id">{{ desktopName(id) }}</div><span v-if="!row.desktopIds.length" class="secondary">尚未分配</span></template></el-table-column><el-table-column label="状态" width="100"><template #default="{ row }"><StatusTag :value="row.enabled ? 'enabled' : 'disabled'" /></template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" width="120"><template #default="{ row }"><el-button link type="primary" @click="edit(row)">编辑授权</el-button></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="total, prev, pager, next" /></section><p class="subtext">这里管理桌面使用者。平台管理员账号独立管理,不因拥有管理角色而自动获得桌面使用权。</p>
<el-dialog v-model="createVisible" title="新建终端用户" width="480px"><el-form label-position="top" @submit.prevent="create"><el-form-item label="姓名" required><el-input v-model="createForm.displayName" maxlength="80" /></el-form-item><el-form-item label="用户账号" required><el-input v-model="createForm.username" maxlength="80" /></el-form-item><p class="subtext">首版使用设备绑定模式,此账号用于授权关系,不是桌面系统密码。</p><div class="actions" style="justify-content:flex-end;margin-top:22px"><el-button @click="createVisible = false">取消</el-button><el-button type="primary" native-type="submit" :disabled="!createForm.username.trim() || !createForm.displayName.trim()" :loading="busy">创建用户</el-button></div></el-form></el-dialog>
<el-dialog v-model="bindingVisible" title="编辑桌面授权" width="540px"><el-form label-position="top" @submit.prevent="save"><el-form-item label="关联终端"><el-select v-model="binding.terminalId" placeholder="不关联终端" clearable @clear="binding.terminalId = null"><el-option v-for="t in data.terminals" :key="t.id" :label="`${t.name} / ${t.sn}`" :value="t.id" :disabled="terminalTaken(t.id)" /></el-select></el-form-item><el-form-item label="可用桌面(可多选)"><el-checkbox-group v-model="binding.desktopIds" style="display:grid"><el-checkbox v-for="d in data.desktops" :key="d.id" :value="d.id">{{ d.name }} · {{ d.os }}</el-checkbox></el-checkbox-group><p v-if="!data.desktops.length" class="subtext">尚无可授权桌面</p></el-form-item><el-form-item label="用户状态"><el-switch v-model="binding.enabled" active-text="启用" inactive-text="停用" /></el-form-item><p class="subtext">撤销授权会阻止后续连接申请;本版不会强制中断已经建立的串流。</p><div class="actions" style="justify-content:flex-end;margin-top:22px"><el-button @click="bindingVisible = false">取消</el-button><el-button type="primary" native-type="submit" :loading="busy">保存授权</el-button></div></el-form></el-dialog>
</template>

12
console/tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"types": ["vite/client", "node"],
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "e2e/**/*.ts", "playwright.config.ts"]
}

8
console/vite.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: { port: 5180, strictPort: true, proxy: { '/api': process.env.VDI_API_PROXY || 'http://127.0.0.1:8088', '/healthz': process.env.VDI_API_PROXY || 'http://127.0.0.1:8088' } },
build: { rollupOptions: { output: { manualChunks: { 'element-plus': ['element-plus'], framework: ['vue', 'vue-router', 'pinia'] } } } },
})

2
console/vitest.config.ts Normal file
View File

@ -0,0 +1,2 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({ test: { include: ['src/**/*.test.ts'], environment: 'node' } })

View File

@ -1 +0,0 @@
# Keep the monorepo directory structure in Git.

44
deploy/compose.dev.yml Normal file
View File

@ -0,0 +1,44 @@
name: vdi-cloud-dev
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: vdi
POSTGRES_PASSWORD: vdi-local-demo
POSTGRES_DB: vdi
ports:
- "127.0.0.1:55438:5432"
volumes:
- vdi-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U vdi -d vdi"]
interval: 3s
timeout: 3s
retries: 20
api:
build:
context: ..
dockerfile: platform/Dockerfile
environment:
VDI_MODE: demo
VDI_LISTEN: 0.0.0.0:8080
VDI_JWT_SECRET: local-demo-only-change-before-any-production-use-2026
DATABASE_URL: postgres://vdi:vdi-local-demo@postgres:5432/vdi?sslmode=disable
ports:
- "127.0.0.1:${VDI_API_PORT:-8088}:8080"
depends_on:
postgres:
condition: service_healthy
init: true
console:
build:
context: ..
dockerfile: console/Dockerfile
args:
VITE_DEMO_HINT: "true"
ports:
- "127.0.0.1:${VDI_CONSOLE_PORT:-5180}:80"
depends_on:
- api
volumes:
vdi-postgres:

59
deploy/compose.live.yml Normal file
View File

@ -0,0 +1,59 @@
name: vdi-cloud-live
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: vdi
POSTGRES_PASSWORD: ${VDI_DB_PASSWORD:?required}
POSTGRES_DB: vdi_live
volumes:
- vdi-live-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U vdi -d vdi_live"]
interval: 3s
timeout: 3s
retries: 20
api:
image: vdi-cloud-live-api
build:
context: ..
dockerfile: platform/Dockerfile
restart: unless-stopped
init: true
extra_hosts:
- "${PVE_HOSTNAME:?required}:${PVE_HOST_IP:?required}"
environment:
VDI_MODE: production
VDI_LISTEN: 0.0.0.0:8080
VDI_JWT_SECRET: ${VDI_JWT_SECRET:?required}
VDI_ADMIN_USERNAME: ${VDI_ADMIN_USERNAME:?required}
VDI_ADMIN_PASSWORD: ${VDI_ADMIN_PASSWORD:?required}
DATABASE_URL: postgres://vdi:${VDI_DB_PASSWORD}@postgres:5432/vdi_live?sslmode=disable
PVE_URL: ${PVE_URL:?required}
PVE_TOKEN_ID: ${PVE_TOKEN_ID:?required}
PVE_TOKEN_SECRET: ${PVE_TOKEN_SECRET:?required}
PVE_CA_FILE: /run/secrets/pve-ca.pem
volumes:
- ../.local/live/pve-ca.pem:/run/secrets/pve-ca.pem:ro
depends_on:
postgres:
condition: service_healthy
console:
image: vdi-cloud-live-console
build:
context: ..
dockerfile: console/Dockerfile
args:
VITE_DEMO_HINT: "false"
restart: unless-stopped
ports:
- "127.0.0.1:${VDI_LIVE_CONSOLE_PORT:-5182}:80"
- "${VDI_LIVE_BIND_IP:?required}:${VDI_LIVE_HTTPS_PORT:-8443}:443"
volumes:
- ./nginx.live.conf:/etc/nginx/conf.d/default.conf:ro
- ../.local/live/tls:/run/secrets/tls:ro
depends_on:
- api
volumes:
vdi-live-postgres:

13
deploy/nginx.conf Normal file
View File

@ -0,0 +1,13 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 1m;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy same-origin always;
add_header X-Frame-Options DENY always;
location /api/ { proxy_pass http://api:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }
location /healthz { proxy_pass http://api:8080; }
location / { try_files $uri $uri/ /index.html; }
}

17
deploy/nginx.live.conf Normal file
View File

@ -0,0 +1,17 @@
server {
listen 80;
listen 443 ssl;
ssl_certificate /run/secrets/tls/server.pem;
ssl_certificate_key /run/secrets/tls/server-key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 1m;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy same-origin always;
add_header X-Frame-Options DENY always;
location /api/ { proxy_pass http://api:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }
location /healthz { proxy_pass http://api:8080; }
location / { try_files $uri $uri/ /index.html; }
}

File diff suppressed because it is too large Load Diff

11
platform/.env.example Normal file
View File

@ -0,0 +1,11 @@
VDI_MODE=production
VDI_LISTEN=127.0.0.1:8080
DATABASE_URL=postgres://vdi:replace-me@127.0.0.1:5432/vdi?sslmode=require
VDI_JWT_SECRET=replace-with-at-least-32-random-characters
VDI_ADMIN_USERNAME=admin
VDI_ADMIN_PASSWORD=replace-with-strong-initial-password
# PVE_URL is the HTTPS origin. CA verification is always enabled.
# PVE_URL=https://pve.example.internal:8006
# PVE_TOKEN_ID=vdi@pve!console
# PVE_TOKEN_SECRET=replace-me
# PVE_CA_FILE=/run/secrets/pve-ca.pem

View File

@ -1 +0,0 @@
# Keep the monorepo directory structure in Git.

13
platform/Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY platform/go.mod platform/go.sum ./
RUN go mod download
COPY platform/ ./
RUN CGO_ENABLED=0 go build -trimpath -o /vdi-api ./cmd/api-gateway
FROM alpine:3.23
RUN apk add --no-cache ca-certificates && adduser -D -u 10001 vdi
COPY --from=build /vdi-api /usr/local/bin/vdi-api
USER vdi
EXPOSE 8080
ENTRYPOINT ["vdi-api"]

14
platform/README.md Normal file
View File

@ -0,0 +1,14 @@
# VDI 云端服务
Go API 网关和第一阶段业务模块。运行、接口、真实 PVE 接入与当前限制见[开发说明](../docs/cloud-platform-development.md)。
```text
cmd/api-gateway/ 服务入口与优雅退出
internal/config/ 环境配置校验
internal/model/ 对外数据类型
internal/store/ PostgreSQL 初始化、账号与业务存储
internal/server/ 认证、资源/桌面、终端、授权、任务与审计
pkg/proxmox/ 真实 PVE API 适配与异步任务跟踪
```
配置参考 `.env.example`,变量由运行环境注入;程序不会自动读取 `.env`。不要提交访问凭据。

View File

@ -0,0 +1,58 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"vdi-cloud/platform/internal/config"
"vdi-cloud/platform/internal/server"
"vdi-cloud/platform/internal/store"
"vdi-cloud/platform/pkg/proxmox"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
startup, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
db, err := store.Open(startup, cfg.DatabaseURL)
if err != nil {
log.Fatal("database initialization failed: ", err)
}
defer db.Close()
if err = store.Bootstrap(startup, db, cfg.Mode, cfg.AdminUsername, cfg.AdminPassword); err != nil {
log.Fatal("bootstrap failed: ", err)
}
var pve *proxmox.Client
if cfg.PVEURL != "" {
pve, err = proxmox.New(cfg.PVEURL, cfg.PVETokenID, cfg.PVETokenSecret, cfg.PVECAFile)
if err != nil {
log.Fatal(err)
}
}
s := server.New(cfg, db, pve)
go s.RunWorker(ctx)
if pve != nil {
go s.RunSync(ctx)
}
httpServer := &http.Server{Addr: cfg.Address, Handler: s.Router, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 20 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
log.Printf("VDI API listening on %s (mode=%s)", cfg.Address, cfg.Mode)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Print(err)
stop()
}
}()
<-ctx.Done()
shutdown, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
_ = httpServer.Shutdown(shutdown)
}

47
platform/go.mod Normal file
View File

@ -0,0 +1,47 @@
module vdi-cloud/platform
go 1.25.0
require (
github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/jackc/pgx/v5 v5.7.6
github.com/jmoiron/sqlx v1.4.0
golang.org/x/crypto v0.43.0
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)

109
platform/go.sum Normal file
View File

@ -0,0 +1,109 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,39 @@
package config
import (
"fmt"
"os"
"strings"
)
type Config struct {
Mode, Address, DatabaseURL, JWTSecret, AdminUsername, AdminPassword string
PVEURL, PVETokenID, PVETokenSecret, PVECAFile string
}
func Load() (Config, error) {
c := Config{Mode: env("VDI_MODE", "production"), Address: env("VDI_LISTEN", "127.0.0.1:8080"), DatabaseURL: os.Getenv("DATABASE_URL"), JWTSecret: os.Getenv("VDI_JWT_SECRET"), AdminUsername: os.Getenv("VDI_ADMIN_USERNAME"), AdminPassword: os.Getenv("VDI_ADMIN_PASSWORD"), PVEURL: os.Getenv("PVE_URL"), PVETokenID: os.Getenv("PVE_TOKEN_ID"), PVETokenSecret: os.Getenv("PVE_TOKEN_SECRET"), PVECAFile: os.Getenv("PVE_CA_FILE")}
if c.Mode != "demo" && c.Mode != "production" {
return c, fmt.Errorf("VDI_MODE must be demo or production")
}
if c.DatabaseURL == "" {
return c, fmt.Errorf("DATABASE_URL is required")
}
if len(c.JWTSecret) < 32 {
return c, fmt.Errorf("VDI_JWT_SECRET must contain at least 32 characters")
}
if c.Mode == "production" && strings.TrimSpace(c.AdminUsername) == "" {
return c, fmt.Errorf("production requires VDI_ADMIN_USERNAME")
}
if c.PVEURL != "" && (c.PVETokenID == "" || c.PVETokenSecret == "") {
return c, fmt.Errorf("PVE_TOKEN_ID and PVE_TOKEN_SECRET are required with PVE_URL")
}
return c, nil
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@ -0,0 +1,91 @@
package model
import "time"
type Administrator struct {
ID string `json:"id" db:"id"`
Username string `json:"username" db:"username"`
DisplayName string `json:"displayName" db:"display_name"`
Role string `json:"role" db:"role"`
PasswordHash string `json:"-" db:"password_hash"`
}
type Desktop struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
OS string `json:"os" db:"os"`
Node string `json:"node" db:"node"`
VMID int `json:"vmid" db:"vmid"`
Pool string `json:"pool" db:"pool"`
PowerState string `json:"powerState" db:"power_state"`
ServiceState string `json:"serviceState" db:"service_state"`
Provider string `json:"provider" db:"provider"`
Endpoint string `json:"endpoint" db:"endpoint"`
App string `json:"app" db:"app"`
Width int `json:"width" db:"width"`
Height int `json:"height" db:"height"`
FPS int `json:"fps" db:"fps"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
type Terminal struct {
ID string `json:"id" db:"id"`
SN string `json:"sn" db:"sn"`
Name string `json:"name" db:"name"`
Group string `json:"group" db:"group_name"`
Status string `json:"status" db:"status"`
AgentVersion string `json:"agentVersion" db:"agent_version"`
LastSeen *time.Time `json:"lastSeen" db:"last_seen"`
SessionDesktopID *string `json:"sessionDesktopId" db:"session_desktop_id"`
SessionState string `json:"sessionState" db:"session_state"`
ConfigVersion int `json:"configVersion" db:"config_version"`
FPS *float64 `json:"fps" db:"fps"`
RTT *float64 `json:"rtt" db:"rtt"`
}
type User struct {
ID string `json:"id" db:"id"`
Username string `json:"username" db:"username"`
DisplayName string `json:"displayName" db:"display_name"`
Enabled bool `json:"enabled" db:"enabled"`
TerminalID *string `json:"terminalId" db:"terminal_id"`
DesktopIDs []string `json:"desktopIds"`
}
type Resource struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Status string `json:"status" db:"status"`
CPU float64 `json:"cpu" db:"cpu"`
Memory float64 `json:"memory" db:"memory"`
Storage float64 `json:"storage" db:"storage"`
Provider string `json:"provider" db:"provider"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
type Task struct {
ID string `json:"id" db:"id"`
Kind string `json:"kind" db:"kind"`
Action string `json:"action" db:"action"`
TargetID string `json:"targetId" db:"target_id"`
TargetName string `json:"targetName" db:"target_name"`
Actor string `json:"actor" db:"actor"`
Status string `json:"status" db:"status"`
Message string `json:"message" db:"message"`
ProviderRef string `json:"providerRef" db:"provider_ref"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
IdempotencyKey string `json:"-" db:"idempotency_key"`
}
type Audit struct {
ID string `json:"id" db:"id"`
Actor string `json:"actor" db:"actor"`
Action string `json:"action" db:"action"`
Target string `json:"target" db:"target"`
Result string `json:"result" db:"result"`
RequestID string `json:"requestId" db:"request_id"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
}
type DeviceDiagnostics struct {
AgentVersion string `json:"agentVersion" binding:"required,max=80"`
BootID string `json:"bootId" binding:"required,max=80"`
UptimeSeconds float64 `json:"uptimeSeconds" binding:"min=0"`
KioskState string `json:"kioskState" binding:"required,max=80"`
LogTail string `json:"logTail" binding:"max=16384"`
}

View File

@ -0,0 +1,553 @@
package server
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgconn"
"log"
"net"
"strings"
"time"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
"vdi-cloud/platform/pkg/proxmox"
)
func (s *Server) overview(c *gin.Context) {
var counts struct {
Terminals int `db:"terminals"`
Online int `db:"online"`
Desktops int `db:"desktops"`
Running int `db:"running"`
Sessions int `db:"sessions"`
Failed int `db:"failed"`
}
err := s.DB.GetContext(c.Request.Context(), &counts, `SELECT (SELECT count(*) FROM terminals) terminals,(SELECT count(*) FROM terminals WHERE last_seen>now()-interval '90 seconds') online,(SELECT count(*) FROM desktops) desktops,(SELECT count(*) FROM desktops WHERE power_state='running') running,(SELECT count(*) FROM terminals WHERE session_state='connected' AND session_desktop_id IS NOT NULL AND last_seen>now()-interval '90 seconds') sessions,(SELECT count(*) FROM tasks WHERE status IN ('failed','timeout') AND created_at>now()-interval '24 hours') failed`)
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"terminalCount": counts.Terminals, "onlineTerminals": counts.Online, "desktopCount": counts.Desktops, "runningDesktops": counts.Running, "activeSessions": counts.Sessions, "failedTasks": counts.Failed, "mode": s.Config.Mode})
}
func (s *Server) settings(c *gin.Context) {
provider := "unconfigured"
if s.Config.Mode == "demo" {
provider = "demo"
}
if s.PVE != nil {
provider = "proxmox"
}
c.JSON(200, gin.H{"mode": s.Config.Mode, "computeProvider": provider, "pveConfigured": s.PVE != nil, "terminalTransport": "https-poll", "capabilities": []string{"auth", "resources", "desktops", "terminals", "bindings", "tasks", "audit"}})
}
func (s *Server) resources(c *gin.Context) {
items := []model.Resource{}
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM resources ORDER BY name"); err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) desktops(c *gin.Context) {
items := []model.Desktop{}
q := "%" + c.Query("keyword") + "%"
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT * FROM desktops WHERE (name ILIKE $1 OR vmid::text ILIKE $1) AND ($2='' OR power_state=$2) ORDER BY name`, q, c.Query("status"))
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) desktop(c *gin.Context) {
var d model.Desktop
if err := s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, d)
}
func (s *Server) terminals(c *gin.Context) {
items := []model.Terminal{}
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT id,sn,name,group_name,CASE WHEN last_seen>now()-interval '90 seconds' THEN 'online' WHEN status='unregistered' THEN 'unregistered' ELSE 'offline' END status,agent_version,last_seen,CASE WHEN last_seen>now()-interval '90 seconds' THEN session_desktop_id ELSE NULL END session_desktop_id,session_state,config_version,fps,rtt FROM terminals WHERE (name ILIKE $1 OR sn ILIKE $1) ORDER BY name`, "%"+c.Query("keyword")+"%")
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) users(c *gin.Context) {
items, err := store.Users(c.Request.Context(), s.DB)
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) createUser(c *gin.Context) {
var in struct {
Username string `json:"username" binding:"required,min=2,max=80"`
DisplayName string `json:"displayName" binding:"required,max=80"`
}
if !s.decode(c, &in) {
return
}
in.Username = strings.TrimSpace(in.Username)
in.DisplayName = strings.TrimSpace(in.DisplayName)
if in.Username == "" || in.DisplayName == "" {
s.fail(c, 400, "invalid_request", "账号和姓名不能为空")
return
}
id := store.ID("user_")
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO users(id,username,display_name) VALUES($1,$2,$3)", id, in.Username, in.DisplayName)
if err != nil {
if conflict(err) {
s.fail(c, 409, "conflict", "该账号已存在")
} else {
s.dbError(c, err)
}
return
}
s.log(c, "create_user", id, "success")
c.JSON(201, model.User{ID: id, Username: in.Username, DisplayName: in.DisplayName, Enabled: true, DesktopIDs: []string{}})
}
func conflict(err error) bool {
var e *pgconn.PgError
return errors.As(err, &e) && (e.Code == "23505" || e.Code == "23503")
}
func (s *Server) bindings(c *gin.Context) {
var in struct {
TerminalID *string `json:"terminalId"`
DesktopIDs []string `json:"desktopIds" binding:"max=100"`
Enabled *bool `json:"enabled"`
}
if !s.decode(c, &in) {
return
}
if in.TerminalID != nil && *in.TerminalID == "" {
in.TerminalID = nil
}
ctx := c.Request.Context()
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
var user model.User
if err = tx.GetContext(ctx, &user, "SELECT * FROM users WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
if in.Enabled != nil {
user.Enabled = *in.Enabled
}
_, err = tx.ExecContext(ctx, "UPDATE users SET terminal_id=$1,enabled=$2 WHERE id=$3", in.TerminalID, user.Enabled, user.ID)
if err != nil {
if conflict(err) {
s.fail(c, 409, "binding_conflict", "终端不存在或已关联其他用户")
} else {
s.dbError(c, err)
}
return
}
if _, err = tx.ExecContext(ctx, "DELETE FROM bindings WHERE user_id=$1", user.ID); err != nil {
s.dbError(c, err)
return
}
seen := map[string]bool{}
for _, id := range in.DesktopIDs {
if seen[id] {
continue
}
seen[id] = true
if _, err = tx.ExecContext(ctx, "INSERT INTO bindings VALUES($1,$2)", user.ID, id); err != nil {
if conflict(err) {
s.fail(c, 400, "invalid_desktop", "所选桌面不存在")
} else {
s.dbError(c, err)
}
return
}
}
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,'update_bindings',$3,'success',$4,now())", store.ID("audit_"), s.admin(c).Username, user.ID, c.GetString("requestId"))
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
user.TerminalID = in.TerminalID
user.DesktopIDs = in.DesktopIDs
if user.DesktopIDs == nil {
user.DesktopIDs = []string{}
}
c.JSON(200, user)
}
func (s *Server) tasks(c *gin.Context) {
items := []model.Task{}
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM tasks ORDER BY created_at DESC LIMIT 1000"); err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) task(c *gin.Context) {
var t model.Task
if err := s.DB.GetContext(c.Request.Context(), &t, "SELECT * FROM tasks WHERE id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, t)
}
func (s *Server) audits(c *gin.Context) {
items := []model.Audit{}
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 1000"); err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) discoverVMs(c *gin.Context) {
if s.PVE == nil {
s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox 接入,演示环境不发现真实 VM")
return
}
items, err := s.PVE.Resources(c.Request.Context())
if err != nil {
s.fail(c, 502, "pve_unavailable", "Proxmox 查询失败,请检查连接、证书和访问权限")
return
}
vms := []gin.H{}
for _, r := range items {
if r.Type == "qemu" && r.Template == 0 {
vms = append(vms, gin.H{"vmid": r.VMID, "node": r.Node, "name": r.Name, "status": r.Status})
}
}
list(c, vms)
}
func (s *Server) syncResources(c *gin.Context) {
if s.PVE == nil {
s.fail(c, 503, "pve_unconfigured", "请通过服务端环境变量配置 Proxmox 接入")
return
}
s.submitTask(c, "resource", "sync", "cluster", "Proxmox 集群")
}
func (s *Server) importDesktop(c *gin.Context) {
var in struct {
Name string `json:"name" binding:"required,max=80"`
OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"`
Node string `json:"node" binding:"required,max=100"`
VMID int `json:"vmid" binding:"min=100"`
Endpoint string `json:"endpoint" binding:"max=253"`
Width int `json:"width" binding:"omitempty,min=640,max=7680"`
Height int `json:"height" binding:"omitempty,min=480,max=4320"`
FPS int `json:"fps" binding:"omitempty,min=10,max=120"`
}
if !s.decode(c, &in) {
return
}
if s.PVE == nil {
s.fail(c, 503, "pve_unconfigured", "真实 VM 纳管需要先配置 Proxmox")
return
}
if in.Endpoint != "" && net.ParseIP(in.Endpoint) == nil {
for _, part := range strings.Split(in.Endpoint, ".") {
if part == "" || strings.ContainsAny(part, "/:@ \\") {
s.fail(c, 400, "invalid_endpoint", "连接地址应为 IP 或主机名")
return
}
}
}
power, err := s.PVE.Power(c.Request.Context(), in.Node, in.VMID)
if err != nil {
s.fail(c, 502, "pve_unavailable", "无法验证所选 VM,请检查节点和 VMID")
return
}
id := store.ID("desk_")
if in.Width == 0 {
in.Width = 2560
}
if in.Height == 0 {
in.Height = 1440
}
if in.FPS == 0 {
in.FPS = 30
}
_, err = s.DB.ExecContext(c.Request.Context(), `INSERT INTO desktops(id,name,os,node,vmid,power_state,provider,endpoint,width,height,fps) VALUES($1,$2,$3,$4,$5,$6,'proxmox',$7,$8,$9,$10)`, id, in.Name, in.OS, in.Node, in.VMID, power, in.Endpoint, in.Width, in.Height, in.FPS)
if err != nil {
if conflict(err) {
s.fail(c, 409, "already_managed", "该 VM 已纳管")
} else {
s.dbError(c, err)
}
return
}
s.log(c, "import_desktop", id, "success")
c.Params = append(c.Params, gin.Param{Key: "id", Value: id})
s.desktop(c)
}
func (s *Server) desktopAction(c *gin.Context) {
var in struct {
Action string `json:"action" binding:"required,oneof=start shutdown reboot"`
}
if !s.decode(c, &in) {
return
}
var d model.Desktop
if err := s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
if d.Provider == "demo" && s.Config.Mode != "demo" {
s.fail(c, 409, "demo_disabled", "正式模式不执行演示桌面任务")
return
}
if d.Provider == "proxmox" && s.PVE == nil {
s.fail(c, 503, "pve_unconfigured", "Proxmox 尚未配置")
return
}
s.submitTask(c, "desktop", in.Action, d.ID, d.Name)
}
func (s *Server) terminalCommand(c *gin.Context) {
var in struct {
Action string `json:"action" binding:"required,oneof=reboot reset-session collect-logs"`
}
if !s.decode(c, &in) {
return
}
var name string
if err := s.DB.GetContext(c.Request.Context(), &name, "SELECT name FROM terminals WHERE id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
s.submitTask(c, "terminal", in.Action, c.Param("id"), name)
}
func (s *Server) submitTask(c *gin.Context, kind, action, targetID, targetName string) {
key := c.GetHeader("Idempotency-Key")
if key == "" || len(key) > 100 {
s.fail(c, 400, "idempotency_required", "请提供不超过 100 字符的 Idempotency-Key")
return
}
actor := s.admin(c).Username
ctx := c.Request.Context()
var existing model.Task
err := s.DB.GetContext(ctx, &existing, "SELECT * FROM tasks WHERE actor=$1 AND idempotency_key=$2", actor, key)
if err == nil {
if existing.Kind != kind || existing.Action != action || existing.TargetID != targetID {
s.fail(c, 409, "idempotency_conflict", "相同请求标识不能用于不同操作")
return
}
c.JSON(202, existing)
return
}
if !errors.Is(err, sql.ErrNoRows) {
s.dbError(c, err)
return
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
var task model.Task
err = tx.GetContext(ctx, &task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,$2,$3,$4,$5,$6,'queued','已提交,等待执行回执',$7) RETURNING *`, store.ID("task_"), kind, action, targetID, targetName, actor, key)
if err != nil {
if conflict(err) {
s.fail(c, 409, "task_conflict", "已有相同请求或该对象有任务执行中,请刷新任务列表")
} else {
s.dbError(c, err)
}
return
}
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,$3,$4,'submitted',$5,now())", store.ID("audit_"), actor, action, targetName, c.GetString("requestId"))
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.JSON(202, task)
}
// Complete persists the final result and its audit entry together.
func (s *Server) complete(ctx context.Context, t model.Task, status, message string) error {
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, "UPDATE tasks SET status=$1,message=$2,updated_at=now() WHERE id=$3 AND status IN ('running','queued')", status, message, t.ID)
if err != nil {
return err
}
n, _ := result.RowsAffected()
if n == 0 {
return nil
}
// Session release is scoped to a session ID by the device event endpoint.
// A delayed command acknowledgement must not clear a newer connection.
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,$3,$4,$5,$6,now())", store.ID("audit_"), t.Actor, t.Action, t.TargetName, status, t.ID)
if err != nil {
return err
}
return tx.Commit()
}
func ratio(a, b float64) float64 {
if b <= 0 {
return 0
}
return a / b
}
func (s *Server) sync(ctx context.Context) error {
items, err := s.PVE.Resources(ctx)
if err != nil {
return err
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, "DELETE FROM resources WHERE provider='proxmox'"); err != nil {
return err
}
for _, r := range items {
if r.Type == "node" {
if _, err = tx.ExecContext(ctx, `INSERT INTO resources VALUES($1,$1,$2,$3,$4,$5,'proxmox',now()) ON CONFLICT(id) DO UPDATE SET status=$2,cpu=$3,memory=$4,storage=$5,provider='proxmox',updated_at=now()`, r.Node, r.Status, r.CPU, ratio(r.Memory, r.MaxMemory), ratio(r.Disk, r.MaxDisk)); err != nil {
return err
}
}
if r.Type == "qemu" {
if _, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,updated_at=now() WHERE provider='proxmox' AND node=$2 AND vmid=$3", r.Status, r.Node, r.VMID); err != nil {
return err
}
}
}
return tx.Commit()
}
func (s *Server) RunWorker(ctx context.Context) {
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// Unknown outcomes are never automatically retried after a worker restart.
var expired []model.Task
if s.DB.SelectContext(ctx, &expired, "SELECT * FROM tasks WHERE status IN ('queued','running') AND created_at<now()-interval '2 minutes'") == nil {
for _, t := range expired {
_ = s.complete(ctx, t, "timeout", "结果未确认,请核对设备或 PVE 实际状态;不会自动重放")
}
}
var t model.Task
err := s.DB.GetContext(ctx, &t, `UPDATE tasks SET status='running',updated_at=now(),message='正在执行,等待最终回执' WHERE id=(SELECT id FROM tasks WHERE status='queued' AND kind!='terminal' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`)
if err != nil {
continue
}
s.execute(ctx, t)
}
}
}
// Resource reads are independent of the command queue and never change VM power.
func (s *Server) RunSync(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
readCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
if err := s.sync(readCtx); err != nil {
log.Printf("PVE resource sync failed: %v", err)
}
cancel()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (s *Server) execute(parent context.Context, t model.Task) {
ctx, cancel := context.WithTimeout(parent, 90*time.Second)
defer cancel()
var err error
uncertain := false
message := "已收到执行结果"
if t.Kind == "resource" {
err = s.sync(ctx)
} else {
var d model.Desktop
err = s.DB.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID)
if err == nil && d.Provider == "demo" {
if s.Config.Mode != "demo" {
err = errors.New("demo disabled")
} else {
select {
case <-ctx.Done():
err = ctx.Err()
case <-time.After(500 * time.Millisecond):
}
if err == nil {
power := "running"
if t.Action == "shutdown" {
power = "stopped"
}
_, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID)
message = "演示计算任务已完成;未检测真实连接服务"
}
}
} else if err == nil {
if s.PVE == nil {
err = errors.New("PVE unconfigured")
} else {
var upid string
upid, err = s.PVE.Action(ctx, d.Node, d.VMID, t.Action)
uncertain = true
var httpError *proxmox.HTTPError
if errors.As(err, &httpError) && httpError.Status >= 400 && httpError.Status < 500 {
uncertain = false
}
if err == nil {
_, err = s.DB.ExecContext(ctx, "UPDATE tasks SET provider_ref=$1 WHERE id=$2", upid, t.ID)
}
if err == nil {
err = s.PVE.Wait(ctx, d.Node, upid, time.Second)
}
if err == nil {
var power string
power, err = s.PVE.Power(ctx, d.Node, d.VMID)
if err == nil {
expected := "running"
if t.Action == "shutdown" {
expected = "stopped"
}
if power != expected {
err = fmt.Errorf("power result not confirmed")
} else {
_, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID)
}
}
}
}
}
}
status := "success"
if err != nil {
log.Printf("task execution error taskId=%s error=%v", t.ID, err)
status = "failed"
message = "执行失败,请检查服务日志及 PVE 任务;未自动重试"
if (uncertain && !errors.Is(err, proxmox.ErrTaskFailed)) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
status = "timeout"
message = "结果未确认,请核对实际状态;未自动重试"
}
}
finalCtx, finalCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer finalCancel()
_ = s.complete(finalCtx, t, status, message)
}

View File

@ -0,0 +1,214 @@
package server
import (
"context"
"database/sql"
"errors"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
"log"
"net/http"
"strconv"
"strings"
"time"
"vdi-cloud/platform/internal/config"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
"vdi-cloud/platform/pkg/proxmox"
)
type Server struct {
Config config.Config
DB *sqlx.DB
PVE *proxmox.Client
Router *gin.Engine
}
type claims struct {
AdminID string `json:"adminId"`
jwt.RegisteredClaims
}
func New(cfg config.Config, db *sqlx.DB, pve *proxmox.Client) *Server {
gin.SetMode(gin.ReleaseMode)
s := &Server{Config: cfg, DB: db, PVE: pve}
r := gin.New()
r.Use(gin.Recovery(), func(c *gin.Context) {
c.Set("requestId", store.ID("req_"))
c.Header("X-Request-ID", c.GetString("requestId"))
c.Header("Cache-Control", "no-store")
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
c.Next()
})
s.Router = r
r.GET("/healthz", func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
if db.PingContext(ctx) != nil {
s.fail(c, 503, "dependency_unavailable", "数据库暂不可用")
return
}
c.JSON(200, gin.H{"status": "ok", "mode": cfg.Mode})
})
r.POST("/api/v1/auth/login", s.login)
r.POST("/api/v1/terminal/register", s.register)
t := r.Group("/api/v1/terminal", s.deviceAuth)
t.POST("/heartbeat", s.heartbeat)
t.GET("/config", s.deviceConfig)
t.GET("/desktops", s.deviceDesktops)
t.GET("/commands", s.deviceCommands)
t.POST("/commands/:id/ack", s.deviceAck)
t.POST("/connect-ticket", s.createTicket)
t.POST("/connect-ticket/consume", s.consumeTicket)
t.POST("/session", s.deviceSession)
a := r.Group("/api/v1", s.adminAuth)
a.GET("/auth/me", func(c *gin.Context) { c.JSON(200, c.MustGet("admin")) })
a.POST("/auth/logout", s.logout)
a.GET("/overview", s.overview)
a.GET("/settings", s.settings)
a.GET("/resources", s.resources)
a.GET("/resources/vms", s.discoverVMs)
a.POST("/resources/sync", s.allow("super_admin"), s.syncResources)
a.GET("/desktops", s.desktops)
a.POST("/desktops/import", s.allow("super_admin"), s.importDesktop)
a.GET("/desktops/:id", s.desktop)
a.POST("/desktops/:id/actions", s.allow("super_admin", "operator"), s.desktopAction)
a.GET("/terminals", s.terminals)
a.POST("/terminals", s.allow("super_admin"), s.enroll)
a.POST("/terminals/:id/commands", s.allow("super_admin", "operator"), s.terminalCommand)
a.GET("/users", s.users)
a.POST("/users", s.allow("super_admin"), s.createUser)
a.PUT("/users/:id/bindings", s.allow("super_admin"), s.bindings)
a.GET("/tasks", s.tasks)
a.GET("/tasks/:id", s.task)
a.GET("/tasks/:id/diagnostics", s.allow("super_admin", "operator"), s.taskDiagnostics)
a.GET("/audit-logs", s.audits)
r.NoRoute(func(c *gin.Context) { s.fail(c, 404, "not_found", "接口不存在") })
return s
}
func (s *Server) fail(c *gin.Context, status int, code, message string) {
c.AbortWithStatusJSON(status, gin.H{"code": code, "message": message, "requestId": c.GetString("requestId")})
}
func (s *Server) dbError(c *gin.Context, err error) {
log.Printf("database request failed requestId=%s error=%v", c.GetString("requestId"), err)
if errors.Is(err, sql.ErrNoRows) {
s.fail(c, 404, "not_found", "记录不存在")
} else {
s.fail(c, 500, "database_error", "操作未完成,请根据请求 ID 检查服务日志")
}
}
func (s *Server) decode(c *gin.Context, v any) bool {
if err := c.ShouldBindJSON(v); err != nil {
s.fail(c, 400, "invalid_request", "请检查必填字段和数据格式")
return false
}
return true
}
func (s *Server) admin(c *gin.Context) model.Administrator {
return c.MustGet("admin").(model.Administrator)
}
func (s *Server) log(c *gin.Context, action, target, result string) {
actor := "device"
if a, ok := c.Get("admin"); ok {
actor = a.(model.Administrator).Username
}
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,$3,$4,$5,$6)", store.ID("audit_"), actor, action, target, result, c.GetString("requestId"))
if err != nil {
c.Error(err)
}
}
func (s *Server) allow(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
for _, role := range roles {
if role == s.admin(c).Role {
c.Next()
return
}
}
s.log(c, c.Request.Method+" "+c.FullPath(), c.Param("id"), "denied")
s.fail(c, 403, "forbidden", "当前账号没有该操作权限")
}
}
func (s *Server) adminAuth(c *gin.Context) {
raw := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
cl := &claims{}
token, err := jwt.ParseWithClaims(raw, cl, func(t *jwt.Token) (any, error) { return []byte(s.Config.JWTSecret), nil }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer("vdi-platform"), jwt.WithAudience("vdi-console"), jwt.WithExpirationRequired())
if err != nil || !token.Valid {
s.fail(c, 401, "unauthorized", "登录已失效,请重新登录")
return
}
var a model.Administrator
err = s.DB.GetContext(c.Request.Context(), &a, `SELECT a.* FROM administrators a JOIN admin_sessions x ON x.admin_id=a.id WHERE x.id=$1 AND a.id=$2 AND x.expires_at>now()`, cl.ID, cl.AdminID)
if err != nil {
s.fail(c, 401, "unauthorized", "登录已失效,请重新登录")
return
}
c.Set("admin", a)
c.Set("sessionId", cl.ID)
c.Next()
}
func (s *Server) login(c *gin.Context) {
var input struct {
Username string `json:"username" binding:"required,max=80"`
Password string `json:"password" binding:"required,max=200"`
}
if !s.decode(c, &input) {
return
}
var a model.Administrator
err := s.DB.GetContext(c.Request.Context(), &a, "SELECT * FROM administrators WHERE username=$1", input.Username)
if err != nil || bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(input.Password)) != nil {
s.fail(c, 401, "invalid_credentials", "用户名或密码错误")
return
}
now := time.Now()
id := store.ID("sess_")
expires := now.Add(time.Hour)
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{AdminID: a.ID, RegisteredClaims: jwt.RegisteredClaims{Issuer: "vdi-platform", Audience: jwt.ClaimStrings{"vdi-console"}, ID: id, Subject: a.ID, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(expires)}}).SignedString([]byte(s.Config.JWTSecret))
if err != nil {
s.dbError(c, err)
return
}
if _, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO admin_sessions VALUES($1,$2,$3)", id, a.ID, expires); err != nil {
s.dbError(c, err)
return
}
c.Set("admin", a)
s.log(c, "login", a.Username, "success")
c.JSON(200, gin.H{"accessToken": token, "expiresIn": 3600, "user": a})
}
func (s *Server) logout(c *gin.Context) {
if _, err := s.DB.ExecContext(c.Request.Context(), "DELETE FROM admin_sessions WHERE id=$1", c.GetString("sessionId")); err != nil {
s.dbError(c, err)
return
}
s.log(c, "logout", s.admin(c).Username, "success")
c.Status(204)
}
func list[T any](c *gin.Context, items []T) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
if size > 100 {
size = 100
}
total := len(items)
start := (page - 1) * size
if start < 0 || start > total {
start = total
}
end := start + size
if end > total {
end = total
}
if items == nil {
items = []T{}
}
c.JSON(200, gin.H{"items": items[start:end], "page": page, "pageSize": size, "total": total, "requestId": c.GetString("requestId")})
}

View File

@ -0,0 +1,375 @@
package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/jmoiron/sqlx"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"vdi-cloud/platform/internal/config"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
"vdi-cloud/platform/pkg/proxmox"
)
type fixture struct {
server *Server
db *sqlx.DB
t *testing.T
}
func newFixture(t *testing.T) *fixture {
t.Helper()
url := os.Getenv("VDI_TEST_DATABASE_URL")
if url == "" {
t.Skip("set VDI_TEST_DATABASE_URL to a dedicated empty test database")
}
ctx := context.Background()
db, err := store.Open(ctx, url)
if err != nil {
t.Fatal(err)
}
if _, err = db.Exec(`TRUNCATE connection_tickets,audit_logs,tasks,bindings,users,terminals,desktops,resources,admin_sessions,administrators CASCADE`); err != nil {
t.Fatal(err)
}
if err = store.Bootstrap(ctx, db, "demo", "", ""); err != nil {
t.Fatal(err)
}
s := New(config.Config{Mode: "demo", JWTSecret: strings.Repeat("test-", 10)}, db, nil)
ctx, cancel := context.WithCancel(ctx)
go s.RunWorker(ctx)
t.Cleanup(func() { cancel(); time.Sleep(30 * time.Millisecond); db.Close() })
return &fixture{s, db, t}
}
func (f *fixture) request(method, path, token string, body any, key string) (int, map[string]any) {
f.t.Helper()
var encoded []byte
if body != nil {
encoded, _ = json.Marshal(body)
}
r := httptest.NewRequest(method, "/api/v1"+path, bytes.NewReader(encoded))
r.Header.Set("Content-Type", "application/json")
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
if key != "" {
r.Header.Set("Idempotency-Key", key)
}
w := httptest.NewRecorder()
f.server.Router.ServeHTTP(w, r)
out := map[string]any{}
if w.Body.Len() > 0 {
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
f.t.Fatal(err)
}
}
return w.Code, out
}
func (f *fixture) login(user string) string {
f.t.Helper()
status, out := f.request("POST", "/auth/login", "", map[string]any{"username": user, "password": "Demo@2026"}, "")
if status != 200 {
f.t.Fatalf("login failed %d %v", status, out)
}
return out["accessToken"].(string)
}
func expect(t *testing.T, want, got int, out any) {
t.Helper()
if want != got {
t.Fatalf("want status %d got %d: %v", want, got, out)
}
}
func TestRBACLogoutAndDurableTask(t *testing.T) {
f := newFixture(t)
viewer := f.login("viewer")
operator := f.login("operator")
admin := f.login("admin")
status, out := f.request("GET", "/desktops", viewer, nil, "")
expect(t, 200, status, out)
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", viewer, map[string]any{"action": "start"}, "viewer-1")
expect(t, 403, status, out)
status, out = f.request("PUT", "/users/user_demo_wang/bindings", operator, map[string]any{"desktopIds": []string{}}, "")
expect(t, 403, status, out)
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "start"}, "start-1")
expect(t, 202, status, out)
id := out["id"].(string)
status, again := f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "start"}, "start-1")
expect(t, 202, status, again)
if again["id"] != id {
t.Fatal("duplicate task created")
}
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "shutdown"}, "start-1")
expect(t, 409, status, out)
deadline := time.Now().Add(5 * time.Second)
for {
status, out = f.request("GET", "/tasks/"+id, admin, nil, "")
expect(t, 200, status, out)
if out["status"] == "success" {
break
}
if time.Now().After(deadline) {
t.Fatal("task never completed", out)
}
time.Sleep(50 * time.Millisecond)
}
var power, service string
if err := f.db.QueryRow("SELECT power_state,service_state FROM desktops WHERE id='desk_demo_kylin'").Scan(&power, &service); err != nil {
t.Fatal(err)
}
if power != "running" || service != "unknown" {
t.Fatal("power and service state confused")
}
status, out = f.request("POST", "/auth/logout", admin, nil, "")
expect(t, 204, status, out)
status, out = f.request("GET", "/auth/me", admin, nil, "")
expect(t, 401, status, out)
var count int
f.db.Get(&count, "SELECT count(*) FROM tasks WHERE id=$1", id)
if count != 1 {
t.Fatal("task not persisted")
}
}
func TestDeviceIdentityBindingTicketAndAcknowledgement(t *testing.T) {
f := newFixture(t)
admin := f.login("admin")
enroll := func(sn string) (string, string) {
status, out := f.request("POST", "/terminals", admin, map[string]any{"sn": sn, "name": sn, "group": "测试"}, "")
expect(t, 201, status, out)
secret := out["activationSecret"].(string)
id := out["id"].(string)
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": sn, "activationSecret": secret, "agentVersion": "test"}, "")
expect(t, 200, status, out)
token := out["accessToken"].(string)
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": sn, "activationSecret": secret, "agentVersion": "test"}, "")
expect(t, 401, status, out)
return id, token
}
id, device := enroll("TEST-DEVICE-001")
otherID, other := enroll("TEST-DEVICE-002")
status, out := f.request("GET", "/users", device, nil, "")
expect(t, 401, status, out)
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
expect(t, 403, status, out)
status, out = f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": id, "desktopIds": []string{"desk_demo_windows", "desk_demo_ubuntu"}, "enabled": true}, "")
expect(t, 200, status, out)
status, out = f.request("GET", "/terminal/desktops", device, nil, "")
expect(t, 200, status, out)
if len(out["items"].([]any)) != 2 {
t.Fatal("missing allowed desktops")
}
f.db.Exec("UPDATE desktops SET endpoint='192.0.2.100' WHERE id='desk_demo_windows'")
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
expect(t, 201, status, out)
ticket := out["ticket"].(string)
status, out = f.request("POST", "/terminal/connect-ticket/consume", other, map[string]any{"ticket": ticket}, "")
expect(t, 401, status, out)
status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": ticket}, "")
expect(t, 200, status, out)
if out["credentialMode"] != "existing-pairing" {
t.Fatal("must declare existing Sunshine pairing")
}
sessionID := out["sessionId"].(string)
status, out = f.request("GET", "/overview", admin, nil, "")
expect(t, 200, status, out)
if out["activeSessions"] != float64(0) {
t.Fatal("reservation counted as active stream")
}
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "connected"}, "")
expect(t, 200, status, out)
status, out = f.request("GET", "/overview", admin, nil, "")
if out["activeSessions"] != float64(1) {
t.Fatal("stream-ready event missing")
}
status, out = f.request("POST", "/terminal/session", other, map[string]any{"sessionId": sessionID, "state": "ended"}, "")
expect(t, 409, status, out)
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "ended"}, "")
expect(t, 200, status, out)
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "connected"}, "")
expect(t, 409, status, out)
status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": ticket}, "")
expect(t, 401, status, out)
status, out = f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": id, "desktopIds": []string{}, "enabled": true}, "")
expect(t, 200, status, out)
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
expect(t, 403, status, out)
status, out = f.request("POST", fmt.Sprintf("/terminals/%s/commands", id), admin, map[string]any{"action": "collect-logs"}, "cmd-1")
expect(t, 202, status, out)
taskID := out["id"].(string)
status, out = f.request("GET", "/terminal/commands", other, nil, "")
expect(t, 200, status, out)
if len(out["items"].([]any)) != 0 {
t.Fatal("command leaked to other terminal")
}
status, out = f.request("POST", "/terminal/commands/"+taskID+"/ack", other, map[string]any{"status": "success"}, "")
expect(t, 404, status, out)
status, out = f.request("GET", "/terminal/commands", device, nil, "")
expect(t, 200, status, out)
if len(out["items"].([]any)) != 1 {
t.Fatal("device did not receive command")
}
status, out = f.request("POST", "/terminal/commands/"+taskID+"/ack", device, map[string]any{"status": "failed", "message": "private device log"}, "")
expect(t, 200, status, out)
status, out = f.request("GET", "/tasks/"+taskID, admin, nil, "")
expect(t, 200, status, out)
if out["status"] != "failed" {
t.Fatal("must wait for device ack")
}
if strings.Contains(out["message"].(string), "private") {
t.Fatal("raw device detail leaked")
}
status, out = f.request("POST", "/terminals/"+id+"/commands", admin, map[string]any{"action": "collect-logs"}, "diagnostics-2")
expect(t, 202, status, out)
diagnosticID := out["id"].(string)
status, out = f.request("GET", "/terminal/commands", device, nil, "")
expect(t, 200, status, out)
status, out = f.request("POST", "/terminal/commands/"+diagnosticID+"/ack", device, map[string]any{"status": "success"}, "")
expect(t, 400, status, out)
status, out = f.request("POST", "/terminal/commands/"+diagnosticID+"/ack", device, map[string]any{"status": "success", "diagnostics": map[string]any{"agentVersion": "qt-test", "bootId": "test-boot", "kioskState": "DESKTOP_LIST", "uptimeSeconds": 100, "logTail": "private diagnostic"}}, "")
expect(t, 200, status, out)
viewer := f.login("viewer")
status, out = f.request("GET", "/tasks/"+diagnosticID+"/diagnostics", viewer, nil, "")
expect(t, 403, status, out)
status, out = f.request("GET", "/tasks/"+diagnosticID+"/diagnostics", admin, nil, "")
expect(t, 200, status, out)
if out["logTail"] != "private diagnostic" {
t.Fatal("diagnostic not retained")
}
_ = otherID
}
func TestUnknownTerminalOutcomeAndBindingRollback(t *testing.T) {
f := newFixture(t)
admin := f.login("admin")
status, out := f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": "term_demo_001", "desktopIds": []string{"missing"}}, "")
expect(t, 400, status, out)
var n int
f.db.Get(&n, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'")
if n != 2 {
t.Fatal("failed binding update lost original authorization")
}
status, out = f.request("POST", "/terminals/term_demo_001/commands", admin, map[string]any{"action": "reboot"}, "offline-1")
expect(t, 202, status, out)
id := out["id"].(string)
f.db.Exec("UPDATE tasks SET created_at=now()-interval '3 minutes' WHERE id=$1", id)
deadline := time.Now().Add(3 * time.Second)
for {
status, out = f.request("GET", "/tasks/"+id, admin, nil, "")
expect(t, 200, status, out)
if out["status"] == "timeout" {
break
}
if time.Now().After(deadline) {
t.Fatal("offline task was not timed out", out)
}
time.Sleep(50 * time.Millisecond)
}
status, out = f.request(http.MethodPost, "/resources/sync", admin, nil, "sync-1")
expect(t, 503, status, out)
}
func TestRestartPreservesRevokedBindingsAndRejectsModeChange(t *testing.T) {
f := newFixture(t)
if _, err := f.db.Exec("DELETE FROM bindings WHERE user_id='user_demo_wang'"); err != nil {
t.Fatal(err)
}
if err := store.Bootstrap(context.Background(), f.db, "demo", "", ""); err != nil {
t.Fatal(err)
}
var count int
if err := f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'"); err != nil || count != 0 {
t.Fatalf("restart restored revoked bindings: count=%d err=%v", count, err)
}
if err := store.Bootstrap(context.Background(), f.db, "production", "prod-admin", "long-password-2026"); err == nil {
t.Fatal("demo database accepted production bootstrap")
}
}
func TestInitialPasswordPolicyDoesNotRejectExistingAccountOnRestart(t *testing.T) {
f := newFixture(t)
if _, err := f.db.Exec("UPDATE platform_metadata SET mode='production'"); err != nil {
t.Fatal(err)
}
defer f.db.Exec("UPDATE platform_metadata SET mode='demo'")
ctx := context.Background()
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "short"); err == nil {
t.Fatal("weak initial password accepted")
}
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "strong-initial-password"); err != nil {
t.Fatal(err)
}
var before, after string
if err := f.db.Get(&before, "SELECT password_hash FROM administrators WHERE username='new-admin'"); err != nil {
t.Fatal(err)
}
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "short"); err != nil {
t.Fatal("existing account blocked startup", err)
}
if err := f.db.Get(&after, "SELECT password_hash FROM administrators WHERE username='new-admin'"); err != nil {
t.Fatal(err)
}
if before != after {
t.Fatal("restart changed the account password")
}
}
func TestLateCommandAcknowledgementPreservesNewSession(t *testing.T) {
f := newFixture(t)
if _, err := f.db.Exec("UPDATE terminals SET session_id='new-session',session_state='connected',session_desktop_id='desk_demo_windows' WHERE id='term_demo_001'"); err != nil {
t.Fatal(err)
}
var task model.Task
if err := f.db.Get(&task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES('task-late','terminal','reset-session','term_demo_001','test','admin','running','','late') RETURNING *`); err != nil {
t.Fatal(err)
}
if err := f.server.complete(context.Background(), task, "success", "ack"); err != nil {
t.Fatal(err)
}
var session string
if err := f.db.Get(&session, "SELECT session_id FROM terminals WHERE id='term_demo_001'"); err != nil || session != "new-session" {
t.Fatalf("new session lost: %q %v", session, err)
}
}
func TestPVEConfirmedFailureAndUncertainSubmission(t *testing.T) {
f := newFixture(t)
if _, err := f.db.Exec("UPDATE desktops SET provider='proxmox' WHERE id='desk_demo_kylin'"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
submission int
exit string
want string
}{{"submission unavailable", 503, "", "timeout"}, {"permission denied", 403, "", "failed"}, {"explicit PVE task failure", 200, "ERROR", "failed"}} {
t.Run(tc.name, func(t *testing.T) {
pve := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodPost {
w.WriteHeader(tc.submission)
fmt.Fprint(w, `{"data":"UPID:test"}`)
return
}
fmt.Fprintf(w, `{"data":{"status":"stopped","exitstatus":%q}}`, tc.exit)
}))
defer pve.Close()
f.server.PVE = &proxmox.Client{BaseURL: pve.URL, HTTP: pve.Client()}
var task model.Task
if err := f.db.Get(&task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,'desktop','start','desk_demo_kylin','test VM','admin','running','test',$1) RETURNING *`, store.ID("task_")); err != nil {
t.Fatal(err)
}
f.server.execute(context.Background(), task)
var status string
if err := f.db.Get(&status, "SELECT status FROM tasks WHERE id=$1", task.ID); err != nil || status != tc.want {
t.Fatalf("want %s, got %s err=%v", tc.want, status, err)
}
})
}
}

View File

@ -0,0 +1,291 @@
package server
import (
"crypto/subtle"
"encoding/json"
"github.com/gin-gonic/gin"
"strings"
"time"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
)
func (s *Server) enroll(c *gin.Context) {
var in struct {
SN string `json:"sn" binding:"required,min=3,max=100"`
Name string `json:"name" binding:"required,max=80"`
Group string `json:"group" binding:"max=80"`
}
if !s.decode(c, &in) {
return
}
in.SN = strings.TrimSpace(in.SN)
in.Name = strings.TrimSpace(in.Name)
if in.SN == "" || in.Name == "" {
s.fail(c, 400, "invalid_request", "SN 和终端名称不能为空")
return
}
id, secret := store.ID("term_"), store.ID("activate_")
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO terminals(id,sn,name,group_name,bootstrap_hash) VALUES($1,$2,$3,$4,$5)", id, in.SN, in.Name, in.Group, store.Hash(secret))
if err != nil {
if conflict(err) {
s.fail(c, 409, "sn_exists", "该 SN 已登记")
} else {
s.dbError(c, err)
}
return
}
s.log(c, "enroll_terminal", id, "success")
c.JSON(201, gin.H{"id": id, "activationSecret": secret})
}
func (s *Server) register(c *gin.Context) {
var in struct {
SN string `json:"sn" binding:"required,max=100"`
ActivationSecret string `json:"activationSecret" binding:"required,max=200"`
AgentVersion string `json:"agentVersion" binding:"required,max=80"`
}
if !s.decode(c, &in) {
return
}
token := store.ID("device_")
var id string
err := s.DB.GetContext(c.Request.Context(), &id, `UPDATE terminals SET token_hash=$1,bootstrap_hash='',status='online',agent_version=$2,last_seen=now() WHERE sn=$3 AND bootstrap_hash=$4 AND bootstrap_hash!='' RETURNING id`, store.Hash(token), in.AgentVersion, in.SN, store.Hash(in.ActivationSecret))
if err != nil {
s.fail(c, 401, "invalid_activation", "激活信息无效或已使用,请联系管理员")
return
}
c.JSON(200, gin.H{"terminalId": id, "accessToken": token, "heartbeatInterval": 30})
}
func (s *Server) deviceAuth(c *gin.Context) {
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
var id string
if token == "" || s.DB.GetContext(c.Request.Context(), &id, "SELECT id FROM terminals WHERE token_hash=$1", store.Hash(token)) != nil {
s.fail(c, 401, "device_unauthorized", "终端身份无效")
return
}
c.Set("terminalId", id)
c.Next()
}
func (s *Server) heartbeat(c *gin.Context) {
var in struct {
AgentVersion string `json:"agentVersion" binding:"required,max=80"`
FPS *float64 `json:"fps" binding:"omitempty,min=0,max=1000"`
RTT *float64 `json:"rtt" binding:"omitempty,min=0,max=60000"`
}
if !s.decode(c, &in) {
return
}
_, err := s.DB.ExecContext(c.Request.Context(), "UPDATE terminals SET last_seen=now(),status='online',agent_version=$1,fps=$2,rtt=$3 WHERE id=$4", in.AgentVersion, in.FPS, in.RTT, c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"accepted": true, "serverTime": time.Now().UTC()})
}
func (s *Server) deviceConfig(c *gin.Context) {
var version int
if err := s.DB.GetContext(c.Request.Context(), &version, "SELECT config_version FROM terminals WHERE id=$1", c.GetString("terminalId")); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"configVersion": version, "identityMode": "device", "commandTransport": "https-poll", "heartbeatInterval": 30})
}
func (s *Server) deviceDesktops(c *gin.Context) {
items := []model.Desktop{}
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT d.* FROM desktops d JOIN bindings b ON b.desktop_id=d.id JOIN users u ON u.id=b.user_id WHERE u.terminal_id=$1 AND u.enabled=true ORDER BY d.name`, c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) deviceCommands(c *gin.Context) {
items := []model.Task{}
err := s.DB.SelectContext(c.Request.Context(), &items, `UPDATE tasks SET status='running',updated_at=now(),message='终端已领取,等待执行回执' WHERE id IN (SELECT id FROM tasks WHERE kind='terminal' AND target_id=$1 AND status='queued' AND created_at>now()-interval '2 minutes' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 10) RETURNING *`, c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) deviceAck(c *gin.Context) {
var in struct {
Status string `json:"status" binding:"required,oneof=success failed"`
Message string `json:"message" binding:"max=300"`
Diagnostics *model.DeviceDiagnostics `json:"diagnostics"`
}
if !s.decode(c, &in) {
return
}
var task model.Task
err := s.DB.GetContext(c.Request.Context(), &task, "SELECT * FROM tasks WHERE id=$1 AND kind='terminal' AND target_id=$2", c.Param("id"), c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
if task.Status == in.Status {
c.JSON(200, gin.H{"accepted": true})
return
}
if task.Status != "running" {
s.fail(c, 409, "invalid_task_state", "该命令尚未领取或已经结束")
return
}
if task.Action == "collect-logs" && in.Status == "success" {
if in.Diagnostics == nil {
s.fail(c, 400, "diagnostics_required", "收集日志成功需要上传诊断结果")
return
}
data, marshalErr := json.Marshal(in.Diagnostics)
if marshalErr != nil {
s.dbError(c, marshalErr)
return
}
if _, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO task_diagnostics(task_id,content) VALUES($1,$2) ON CONFLICT(task_id) DO NOTHING", task.ID, string(data)); err != nil {
s.dbError(c, err)
return
}
}
// Agent text can contain sensitive device details. Keep the public audit message fixed.
if err = s.complete(c.Request.Context(), task, in.Status, "已收到终端执行回执"); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"accepted": true})
}
func (s *Server) createTicket(c *gin.Context) {
var in struct {
DesktopID string `json:"desktopId" binding:"required,max=100"`
}
if !s.decode(c, &in) {
return
}
var allowed bool
err := s.DB.GetContext(c.Request.Context(), &allowed, `SELECT EXISTS(SELECT 1 FROM bindings b JOIN users u ON u.id=b.user_id WHERE b.desktop_id=$1 AND u.terminal_id=$2 AND u.enabled=true)`, in.DesktopID, c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
if !allowed {
s.fail(c, 403, "desktop_forbidden", "该终端没有桌面使用权限")
return
}
var d model.Desktop
if err = s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", in.DesktopID); err != nil {
s.dbError(c, err)
return
}
if d.PowerState != "running" || d.Endpoint == "" {
s.fail(c, 409, "desktop_not_ready", "桌面未开机或尚未配置连接地址")
return
}
token := store.ID("ticket_")
expires := time.Now().Add(45 * time.Second)
_, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO connection_tickets(token_hash,terminal_id,desktop_id,expires_at) VALUES($1,$2,$3,$4)", store.Hash(token), c.GetString("terminalId"), in.DesktopID, expires)
if err != nil {
s.dbError(c, err)
return
}
c.JSON(201, gin.H{"ticket": token, "desktopId": in.DesktopID, "expiresAt": expires, "credentialMode": "existing-pairing"})
}
func (s *Server) consumeTicket(c *gin.Context) {
var in struct {
Ticket string `json:"ticket" binding:"required,max=200"`
}
if !s.decode(c, &in) {
return
}
ctx := c.Request.Context()
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
var row struct {
TokenHash string `db:"token_hash"`
TerminalID string `db:"terminal_id"`
DesktopID string `db:"desktop_id"`
}
err = tx.GetContext(ctx, &row, `SELECT token_hash,terminal_id,desktop_id FROM connection_tickets WHERE token_hash=$1 AND consumed_at IS NULL AND expires_at>now() FOR UPDATE`, store.Hash(in.Ticket))
if err != nil || subtle.ConstantTimeCompare([]byte(row.TerminalID), []byte(c.GetString("terminalId"))) != 1 {
s.fail(c, 401, "invalid_ticket", "票据已失效或不属于当前终端")
return
}
var d model.Desktop
if err = tx.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1 FOR UPDATE", row.DesktopID); err != nil {
s.dbError(c, err)
return
}
var allowed bool
err = tx.GetContext(ctx, &allowed, `SELECT EXISTS(SELECT 1 FROM users u JOIN bindings b ON b.user_id=u.id WHERE u.terminal_id=$1 AND u.enabled AND b.desktop_id=$2)`, row.TerminalID, d.ID)
if err != nil {
s.dbError(c, err)
return
}
if !allowed {
s.fail(c, 403, "desktop_forbidden", "桌面授权已撤销")
return
}
if d.PowerState != "running" || d.Endpoint == "" {
s.fail(c, 409, "desktop_not_ready", "桌面当前不可连接")
return
}
var busy bool
if err = tx.GetContext(ctx, &busy, `SELECT EXISTS(SELECT 1 FROM terminals WHERE session_desktop_id=$1 AND id!=$2 AND last_seen>now()-interval '90 seconds')`, d.ID, row.TerminalID); err != nil {
s.dbError(c, err)
return
}
if busy {
s.fail(c, 409, "desktop_busy", "桌面已被其他终端占用")
return
}
sessionID := store.ID("session_")
_, err = tx.ExecContext(ctx, "UPDATE connection_tickets SET consumed_at=now() WHERE token_hash=$1", row.TokenHash)
if err == nil {
_, err = tx.ExecContext(ctx, "UPDATE terminals SET session_desktop_id=$1,last_seen=now(),session_id=$3,session_state='connecting' WHERE id=$2", d.ID, row.TerminalID, sessionID)
}
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"desktopId": d.ID, "sessionId": sessionID, "protocol": "moonlight", "endpoint": d.Endpoint, "app": d.App, "width": d.Width, "height": d.Height, "fps": d.FPS, "credentialMode": "existing-pairing"})
}
func (s *Server) deviceSession(c *gin.Context) {
var in struct {
SessionID string `json:"sessionId" binding:"required,max=100"`
State string `json:"state" binding:"required,oneof=connected ended"`
}
if !s.decode(c, &in) {
return
}
query := "UPDATE terminals SET session_state='connected',last_seen=now() WHERE id=$1 AND session_id=$2 AND session_desktop_id IS NOT NULL"
if in.State == "ended" {
query = "UPDATE terminals SET session_state='idle',session_id='',session_desktop_id=NULL WHERE id=$1 AND session_id=$2"
}
result, err := s.DB.ExecContext(c.Request.Context(), query, c.GetString("terminalId"), in.SessionID)
if err != nil {
s.dbError(c, err)
return
}
n, _ := result.RowsAffected()
if n == 0 {
s.fail(c, 409, "stale_session", "连接预约已结束或已被新会话替代")
return
}
c.JSON(200, gin.H{"accepted": true})
}
func (s *Server) taskDiagnostics(c *gin.Context) {
var data []byte
if err := s.DB.GetContext(c.Request.Context(), &data, "SELECT content FROM task_diagnostics WHERE task_id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
s.log(c, "read_diagnostics", c.Param("id"), "success")
c.Data(200, "application/json", data)
}

View File

@ -0,0 +1,59 @@
CREATE TABLE IF NOT EXISTS platform_metadata (
singleton boolean PRIMARY KEY DEFAULT true CHECK(singleton), mode text NOT NULL CHECK(mode IN ('demo','production'))
);
CREATE TABLE IF NOT EXISTS administrators (
id text PRIMARY KEY, username text NOT NULL UNIQUE, display_name text NOT NULL,
role text NOT NULL CHECK (role IN ('super_admin','operator','viewer')), password_hash text NOT NULL
);
CREATE TABLE IF NOT EXISTS admin_sessions (
id text PRIMARY KEY, admin_id text NOT NULL REFERENCES administrators(id), expires_at timestamptz NOT NULL
);
CREATE TABLE IF NOT EXISTS desktops (
id text PRIMARY KEY, name text NOT NULL, os text NOT NULL, node text NOT NULL, vmid integer NOT NULL,
pool text NOT NULL DEFAULT '', power_state text NOT NULL, service_state text NOT NULL DEFAULT 'unknown',
provider text NOT NULL CHECK (provider IN ('demo','proxmox')), endpoint text NOT NULL DEFAULT '', app text NOT NULL DEFAULT 'Desktop',
width integer NOT NULL DEFAULT 2560, height integer NOT NULL DEFAULT 1440, fps integer NOT NULL DEFAULT 30,
updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(provider,node,vmid)
);
CREATE TABLE IF NOT EXISTS terminals (
id text PRIMARY KEY, sn text NOT NULL UNIQUE, name text NOT NULL, group_name text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'unregistered', agent_version text NOT NULL DEFAULT '', last_seen timestamptz,
session_desktop_id text REFERENCES desktops(id), config_version integer NOT NULL DEFAULT 1,
bootstrap_hash text NOT NULL, token_hash text UNIQUE, fps double precision, rtt double precision
);
CREATE TABLE IF NOT EXISTS users (
id text PRIMARY KEY, username text NOT NULL UNIQUE, display_name text NOT NULL,
enabled boolean NOT NULL DEFAULT true, terminal_id text UNIQUE REFERENCES terminals(id)
);
CREATE TABLE IF NOT EXISTS bindings (
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
desktop_id text NOT NULL REFERENCES desktops(id), PRIMARY KEY(user_id,desktop_id)
);
CREATE TABLE IF NOT EXISTS resources (
id text PRIMARY KEY, name text NOT NULL, status text NOT NULL, cpu double precision NOT NULL,
memory double precision NOT NULL, storage double precision NOT NULL, provider text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tasks (
id text PRIMARY KEY, kind text NOT NULL, action text NOT NULL, target_id text NOT NULL, target_name text NOT NULL,
actor text NOT NULL, status text NOT NULL, message text NOT NULL DEFAULT '', provider_ref text NOT NULL DEFAULT '',
idempotency_key text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(actor,idempotency_key)
);
CREATE UNIQUE INDEX IF NOT EXISTS tasks_active_target ON tasks(kind,target_id) WHERE status IN ('queued','running');
CREATE TABLE IF NOT EXISTS audit_logs (
id text PRIMARY KEY, actor text NOT NULL, action text NOT NULL, target text NOT NULL, result text NOT NULL,
request_id text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS connection_tickets (
token_hash text PRIMARY KEY, terminal_id text NOT NULL REFERENCES terminals(id), desktop_id text NOT NULL REFERENCES desktops(id),
expires_at timestamptz NOT NULL, consumed_at timestamptz
);
CREATE INDEX IF NOT EXISTS audit_created ON audit_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS tasks_created ON tasks(created_at DESC);
ALTER TABLE terminals ADD COLUMN IF NOT EXISTS session_id text NOT NULL DEFAULT '';
ALTER TABLE terminals ADD COLUMN IF NOT EXISTS session_state text NOT NULL DEFAULT 'idle';
CREATE TABLE IF NOT EXISTS task_diagnostics (
task_id text PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
content jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);

View File

@ -0,0 +1,119 @@
package store
import (
"context"
"crypto/rand"
"crypto/sha256"
_ "embed"
"encoding/hex"
"fmt"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
"time"
"vdi-cloud/platform/internal/model"
)
//go:embed schema.sql
var schema string
func Open(ctx context.Context, url string) (*sqlx.DB, error) {
db, err := sqlx.ConnectContext(ctx, "pgx", url)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(12)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(30 * time.Minute)
if _, err = db.ExecContext(ctx, schema); err != nil {
db.Close()
return nil, err
}
return db, nil
}
func ID(prefix string) string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return prefix + hex.EncodeToString(b)
}
func Hash(secret string) string { h := sha256.Sum256([]byte(secret)); return hex.EncodeToString(h[:]) }
func Bootstrap(ctx context.Context, db *sqlx.DB, mode, username, password string) error {
if _, err := db.ExecContext(ctx, "INSERT INTO platform_metadata(singleton,mode) VALUES(true,$1) ON CONFLICT DO NOTHING", mode); err != nil {
return err
}
var existingMode string
if err := db.GetContext(ctx, &existingMode, "SELECT mode FROM platform_metadata WHERE singleton=true"); err != nil {
return err
}
if existingMode != mode {
return fmt.Errorf("database mode is %s; use a separate database for %s", existingMode, mode)
}
admins := []struct{ username, name, role, password string }{{username, "平台管理员", "super_admin", password}}
if mode == "demo" {
admins = []struct{ username, name, role, password string }{{"admin", "系统管理员", "super_admin", "Demo@2026"}, {"operator", "运维人员", "operator", "Demo@2026"}, {"viewer", "只读人员", "viewer", "Demo@2026"}}
}
for _, a := range admins {
var exists bool
if err := db.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM administrators WHERE username=$1)", a.username); err != nil {
return err
}
if exists {
continue
}
if mode == "production" && len(a.password) < 12 {
return fmt.Errorf("initial administrator password must contain at least 12 characters")
}
hash, err := bcrypt.GenerateFromPassword([]byte(a.password), bcrypt.DefaultCost)
if err != nil {
return err
}
if _, err = db.ExecContext(ctx, "INSERT INTO administrators VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING", ID("adm_"), a.username, a.name, a.role, string(hash)); err != nil {
return err
}
}
if mode != "demo" {
return nil
}
// Seed the demo only once; restarting must not restore revoked bindings.
var seeded bool
if err := db.GetContext(ctx, &seeded, "SELECT EXISTS(SELECT 1 FROM desktops WHERE provider='demo')"); err != nil {
return err
}
if seeded {
return nil
}
tx, err := db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
statements := []string{
`INSERT INTO desktops(id,name,os,node,vmid,pool,power_state,service_state,provider) VALUES ('desk_demo_windows','设计专属桌面','Windows','demo-node-a',201,'设计专属池','running','unknown','demo'),('desk_demo_ubuntu','研发办公桌面','Ubuntu','demo-node-a',202,'研发专属池','running','unknown','demo'),('desk_demo_kylin','国产办公桌面','麒麟','demo-node-a',203,'通用办公池','stopped','unknown','demo') ON CONFLICT DO NOTHING`,
`INSERT INTO terminals(id,sn,name,group_name,status,agent_version,last_seen,bootstrap_hash) VALUES ('term_demo_001','DEMO-RK-001','设计工位 01','设计部','offline','演示版本',now()-interval '2 hours','disabled'),('term_demo_002','DEMO-RK-002','研发工位 02','研发部','offline','演示版本',now()-interval '3 hours','disabled') ON CONFLICT DO NOTHING`,
`INSERT INTO users(id,username,display_name,terminal_id) VALUES ('user_demo_wang','wang.demo','王工','term_demo_001'),('user_demo_chen','chen.demo','陈工','term_demo_002') ON CONFLICT DO NOTHING`,
`INSERT INTO bindings VALUES ('user_demo_wang','desk_demo_windows'),('user_demo_wang','desk_demo_ubuntu'),('user_demo_chen','desk_demo_ubuntu') ON CONFLICT DO NOTHING`,
`INSERT INTO resources VALUES ('demo-node-a','demo-node-a','online',0.24,0.48,0.35,'demo',now()) ON CONFLICT DO NOTHING`,
}
for _, q := range statements {
if _, err = tx.ExecContext(ctx, q); err != nil {
return fmt.Errorf("demo seed: %w", err)
}
}
return tx.Commit()
}
func Users(ctx context.Context, db *sqlx.DB) ([]model.User, error) {
users := []model.User{}
if err := db.SelectContext(ctx, &users, "SELECT * FROM users ORDER BY username"); err != nil {
return nil, err
}
for i := range users {
users[i].DesktopIDs = []string{}
if err := db.SelectContext(ctx, &users[i].DesktopIDs, "SELECT desktop_id FROM bindings WHERE user_id=$1 ORDER BY desktop_id", users[i].ID); err != nil {
return nil, err
}
}
return users, nil
}

View File

@ -0,0 +1,146 @@
package proxmox
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
type Client struct {
BaseURL, TokenID, TokenSecret string
HTTP *http.Client
}
type Resource struct {
Type string `json:"type"`
Node string `json:"node"`
Name string `json:"name"`
Status string `json:"status"`
VMID int `json:"vmid"`
CPU float64 `json:"cpu"`
Memory float64 `json:"mem"`
MaxMemory float64 `json:"maxmem"`
Disk float64 `json:"disk"`
MaxDisk float64 `json:"maxdisk"`
Template int `json:"template"`
}
type TaskStatus struct {
Status string `json:"status"`
ExitStatus string `json:"exitstatus"`
}
var ErrTaskFailed = errors.New("PVE task failed; inspect task logs in PVE")
type HTTPError struct{ Status int }
func (e *HTTPError) Error() string { return fmt.Sprintf("PVE returned HTTP %d", e.Status) }
func New(base, id, secret, caFile string) (*Client, error) {
u, err := url.Parse(base)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return nil, errors.New("PVE_URL must be an HTTPS origin without credentials or query")
}
if u.Path != "" && u.Path != "/" {
return nil, errors.New("PVE_URL must not include a path")
}
pool, err := x509.SystemCertPool()
if err != nil {
pool = x509.NewCertPool()
}
if caFile != "" {
data, err := os.ReadFile(caFile)
if err != nil {
return nil, err
}
if !pool.AppendCertsFromPEM(data) {
return nil, errors.New("invalid PVE CA file")
}
}
return &Client{BaseURL: strings.TrimRight(base, "/"), TokenID: id, TokenSecret: secret, HTTP: &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: pool}}}}, nil
}
func (c *Client) request(ctx context.Context, method, path string, form url.Values, out any) error {
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+"/api2/json"+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "PVEAPIToken="+c.TokenID+"="+c.TokenSecret)
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
res, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("PVE request failed: %w", err)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return &HTTPError{Status: res.StatusCode}
}
var envelope struct {
Data json.RawMessage `json:"data"`
}
if err = json.NewDecoder(io.LimitReader(res.Body, 4<<20)).Decode(&envelope); err != nil {
return err
}
return json.Unmarshal(envelope.Data, out)
}
func (c *Client) Resources(ctx context.Context) ([]Resource, error) {
var list []Resource
err := c.request(ctx, "GET", "/cluster/resources", nil, &list)
return list, err
}
func (c *Client) Action(ctx context.Context, node string, vmid int, action string) (string, error) {
if action != "start" && action != "shutdown" && action != "reboot" {
return "", errors.New("unsupported VM action")
}
var upid string
err := c.request(ctx, "POST", fmt.Sprintf("/nodes/%s/qemu/%d/status/%s", url.PathEscape(node), vmid, action), url.Values{}, &upid)
if err == nil && upid == "" {
err = errors.New("PVE did not return a task ID")
}
return upid, err
}
func (c *Client) Task(ctx context.Context, node, upid string) (TaskStatus, error) {
var status TaskStatus
err := c.request(ctx, "GET", "/nodes/"+url.PathEscape(node)+"/tasks/"+url.PathEscape(upid)+"/status", nil, &status)
return status, err
}
func (c *Client) Power(ctx context.Context, node string, vmid int) (string, error) {
var status struct {
Status string `json:"status"`
}
err := c.request(ctx, "GET", fmt.Sprintf("/nodes/%s/qemu/%d/status/current", url.PathEscape(node), vmid), nil, &status)
return status.Status, err
}
func (c *Client) Wait(ctx context.Context, node, upid string, interval time.Duration) error {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
s, err := c.Task(ctx, node, upid)
if err != nil {
return err
}
if s.Status == "stopped" {
if s.ExitStatus != "OK" {
return ErrTaskFailed
}
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}

View File

@ -0,0 +1,77 @@
package proxmox
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestTLSAndTaskLifecycle(t *testing.T) {
polls := 0
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "PVEAPIToken=vdi@pve!console=secret" {
t.Error("missing PVE token")
}
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/status/start"):
if r.Method != "POST" {
t.Error("action should POST")
}
w.Write([]byte(`{"data":"UPID:node:123"}`))
case strings.HasSuffix(r.URL.Path, "/status/current"):
w.Write([]byte(`{"data":{"status":"running"}}`))
case strings.Contains(r.URL.Path, "/tasks/"):
polls++
if polls < 2 {
w.Write([]byte(`{"data":{"status":"running"}}`))
} else {
w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"OK"}}`))
}
default:
w.WriteHeader(404)
}
}))
defer ts.Close()
c, err := New(ts.URL, "vdi@pve!console", "secret", "")
if err != nil {
t.Fatal(err)
}
if _, err = c.Action(context.Background(), "node", 201, "start"); err == nil {
t.Fatal("untrusted PVE certificate must not be accepted")
}
c.HTTP = ts.Client()
upid, err := c.Action(context.Background(), "node", 201, "start")
if err != nil {
t.Fatal(err)
}
if err = c.Wait(context.Background(), "node", upid, time.Millisecond); err != nil {
t.Fatal(err)
}
if polls != 2 {
t.Fatalf("expected task polling; got %d", polls)
}
if _, err = c.Action(context.Background(), "node", 201, "restore"); err == nil {
t.Fatal("restore must never map to hard reset")
}
}
func TestFailedPVEJobIsNotSuccess(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"ERROR: disk full"}}`))
}))
defer ts.Close()
c := &Client{BaseURL: ts.URL, HTTP: ts.Client()}
if err := c.Wait(context.Background(), "node", "task", time.Millisecond); err == nil {
t.Fatal("failed PVE job reported as success")
}
}
func TestRejectInsecureOrigin(t *testing.T) {
for _, base := range []string{"http://pve:8006", "https://user:password@pve:8006", "https://pve:8006/other"} {
if _, err := New(base, "id", "secret", ""); err == nil {
t.Fatalf("accepted %s", base)
}
}
}

View File

@ -0,0 +1,98 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
const string = { type: 'string' }, integer = { type: 'integer' }, number = { type: 'number' }, boolean = { type: 'boolean' }
const date = { type: 'string', format: 'date-time' }
const ref = name => ({ $ref: `#/components/schemas/${name}` })
const array = items => ({ type: 'array', items })
const object = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false })
const schemas = {
Error: object({ code: string, message: string, requestId: string }),
Administrator: object({ id: string, username: string, displayName: string, role: { type: 'string', enum: ['super_admin', 'operator', 'viewer'] } }),
LoginInput: object({ username: string, password: string }),
LoginResult: object({ accessToken: string, expiresIn: integer, user: ref('Administrator') }),
Desktop: object({ id: string, name: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, pool: string, powerState: string, serviceState: string, provider: { type: 'string', enum: ['demo', 'proxmox'] }, endpoint: string, app: string, width: integer, height: integer, fps: integer, updatedAt: date }),
Terminal: object({ id: string, sn: string, name: string, group: string, status: string, agentVersion: string, lastSeen: { ...date, nullable: true }, sessionDesktopId: { ...string, nullable: true }, configVersion: integer, fps: { ...number, nullable: true }, rtt: { ...number, nullable: true } }),
User: object({ id: string, username: string, displayName: string, enabled: boolean, terminalId: { ...string, nullable: true }, desktopIds: array(string) }),
Resource: object({ id: string, name: string, status: string, cpu: number, memory: number, storage: number, provider: string, updatedAt: date }),
Task: object({ id: string, kind: string, action: string, targetId: string, targetName: string, actor: string, status: { type: 'string', enum: ['queued', 'running', 'success', 'failed', 'timeout'] }, message: string, providerRef: string, createdAt: date, updatedAt: date }),
Audit: object({ id: string, actor: string, action: string, target: string, result: string, requestId: string, createdAt: date }),
Overview: object({ terminalCount: integer, onlineTerminals: integer, desktopCount: integer, runningDesktops: integer, activeSessions: integer, failedTasks: integer, mode: string }),
Settings: object({ mode: { type: 'string', enum: ['demo', 'production'] }, computeProvider: string, pveConfigured: boolean, terminalTransport: string, capabilities: array(string) }),
ImportInput: object({ name: string, os: { type: 'string', enum: ['Windows', 'Ubuntu', '麒麟'] }, node: string, vmid: integer, endpoint: string, width: integer, height: integer, fps: integer }, ['name', 'os', 'node', 'vmid']),
DiscoveredVM: object({ name: string, node: string, vmid: integer, status: string }),
EnrollmentInput: object({ sn: string, name: string, group: string }),
Enrollment: object({ id: string, activationSecret: string }),
CreateUserInput: object({ username: string, displayName: string }),
BindingInput: object({ terminalId: { ...string, nullable: true }, desktopIds: array(string), enabled: boolean }),
DesktopAction: object({ action: { type: 'string', enum: ['start', 'shutdown', 'reboot'] } }),
TerminalAction: object({ action: { type: 'string', enum: ['reboot', 'reset-session', 'collect-logs'] } }),
RegisterInput: object({ sn: string, activationSecret: string, agentVersion: string }),
RegisterResult: object({ terminalId: string, accessToken: string, heartbeatInterval: integer }),
HeartbeatInput: object({ agentVersion: string, fps: { ...number, nullable: true }, rtt: { ...number, nullable: true } }, ['agentVersion']),
Accepted: object({ accepted: boolean }),
HeartbeatResult: object({ accepted: boolean, serverTime: date }),
DeviceConfig: object({ configVersion: integer, identityMode: string, commandTransport: string, heartbeatInterval: integer }),
TicketInput: object({ desktopId: string }),
Ticket: object({ ticket: string, desktopId: string, expiresAt: date, credentialMode: string }),
ConsumeInput: object({ ticket: string }),
Connection: object({ desktopId: string, sessionId: string, protocol: string, endpoint: string, app: string, width: integer, height: integer, fps: integer, credentialMode: string }),
DeviceDiagnostics: object({ agentVersion: string, bootId: string, uptimeSeconds: number, kioskState: string, logTail: string }),
SessionInput: object({ sessionId: string, state: { type: 'string', enum: ['connected', 'ended'] } }),
AckInput: object({ status: { type: 'string', enum: ['success', 'failed'] }, message: string, diagnostics: ref('DeviceDiagnostics') }, ['status']),
}
// Older frontends can omit this display field; the API always supplies it.
schemas.Terminal.properties.sessionState = string
for (const name of ['Desktop', 'Terminal', 'User', 'Resource', 'Task', 'Audit', 'DiscoveredVM']) {
schemas[`${name}List`] = object({ items: array(ref(name)), page: integer, pageSize: integer, total: integer, requestId: string })
}
const json = schema => ({ 'application/json': { schema } })
const operation = (id, output, { input, status = 200, list = false, param = false, command = false, security = 'adminBearer' } = {}) => ({
operationId: id,
security: security ? [{ [security]: [] }] : [],
...(input ? { requestBody: { required: true, content: json(ref(input)) } } : {}),
parameters: [
...(param ? [{ name: 'id', in: 'path', required: true, schema: string }] : []),
...(list ? ['page', 'pageSize', 'keyword', 'status'].map(name => ({ name, in: 'query', schema: name.startsWith('page') ? integer : string })) : []),
...(command ? [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', maxLength: 100 } }] : []),
],
responses: {
[status]: { description: 'Successful response', ...(output ? { content: json(ref(output)) } : {}) },
...Object.fromEntries([400, 401, 403, 404, 409, 500, 502, 503].map(code => [code, { description: 'Request failed', content: json(ref('Error')) }])),
},
})
const paths = {
'/auth/login': { post: operation('login', 'LoginResult', { input: 'LoginInput', security: null }) },
'/auth/me': { get: operation('me', 'Administrator') },
'/auth/logout': { post: operation('logout', null, { status: 204 }) },
'/overview': { get: operation('overview', 'Overview') },
'/settings': { get: operation('settings', 'Settings') },
'/resources': { get: operation('resources', 'ResourceList', { list: true }) },
'/resources/vms': { get: operation('discoverVMs', 'DiscoveredVMList', { list: true }) },
'/resources/sync': { post: operation('syncResources', 'Task', { status: 202, command: true }) },
'/desktops': { get: operation('desktops', 'DesktopList', { list: true }) },
'/desktops/import': { post: operation('importDesktop', 'Desktop', { input: 'ImportInput' }) },
'/desktops/{id}': { get: operation('desktop', 'Desktop', { param: true }) },
'/desktops/{id}/actions': { post: operation('desktopAction', 'Task', { input: 'DesktopAction', param: true, status: 202, command: true }) },
'/terminals': { get: operation('terminals', 'TerminalList', { list: true }), post: operation('enrollTerminal', 'Enrollment', { input: 'EnrollmentInput', status: 201 }) },
'/terminals/{id}/commands': { post: operation('terminalCommand', 'Task', { input: 'TerminalAction', param: true, status: 202, command: true }) },
'/users': { get: operation('users', 'UserList', { list: true }), post: operation('createUser', 'User', { input: 'CreateUserInput', status: 201 }) },
'/users/{id}/bindings': { put: operation('updateBindings', 'User', { input: 'BindingInput', param: true }) },
'/tasks': { get: operation('tasks', 'TaskList', { list: true }) },
'/tasks/{id}': { get: operation('task', 'Task', { param: true }) },
'/tasks/{id}/diagnostics': { get: operation('taskDiagnostics', 'DeviceDiagnostics', { param: true }) },
'/audit-logs': { get: operation('audits', 'AuditList', { list: true }) },
'/terminal/register': { post: operation('registerDevice', 'RegisterResult', { input: 'RegisterInput', security: null }) },
'/terminal/heartbeat': { post: operation('heartbeat', 'HeartbeatResult', { input: 'HeartbeatInput', security: 'deviceBearer' }) },
'/terminal/config': { get: operation('deviceConfig', 'DeviceConfig', { security: 'deviceBearer' }) },
'/terminal/desktops': { get: operation('deviceDesktops', 'DesktopList', { security: 'deviceBearer', list: true }) },
'/terminal/commands': { get: operation('deviceCommands', 'TaskList', { security: 'deviceBearer', list: true }) },
'/terminal/commands/{id}/ack': { post: operation('deviceAck', 'Accepted', { input: 'AckInput', param: true, security: 'deviceBearer' }) },
'/terminal/connect-ticket': { post: operation('createTicket', 'Ticket', { input: 'TicketInput', status: 201, security: 'deviceBearer' }) },
'/terminal/connect-ticket/consume': { post: operation('consumeTicket', 'Connection', { input: 'ConsumeInput', security: 'deviceBearer' }) },
'/terminal/session': { post: operation('deviceSession', 'Accepted', { input: 'SessionInput', security: 'deviceBearer' }) },
}
const output = { openapi: '3.0.3', info: { title: 'VDI 管理平台 API', version: '0.1.0', description: '第一阶段接口。平台票据不替代 Sunshine 配对;HTTP 202 只代表已提交。' }, servers: [{ url: '/api/v1' }], paths, components: { schemas, securitySchemes: { adminBearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, deviceBearer: { type: 'http', scheme: 'bearer', description: '设备激活后获得的独立令牌,不是管理员 JWT' } } } }
const directory = fileURLToPath(new URL('../docs/openapi/', import.meta.url))
mkdirSync(directory, { recursive: true })
writeFileSync(`${directory}/platform.v1.json`, `${JSON.stringify(output, null, 2)}\n`)
console.log('Updated docs/openapi/platform.v1.json')

View File

@ -0,0 +1,53 @@
"""Exercise device registration/heartbeat/command receipts without controlling real hardware."""
import argparse
import getpass
import json
import os
import time
from urllib.request import Request, urlopen
from urllib.error import HTTPError
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--server', default='http://127.0.0.1:8088')
parser.add_argument('--sn', required=True)
args = parser.parse_args()
server = args.server.rstrip('/')
token = ''
def request(path, body=None):
headers = {'Content-Type': 'application/json'}
if token:
headers['Authorization'] = 'Bearer ' + token
req = Request(server + path, data=json.dumps(body).encode() if body is not None else None, headers=headers)
try:
with urlopen(req, timeout=15) as response:
return json.load(response)
except HTTPError as error:
detail = json.load(error)
raise RuntimeError(detail.get('message', 'API request failed')) from None
if request('/healthz').get('mode') != 'demo':
raise SystemExit('This simulator only runs against VDI_MODE=demo.')
secret = os.environ.get('VDI_DEVICE_ACTIVATION_SECRET') or getpass.getpass('One-time activation secret: ')
result = request('/api/v1/terminal/register', {'sn': args.sn, 'activationSecret': secret, 'agentVersion': 'simulator-0.1'})
token = result['accessToken']
print('Registered terminal:', result['terminalId'])
print('Simulator sends heartbeat only; all requested device operations return failed.')
print('Press Ctrl+C to stop. No reboot, session change, or log collection is performed.')
while True:
request('/api/v1/terminal/heartbeat', {'agentVersion': 'simulator-0.1', 'fps': None, 'rtt': None})
desktops = request('/api/v1/terminal/desktops')['items']
print('Authorized desktops:', ', '.join(d['name'] for d in desktops) or '(none)')
for command in request('/api/v1/terminal/commands')['items']:
request('/api/v1/terminal/commands/' + command['id'] + '/ack', {'status': 'failed', 'message': 'Device simulator does not execute operations'})
print('Returned failed receipt for:', command['action'], command['id'])
time.sleep(10)
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
print('\nSimulator stopped.')

View File

@ -0,0 +1,43 @@
"""Generate private local configuration and a CA for the device HTTPS channel."""
import argparse
import datetime
import ipaddress
import json
import pathlib
import secrets
import os
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
p = argparse.ArgumentParser()
p.add_argument('--ip', required=True)
p.add_argument('--pve-hostname', required=True, help='DNS name present in the PVE certificate')
p.add_argument('--pve-ip', required=True)
args = p.parse_args()
ip = ipaddress.ip_address(args.ip)
root = pathlib.Path(__file__).resolve().parent.parent
target = root / '.local/live'
target.mkdir(parents=True, exist_ok=True)
if (target / '.env').exists():
raise RuntimeError('Existing configuration retained; edit it deliberately instead of regenerating secrets')
access = json.loads((target / 'pve-access.json').read_text())
access['PVE_URL']=f'https://{args.pve_hostname}:8006'
now = datetime.datetime.now(datetime.timezone.utc)
key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'VDI local device CA')])
ca = x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()).serial_number(x509.random_serial_number()).not_valid_before(now-datetime.timedelta(minutes=5)).not_valid_after(now+datetime.timedelta(days=3650)).add_extension(x509.BasicConstraints(ca=True,path_length=0),critical=True).add_extension(x509.KeyUsage(True,False,False,False,False,True,True,False,False),critical=True).sign(key,hashes.SHA256())
server_key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
server = x509.CertificateBuilder().subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,str(ip))])).issuer_name(name).public_key(server_key.public_key()).serial_number(x509.random_serial_number()).not_valid_before(now-datetime.timedelta(minutes=5)).not_valid_after(now+datetime.timedelta(days=365)).add_extension(x509.BasicConstraints(ca=False,path_length=None),critical=True).add_extension(x509.SubjectAlternativeName([x509.IPAddress(ip),x509.DNSName('localhost')]),critical=False).add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]),critical=False).sign(key,hashes.SHA256())
tls = target / 'tls'; tls.mkdir(exist_ok=True)
def save_private(path, data):
path.write_bytes(data); os.chmod(path, 0o600)
for name, obj in [('ca',key),('server',server_key)]:
save_private(tls/f'{name}-key.pem', obj.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()))
(tls/'ca.pem').write_bytes(ca.public_bytes(serialization.Encoding.PEM))
(tls/'server.pem').write_bytes(server.public_bytes(serialization.Encoding.PEM)+ca.public_bytes(serialization.Encoding.PEM))
env = dict(VDI_DB_PASSWORD=secrets.token_hex(24),VDI_JWT_SECRET=secrets.token_hex(48),VDI_ADMIN_USERNAME='admin',VDI_ADMIN_PASSWORD=secrets.token_urlsafe(24),VDI_LIVE_BIND_IP=str(ip),PVE_HOSTNAME=args.pve_hostname,PVE_HOST_IP=str(ipaddress.ip_address(args.pve_ip)),**access)
save_private(target/'.env', ''.join(f'{k}={v}\n' for k,v in env.items()).encode())
save_private(target/'admin-login.txt', f'Console: http://127.0.0.1:5182\nUsername: {env["VDI_ADMIN_USERNAME"]}\nPassword: {env["VDI_ADMIN_PASSWORD"]}\nDevice HTTPS: https://{ip}:8443\n'.encode())
print('Private environment, admin login, CA and HTTPS certificate generated in .local/live')

View File

@ -0,0 +1,62 @@
"""Create scoped VDI API access through a known-host-verified SSH connection.
Requires paramiko. Password is prompted or read from VDI_PVE_SSH_PASSWORD.
The one-time API secret is written only to the requested private output file.
"""
import argparse
import getpass
import json
import os
import pathlib
import shlex
import paramiko
def main():
p = argparse.ArgumentParser()
p.add_argument('--host', required=True)
p.add_argument('--vmids', nargs='+', type=int, required=True)
p.add_argument('--output', required=True)
args = p.parse_args()
target = pathlib.Path(args.output)
if target.exists():
raise RuntimeError('Output already exists; reuse the existing token rather than replace it')
ssh = paramiko.SSHClient()
ssh.load_system_host_keys(str(pathlib.Path.home() / '.ssh/known_hosts'))
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
password = os.environ.get('VDI_PVE_SSH_PASSWORD') or getpass.getpass('PVE SSH password: ')
ssh.connect(args.host, username='root', password=password, look_for_keys=False, allow_agent=False, timeout=10)
def run(*parts):
_, out, err = ssh.exec_command(shlex.join(parts), timeout=30)
body = out.read().decode()
if out.channel.recv_exit_status() != 0:
raise RuntimeError(err.read().decode())
return body
try:
user, role, token = 'vdi-cloud@pve', 'VDICloudPower', 'console'
users = json.loads(run('pveum', 'user', 'list', '--output-format', 'json'))
if any(u['userid'] == user for u in users):
raise RuntimeError('VDI account already exists. Inspect it and recover the existing token first')
roles = json.loads(run('pveum', 'role', 'list', '--output-format', 'json'))
if not any(r['roleid'] == role for r in roles):
run('pveum', 'role', 'add', role, '--privs', 'VM.Audit VM.PowerMgmt')
elif set(next(r for r in roles if r['roleid'] == role)['privs'].split(',')) != {'VM.Audit','VM.PowerMgmt'}:
raise RuntimeError('Existing VDICloudPower role has unexpected privileges; inspect it first')
run('pveum', 'user', 'add', user, '--comment', 'VDI cloud API; scoped power management')
run('pveum', 'acl', 'modify', '/', '--users', user, '--roles', 'PVEAuditor')
for vmid in args.vmids:
run('pveum', 'acl', 'modify', f'/vms/{vmid}', '--users', user, '--roles', role)
result = json.loads(run('pveum', 'user', 'token', 'add', user, token, '--privsep', '0', '--output-format', 'json'))
target.parent.mkdir(parents=True, exist_ok=True)
with target.open('x', encoding='utf-8') as f:
json.dump({'PVE_URL': f'https://{args.host}:8006', 'PVE_TOKEN_ID': result['full-tokenid'], 'PVE_TOKEN_SECRET': result['value']}, f)
os.chmod(target, 0o600)
with ssh.open_sftp() as sftp:
sftp.get('/etc/pve/pve-root-ca.pem', str(target.parent / 'pve-ca.pem'))
print(f'Created dedicated account and VM power scopes {args.vmids}; credentials saved privately')
finally:
ssh.close()
if __name__ == '__main__':
main()

42
tools/start-cloud-dev.ps1 Normal file
View File

@ -0,0 +1,42 @@
param([switch]$SkipInstall, [int]$ApiPort=8088, [int]$ConsolePort=5180)
$ErrorActionPreference = 'Stop'
$vdiWorkspace = Split-Path -Parent $PSScriptRoot
$vdiLocalDir = Join-Path $vdiWorkspace '.local'
New-Item -ItemType Directory -Force -Path $vdiLocalDir | Out-Null
$vdiGoCommand = Get-Command go -ErrorAction SilentlyContinue
$vdiGoPath = if ($vdiGoCommand) { $vdiGoCommand.Source } else { Join-Path $env:LOCALAPPDATA 'vdi-cloud-dev\go\bin\go.exe' }
if (!(Test-Path -LiteralPath $vdiGoPath)) { throw 'Go 1.25+ is required. Install Go and add it to PATH.' }
foreach ($vdiPort in @($ApiPort,$ConsolePort)) {
if (Get-NetTCPConnection -State Listen -LocalPort $vdiPort -ErrorAction SilentlyContinue) { throw "Port $vdiPort is already in use. Stop the existing development instance before starting another." }
}
Push-Location $vdiWorkspace
try {
docker compose -f deploy/compose.dev.yml up -d postgres
if ($LASTEXITCODE -ne 0) { throw 'PostgreSQL startup failed' }
$vdiDatabaseReady = $false
for ($vdiAttempt = 0; $vdiAttempt -lt 40; $vdiAttempt++) {
docker exec vdi-cloud-dev-postgres-1 pg_isready -U vdi -d vdi 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) { $vdiDatabaseReady = $true; break }
Start-Sleep -Milliseconds 500
}
if (!$vdiDatabaseReady) { throw 'PostgreSQL did not become ready' }
Push-Location platform
try { & $vdiGoPath build -o (Join-Path $vdiLocalDir 'vdi-api.exe') ./cmd/api-gateway; if ($LASTEXITCODE -ne 0) { throw 'API build failed' } } finally { Pop-Location }
if (!$SkipInstall) { Push-Location console; try { npm.cmd ci; if ($LASTEXITCODE -ne 0) { throw 'Console install failed' } } finally { Pop-Location } }
$vdiEnvironment = @{
VDI_MODE = 'demo'; VDI_LISTEN = "127.0.0.1:$ApiPort";
DATABASE_URL = 'postgres://vdi:vdi-local-demo@127.0.0.1:55438/vdi?sslmode=disable';
VDI_JWT_SECRET = 'local-demo-only-change-before-any-production-use-2026';
PVE_URL = ''; PVE_TOKEN_ID = ''; PVE_TOKEN_SECRET = ''; PVE_CA_FILE = ''; VITE_API_MODE = 'api'; VDI_API_PROXY = "http://127.0.0.1:$ApiPort"
}
$vdiOriginalEnvironment = @{}
try {
foreach ($vdiKey in $vdiEnvironment.Keys) { $vdiOriginalEnvironment[$vdiKey] = [Environment]::GetEnvironmentVariable($vdiKey,'Process'); [Environment]::SetEnvironmentVariable($vdiKey,$vdiEnvironment[$vdiKey],'Process') }
$vdiAPIProcess = Start-Process -FilePath (Join-Path $vdiLocalDir 'vdi-api.exe') -WorkingDirectory $vdiWorkspace -WindowStyle Hidden -RedirectStandardOutput (Join-Path $vdiLocalDir 'api.stdout.log') -RedirectStandardError (Join-Path $vdiLocalDir 'api.stderr.log') -PassThru
$vdiConsoleProcess = Start-Process -FilePath 'cmd.exe' -ArgumentList '/d','/c',"npm.cmd run dev -- --port $ConsolePort" -WorkingDirectory (Join-Path $vdiWorkspace 'console') -WindowStyle Hidden -RedirectStandardOutput (Join-Path $vdiLocalDir 'console.stdout.log') -RedirectStandardError (Join-Path $vdiLocalDir 'console.stderr.log') -PassThru
@{ apiPid = $vdiAPIProcess.Id; consolePid = $vdiConsoleProcess.Id; startedAt = (Get-Date).ToString('o') } | ConvertTo-Json | Set-Content (Join-Path $vdiLocalDir 'cloud-dev.json')
} finally { foreach ($vdiKey in $vdiOriginalEnvironment.Keys) { [Environment]::SetEnvironmentVariable($vdiKey,$vdiOriginalEnvironment[$vdiKey],'Process') } }
Write-Host "Console: http://127.0.0.1:$ConsolePort"
Write-Host 'Demo users: admin / operator / viewer; password: Demo@2026'
Write-Host 'Development process IDs and logs: .local/'
} finally { Pop-Location }

View File

@ -0,0 +1,13 @@
param([switch]$Build)
$ErrorActionPreference='Stop'
$vdiRoot=Split-Path -Parent $PSScriptRoot
Push-Location $vdiRoot
try {
if (!(Test-Path -LiteralPath '.local/live/.env')) { throw 'Configure PVE access and run tools/prepare-live-config.py first. See docs/cloud-live-integration.md.' }
$vdiArgs=@('compose','--env-file','.local/live/.env','-f','deploy/compose.live.yml','up','-d')
if ($Build) { $vdiArgs+='--build' }
& docker @vdiArgs
if ($LASTEXITCODE -ne 0) { throw 'Live platform startup failed' }
Write-Host 'Real platform: http://127.0.0.1:5182'
Write-Host 'Private initial administrator credentials: .local/live/admin-login.txt'
} finally { Pop-Location }