功能:完善通用云管工作台、审计与诊断能力

This commit is contained in:
宋一鸣 2026-11-11 18:46:16 +08:00
parent 5ac692724d
commit 9f05aa20ea
225 changed files with 14266 additions and 141 deletions

View File

@ -0,0 +1,89 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { fail?: boolean; deny?: boolean; slow?: boolean } = {}) {
const revoked: string[] = [], queries: string[] = []
const now = new Date().toISOString(), expires = new Date(Date.now() + 3600000).toISOString()
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'session-fixture'))
await page.route('**/api/v1/**', async route => {
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '')
const list = (items: unknown[], total = items.length) => route.fulfill({ json: { items, total, page: 1, pageSize: 20, requestId: 'sessions-ui' } })
if (path === '/auth/me') { await route.fulfill({ json: { id: 'owner', username: 'root', displayName: '管理员', role: 'super_admin' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['administrator-sessions'], pveConfigured: true } }); return }
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
if (path === '/administrators') { await list([{ id: 'owner', username: 'root', displayName: '管理员', role: 'super_admin', enabled: true }, { id: 'other', username: 'maintainer', displayName: '运维', role: 'operator', enabled: true }]); return }
const match = path.match(/^\/administrators\/(owner|other)\/sessions(?:\/(.+))?$/)
if (match) {
const id = match[1]!, key = match[2]
if (request.method() === 'DELETE') {
revoked.push(`${id}/${key}`)
if (options.deny) { await route.fulfill({ status: 403, json: { code: 'forbidden', message: '当前账号没有该操作权限' } }); return }
await route.fulfill({ status: 204 }); return
}
queries.push(id)
if (options.fail && id === 'other') { await route.fulfill({ status: 500, json: { code: 'database_error', message: '登录列表暂不可用' } }); return }
if (options.slow && id === 'owner') await new Promise(resolve => setTimeout(resolve, 700))
const p = Number(url.searchParams.get('page') || 1), size = 20, total = 21 - revoked.filter(r => r.startsWith(`${id}/`)).length
await list(Array.from({ length: Math.max(0, Math.min(size, total - (p - 1) * size)) }, (_, i) => ({ key: `${id}-${(p - 1) * size + i}`, createdAt: i === 1 ? null : now, expiresAt: expires, clientIp: i === 1 ? '' : `192.0.2.${(p - 1) * size + i + 1}`, userAgent: 'Example browser <script>alert(1)</script>', current: id === 'owner' && p === 1 && i === 0 })), total); return
}
if (['/resources', '/desktops', '/users', '/terminals', '/tasks'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { message: '测试接口未定义' } })
})
await page.goto('/settings'); await page.getByRole('button', { name: '管理员账号', exact: true }).click()
await expect(page.getByRole('heading', { name: '管理员账号', exact: true })).toBeVisible()
return { revoked, queries }
}
async function open(page: Page, name: string) {
await page.getByRole('row').filter({ hasText: name }).getByRole('button', { name: '在线登录', exact: true }).click()
return page.getByRole('dialog', { name: `在线登录 · ${name}`, exact: true })
}
test('登录来源、旧登录未知信息、分页、当前登录保护及深浅窄屏', async ({ page }, info) => {
const calls = await fixture(page), dialog = await open(page, 'root')
await expect(dialog.getByText('当前登录', { exact: true })).toBeVisible()
await expect(dialog.getByRole('row').filter({ hasText: '当前登录' }).getByRole('button', { name: '撤销登录' })).toBeDisabled()
await expect(dialog.getByText('升级前登录,时间未知', { exact: true })).toBeVisible()
await expect(dialog.getByText('升级前登录,来源未知', { exact: true })).toBeVisible()
await page.screenshot({ path: info.outputPath('admin-sessions-dark.png'), fullPage: true, animations: 'disabled' })
await dialog.locator('.el-pagination .btn-next').click(); await expect(dialog.getByText('192.0.2.21', { exact: true })).toBeVisible()
await dialog.getByRole('button', { name: '关闭', exact: true }).click()
await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 })
await open(page, 'root'); await expect(dialog.getByText('当前登录', { exact: true })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await page.screenshot({ path: info.outputPath('admin-sessions-mobile.png'), fullPage: true, animations: 'disabled' })
expect(calls.revoked).toEqual([])
})
test('撤销指定登录需确认,取消不提交,成功刷新', async ({ page }) => {
const calls = await fixture(page), dialog = await open(page, 'maintainer')
await expect(dialog.getByText('共 21 次有效登录', { exact: true })).toBeVisible()
await dialog.getByRole('button', { name: '撤销登录', exact: true }).first().click()
await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click()
expect(calls.revoked).toEqual([])
await dialog.getByRole('button', { name: '撤销登录', exact: true }).first().click()
await page.locator('.el-message-box').getByRole('button', { name: '确认提交', exact: true }).click()
await expect(dialog.getByText('共 20 次有效登录', { exact: true })).toBeVisible()
expect(calls.revoked).toEqual(['other/other-0'])
})
test('查询和撤销失败清除旧结果并显示错误,不误报成功', async ({ page }) => {
const calls = await fixture(page, { deny: true }), dialog = await open(page, 'maintainer')
await dialog.getByRole('button', { name: '撤销登录', exact: true }).first().click()
await page.locator('.el-message-box').getByRole('button', { name: '确认提交', exact: true }).click()
await expect(dialog.getByText('当前账号没有该操作权限', { exact: true })).toBeVisible()
await expect(dialog.getByRole('button', { name: '撤销登录', exact: true })).toHaveCount(0)
expect(calls.revoked).toHaveLength(1)
await page.unrouteAll(); await fixture(page, { fail: true }); await open(page, 'maintainer')
await expect(page.getByRole('dialog').getByText('登录列表暂不可用', { exact: true })).toBeVisible()
await expect(page.getByRole('dialog').getByRole('button', { name: '撤销登录', exact: true })).toHaveCount(0)
})
test('切换账号后旧请求不能覆盖新的登录列表', async ({ page }) => {
const calls = await fixture(page, { slow: true }), old = await open(page, 'root')
await expect.poll(() => calls.queries.includes('owner')).toBe(true)
await old.getByRole('button', { name: '关闭', exact: true }).click()
const current = await open(page, 'maintainer')
await expect(current.getByText('共 21 次有效登录', { exact: true })).toBeVisible()
await page.waitForTimeout(850)
await expect(current.getByText('当前登录', { exact: true })).toHaveCount(0)
expect(calls.revoked).toEqual([])
})

View File

@ -0,0 +1,92 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { fail?: boolean; oversized?: boolean; exportFail?: boolean } = {}) {
const queries: URLSearchParams[] = [], exports: URLSearchParams[] = [], writes: string[] = []
const now = new Date().toISOString()
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'audit-fixture'))
await page.route('**/api/v1/**', async route => {
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '')
const list = (items: unknown[], extra = {}) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'audit-ui', ...extra } })
if (request.method() !== 'GET') { writes.push(path); await route.fulfill({ status: 403, json: { message: '不应发送写请求' } }); return }
if (path === '/auth/me') { await route.fulfill({ json: { id: 'reader', username: 'reader', displayName: '只读人员', role: 'viewer' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['audit'], pveConfigured: false } }); return }
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
if (path === '/audit-logs/export') {
exports.push(url.searchParams)
if (options.exportFail) { await route.fulfill({ status: 413, json: { message: '匹配记录超过 10000 条,请缩小时间范围', code: 'export_too_large', requestId: 'audit-ui' } }); return }
await route.fulfill({ contentType: 'text/csv; charset=utf-8', body: '\ufeff时间,操作者\r\n2026-11-11T00:00:00Z,root\r\n' }); return
}
if (path === '/audit-logs') {
queries.push(url.searchParams)
if (options.fail && url.searchParams.get('keyword')) { await route.fulfill({ status: 500, json: { message: '审计查询暂不可用', code: 'database_error', requestId: 'audit-ui' } }); return }
const p = Number(url.searchParams.get('page') || 1), filtered = !!url.searchParams.get('actor'), total = options.oversized ? 10001 : filtered ? 1 : 45
const items = Array.from({ length: Math.min(20, total - (p - 1) * 20) }, (_, i) => ({ id: `audit-${(p - 1) * 20 + i + 1}`, actor: filtered ? 'operator_%' : 'root', action: 'save_alert_rule', target: `node-${(p - 1) * 20 + i + 1}`, result: 'success', requestId: `req-${p}-${i}`, createdAt: now }))
await list(items, { total, page: p, pageSize: 20 }); return
}
if (['/resources', '/desktops', '/users', '/terminals', '/tasks'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { message: '未定义的测试接口' } })
})
return { queries, exports, writes }
}
test('按需加载审计,真实分页参数和固定截止时间,浅色移动布局', async ({ page }, info) => {
const calls = await fixture(page); await page.goto('/tasks')
await expect(page.getByRole('tab', { name: '执行任务' })).toBeVisible()
expect(calls.queries).toHaveLength(0)
await page.getByRole('tab', { name: '操作审计' }).click()
await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
await page.locator('.el-pagination .btn-next').click()
await expect(page.getByRole('cell', { name: 'node-21', exact: true })).toBeVisible()
expect(calls.queries[calls.queries.length - 1]?.get('page')).toBe('2')
expect(calls.queries[calls.queries.length - 1]?.get('to')).toBe(calls.queries[0]?.get('to'))
await page.screenshot({ path: info.outputPath('audit-dark.png'), fullPage: true, animations: 'disabled' })
await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 })
await page.screenshot({ path: info.outputPath('audit-mobile-light.png'), fullPage: true, animations: 'disabled' })
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
expect(calls.writes).toEqual([])
})
test('操作人、动作、结果与时间筛选传到后端,导出使用已查询条件而非未提交的输入', async ({ page }) => {
const calls = await fixture(page); await page.goto('/tasks'); await page.getByRole('tab', { name: '操作审计' }).click()
await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
await page.getByRole('textbox', { name: '审计操作者', exact: true }).fill('operator_%')
await page.getByRole('textbox', { name: '审计关键词' }).fill('req_trace')
await page.getByRole('combobox', { name: '审计动作' }).click(); await page.getByRole('option', { name: '保存告警规则', exact: true }).click()
await page.getByRole('combobox', { name: '审计结果' }).click(); await page.getByRole('option', { name: '成功', exact: true }).click()
await page.getByRole('combobox', { name: '审计时间范围' }).first().click(); await page.getByRole('button', { name: '最近 7 天', exact: true }).click()
await page.getByRole('button', { name: '查询审计', exact: true }).click()
await expect(page.getByRole('cell', { name: 'operator_%', exact: true })).toBeVisible()
const q = calls.queries[calls.queries.length - 1]!
expect(Object.fromEntries(q)).toMatchObject({ actor: 'operator_%', keyword: 'req_trace', action: 'save_alert_rule', result: 'success', page: '1', pageSize: '20' })
expect(q.get('from')).toBeTruthy(); expect(q.get('to')).toBeTruthy()
await page.getByRole('textbox', { name: '审计关键词' }).fill('还未查询的输入')
const downloaded = page.waitForEvent('download'); await page.getByRole('button', { name: '导出查询结果 CSV' }).click()
expect((await downloaded).suggestedFilename()).toMatch(/操作审计.*\.csv$/)
expect(Object.fromEntries(calls.exports[0]!)).toMatchObject({ actor: 'operator_%', keyword: 'req_trace', action: 'save_alert_rule', result: 'success', from: q.get('from'), to: q.get('to') })
expect(calls.exports[0]?.get('page')).toBeNull()
await page.getByRole('button', { name: '重置', exact: true }).click()
await expect(page.getByRole('cell', { name: 'root', exact: true }).first()).toBeVisible()
expect(calls.queries[calls.queries.length - 1]?.get('actor')).toBe('')
expect(calls.writes).toEqual([])
})
test('查询失败清除旧结果并禁用导出,允许重试', async ({ page }) => {
const calls = await fixture(page, { fail: true }); await page.goto('/tasks'); await page.getByRole('tab', { name: '操作审计' }).click()
await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
await page.getByRole('textbox', { name: '审计关键词' }).fill('failure'); await page.getByRole('button', { name: '查询审计', exact: true }).click()
await expect(page.getByText(/审计查询暂不可用/)).toBeVisible(); await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: '导出查询结果 CSV' })).toBeDisabled()
await page.getByRole('button', { name: '重置', exact: true }).click(); await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
expect(calls.exports).toHaveLength(0)
})
test('导出超量有明确提示,服务端导出错误保留查询结果', async ({ page }) => {
await fixture(page, { oversized: true }); await page.goto('/tasks'); await page.getByRole('tab', { name: '操作审计' }).click()
await expect(page.getByText(/匹配记录超过 10000 条/)).toBeVisible()
await expect(page.getByRole('button', { name: '导出查询结果 CSV' })).toBeDisabled()
await page.unrouteAll(); await fixture(page, { exportFail: true }); await page.reload(); await page.getByRole('tab', { name: '操作审计' }).click()
await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
await page.getByRole('button', { name: '导出查询结果 CSV' }).click()
await expect(page.getByText(/匹配记录超过 10000 条/)).toBeVisible()
await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible()
})

View File

@ -0,0 +1,99 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { role?: string; queryError?: boolean; exportError?: boolean; slow?: boolean; offline?: boolean; pending?: boolean; collectError?: boolean } = {}) {
const queries: URLSearchParams[] = [], exports: URLSearchParams[] = [], writes: { path: string; key: string | undefined; body: unknown }[] = []
let collected = false, attempts = 0
const now = new Date().toISOString()
const task = () => ({ id: 'collect-1', kind: 'terminal', action: 'collect-logs', targetId: 't1', targetName: '同名盒子', actor: 'operator', status: collected ? 'success' : 'running', message: collected ? '日志已回传' : '等待设备回执', providerRef: '', createdAt: now, updatedAt: now })
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'diagnostics-fixture'))
await page.route('**/api/v1/**', async route => {
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '')
const list = (items: unknown[]) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100 } })
if (request.method() !== 'GET') {
writes.push({ path, key: request.headers()['idempotency-key'], body: request.postDataJSON() })
if (path === '/terminals/t1/commands') {
attempts++
if (options.collectError && attempts === 1) { await route.fulfill({ status: 503, json: { message: '提交结果尚未确认', code: 'unavailable', requestId: 'diag-ui' } }); return }
collected = true; await route.fulfill({ status: 202, json: { ...task(), status: 'queued', message: '采集命令已提交' } }); return
}
await route.abort(); return
}
if (path === '/auth/me') { await route.fulfill({ json: { id: 'operator', username: 'operator', displayName: '运维', role: options.role || 'operator' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: [] } }); return }
if (path === '/overview') { await route.fulfill({ json: { activeSessions: 1 } }); return }
if (path === '/desktops') { await list([{ id: 'd1', name: '办公桌面', node: 'pve', vmid: 100, powerState: 'running', serviceState: 'unknown', os: 'Windows' }]); return }
if (path === '/terminals') { await list(['t1', 't2'].map((id, i) => ({ id, name: '同名盒子', sn: `SN-${i + 1}`, status: options.offline ? 'offline' : 'online', enabled: true, lastSeen: now, sessionDesktopId: null }))); return }
if (path === '/resources' || path === '/users') { await list([]); return }
if (path === '/tasks') { await list(collected || options.pending ? [options.pending ? { ...task(), status: 'timeout' } : task()] : []); return }
if (path === '/diagnostics/export') {
exports.push(url.searchParams)
if (options.exportError) { await route.fulfill({ status: 413, json: { code: 'export_too_large', message: '诊断事件超过 2000 条', requestId: 'diag-ui' } }); return }
await route.fulfill({ contentType: 'application/zip', body: 'fixture-download' }); return
}
if (path === '/diagnostics') {
queries.push(url.searchParams)
if (options.queryError && url.searchParams.get('source')) { await route.fulfill({ status: 500, json: { message: '诊断查询暂不可用', requestId: 'diag-ui' } }); return }
const target = url.searchParams.get('targetId') || 't1', kind = url.searchParams.get('targetType') || 'terminal', source = url.searchParams.get('source') || '', p = Number(url.searchParams.get('page') || 1)
if (options.slow && target === 't1') await new Promise(resolve => setTimeout(resolve, 600))
const items = [{ id: `event-${p}`, source: 'logs', time: now, action: 'collect-logs', status: 'success', targetId: target, targetName: target === 't2' ? '第二个同名盒子' : '同名盒子', actor: 'operator', message: target === 't2' ? '第二个设备独立记录' : '终端已回传的日志片段', details: JSON.stringify({ taskId: `task-${p}`, logTail: '<img src=x onerror="window.diagnosticInjected=true">\nnetwork error\n[REDACTED]', bootId: 'test' }) }]
await route.fulfill({ json: { subject: { kind, id: target, name: kind === 'desktop' ? '办公桌面' : target === 't2' ? '第二个同名盒子' : '同名盒子', state: options.offline ? 'offline' : kind === 'desktop' ? 'running' : 'online', node: kind === 'desktop' ? 'pve' : '', vmid: kind === 'desktop' ? 100 : 0, lastSeen: now, agentVersion: 'test' }, from: url.searchParams.get('from') || now, to: url.searchParams.get('to') || now, observedAt: now, items, total: source ? 1 : 25, page: p, pageSize: 20, source, counts: { tasks: 10, sessions: 10, logs: 3, alerts: 1, audits: 1 }, notes: ['按唯一标识关联;无记录不代表设备无故障。', '日志为设备回传片段,当前快照独立于历史时间范围。'] } }); return
}
await route.fulfill({ status: 404, json: { message: '未定义的测试接口' } })
})
return { queries, exports, writes }
}
test('按对象显式查询,关联来源与详情以纯文本展示,支持分页', async ({ page }) => {
const calls = await fixture(page); await page.goto('/diagnostics')
await expect(page.getByText('选择需要排查的设备或桌面,再查询诊断记录')).toBeVisible(); expect(calls.queries).toHaveLength(0)
await page.getByRole('combobox', { name: '诊断对象', exact: true }).click(); await page.getByRole('option', { name: '同名盒子 / SN-1', exact: true }).click(); await page.getByRole('button', { name: '查询诊断', exact: true }).click()
await expect(page.getByRole('heading', { name: '关联事件', exact: true })).toBeVisible(); expect(calls.queries.slice(-1)[0]?.get('targetId')).toBe('t1')
await page.getByText('查看关联标识与详情', { exact: true }).click(); await expect(page.locator('.diagnosis-log')).toContainText('<img src=x'); expect(await page.evaluate(() => (window as Window & { diagnosticInjected?: boolean }).diagnosticInjected)).toBeUndefined()
await expect(page.locator('.diagnosis-event img')).toHaveCount(0)
const cutoff = calls.queries.slice(-1)[0]?.get('to'); await page.locator('.el-pagination .btn-next').click(); await expect.poll(() => calls.queries.slice(-1)[0]?.get('page')).toBe('2'); expect(calls.queries.slice(-1)[0]?.get('to')).toBe(cutoff)
expect(calls.writes).toHaveLength(0)
})
test('导出沿用已查询条件,修改筛选立即清除旧记录并禁用导出', async ({ page }) => {
const calls = await fixture(page); await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await expect(page.getByRole('heading', { name: '关联事件' })).toBeVisible()
const applied = calls.queries.slice(-1)[0]!, download = page.waitForEvent('download'); await page.getByRole('button', { name: '导出诊断包', exact: true }).click(); expect((await download).suggestedFilename()).toContain('.zip')
expect(calls.exports[0]?.get('targetId')).toBe(applied.get('targetId')); expect(calls.exports[0]?.get('to')).toBe(applied.get('to')); expect(calls.exports[0]?.get('page')).toBeNull()
await page.locator('.diagnosis-query > .el-select').last().click(); await page.getByRole('option', { name: '监控告警', exact: true }).click(); await expect(page.getByRole('heading', { name: '关联事件' })).toHaveCount(0); await expect(page.getByRole('button', { name: '导出诊断包' })).toBeDisabled()
await page.getByRole('button', { name: '查询诊断', exact: true }).click(); await expect(page.getByRole('heading', { name: '关联事件' })).toBeVisible(); expect(calls.queries.slice(-1)[0]?.get('source')).toBe('alerts')
})
test('查询失败清除结果,导出超量显示后台错误', async ({ page }) => {
const calls = await fixture(page, { queryError: true, exportError: true }); await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await expect(page.getByRole('heading', { name: '关联事件' })).toBeVisible()
await page.getByRole('button', { name: '导出诊断包' }).click(); await expect(page.getByText(/诊断事件超过 2000 条/)).toBeVisible()
await page.locator('.diagnosis-query > .el-select').last().click(); await page.getByRole('option', { name: '执行任务', exact: true }).click(); await page.getByRole('button', { name: '查询诊断', exact: true }).click(); await expect(page.getByText(/诊断查询暂不可用/)).toBeVisible(); await expect(page.getByRole('heading', { name: '关联事件' })).toHaveCount(0); await expect(page.getByRole('button', { name: '导出诊断包' })).toBeDisabled(); expect(calls.writes).toHaveLength(0)
})
test('切换对象后较晚返回的旧查询不能覆盖新对象', async ({ page }) => {
await fixture(page, { slow: true }); await page.goto('/diagnostics?targetType=terminal&targetId=t1')
await page.getByRole('combobox', { name: '诊断对象', exact: true }).click(); await page.getByRole('option', { name: '同名盒子 / SN-2', exact: true }).click(); await page.getByRole('button', { name: '查询诊断', exact: true }).click()
await expect(page.getByRole('heading', { name: '第二个同名盒子', exact: true })).toBeVisible(); await expect(page.getByText('第二个设备独立记录')).toBeVisible(); await expect(page.getByRole('heading', { name: '同名盒子', exact: true })).toHaveCount(0)
})
test('采集先确认,取消不提交,提交与回执分开,结果失败可复用原幂等标识', async ({ page }) => {
const calls = await fixture(page, { collectError: true }); await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await page.getByRole('button', { name: '收集此设备日志' }).click()
const confirm = page.locator('.el-message-box'); await expect(confirm).toContainText('不会重启设备'); await confirm.getByRole('button', { name: '取消', exact: true }).click(); expect(calls.writes).toHaveLength(0)
await page.getByRole('button', { name: '收集此设备日志' }).click(); await confirm.getByRole('button', { name: '确认提交' }).click(); await expect(page.getByText(/提交结果尚未确认/)).toBeVisible()
await page.getByRole('button', { name: '收集此设备日志' }).click(); await confirm.getByRole('button', { name: '确认提交' }).click(); await expect(page.getByText('本次日志采集 · 同名盒子')).toBeVisible(); await expect(page.getByRole('button', { name: '查看本次采集' })).toBeVisible()
expect(calls.writes).toHaveLength(2); expect(calls.writes[0]?.key).toBeTruthy(); expect(calls.writes[0]?.key).toBe(calls.writes[1]?.key); expect(calls.writes[1]?.body).toEqual({ action: 'collect-logs' })
await page.getByRole('button', { name: '查看本次采集' }).click(); await expect.poll(() => calls.queries.slice(-1)[0]?.get('source')).toBe('logs')
})
for (const options of [{ offline: true }, { pending: true }]) test(`离线或有未知任务时禁止发起新采集 ${JSON.stringify(options)}`, async ({ page }) => {
const calls = await fixture(page, options); await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await expect(page.getByRole('button', { name: '收集此设备日志' })).toBeDisabled(); expect(calls.writes).toHaveLength(0)
})
test('只读角色隐藏高级入口且直接访问被拦截', async ({ page }) => {
const calls = await fixture(page, { role: 'viewer' }); await page.goto('/overview'); await expect(page.getByRole('navigation', { name: '主导航' }).getByRole('link', { name: '日志与诊断' })).toHaveCount(0)
await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await expect(page).toHaveURL(/\/forbidden$/); expect(calls.queries).toHaveLength(0); expect(calls.writes).toHaveLength(0)
})
test('桌面视角与深浅主题、390宽度布局可读', async ({ page }, info) => {
const calls = await fixture(page); await page.goto('/diagnostics?targetType=desktop&targetId=d1'); await expect(page.getByRole('heading', { name: '办公桌面', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: '收集此设备日志' })).toHaveCount(0)
await page.screenshot({ path: info.outputPath('diagnostics-dark.png'), fullPage: true, animations: 'disabled' }); await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.screenshot({ path: info.outputPath('diagnostics-light.png'), fullPage: true, animations: 'disabled' })
await page.setViewportSize({ width: 390, height: 844 }); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ path: info.outputPath('diagnostics-mobile.png'), fullPage: true, animations: 'disabled' }); expect(calls.writes).toHaveLength(0)
})

View File

@ -0,0 +1,131 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { role?: string; empty?: boolean; fail?: string; id?: string; saved?: string; timeout?: boolean } = {}) {
const writes: { path: string; body: Record<string, unknown> }[] = [], reads: string[] = []
let users = options.empty ? [] : [{ id: 'u1', username: 'zhang', displayName: '张三', enabled: true, terminalId: 't1', desktopIds: ['d1'] }, { id: 'u2', username: 'li', displayName: '李四', enabled: false, terminalId: null, desktopIds: [] as string[] }]
const desktops = options.empty ? [] : ['d1', 'd2'].map((id, i) => ({ id, name: i ? '备用桌面' : '办公桌面', os: 'Windows', node: 'pve', vmid: 100 + i, powerState: i ? 'stopped' : 'running', serviceState: i ? 'unknown' : 'ready', setupRequired: false, updatedAt: new Date().toISOString() }))
const terminals = options.empty ? [] : [{ id: 't1', sn: 'SN-01', name: '前台盒子', enabled: true, status: 'offline', sessionDesktopId: null, lastSeen: new Date().toISOString() }, { id: 't2', sn: 'SN-02', name: '新盒子', enabled: true, status: 'unregistered', sessionDesktopId: null, lastSeen: null }]
const task = { id: 'k1', targetId: 'd2', targetName: '备用桌面', kind: 'desktop', action: 'start', status: options.timeout ? 'timeout' : 'success', message: '等待核对', actor: 'admin', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
await page.addInitScript(({ id, saved }) => { sessionStorage.setItem('vdi.accessToken', 'editions-fixture'); if (saved) localStorage.setItem(`vdi.console-mode:${id}`, saved) }, { id: options.id || 'admin', saved: options.saved })
await page.route('**/api/v1/**', async route => {
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', ''), method = request.method(), body = request.postData() ? request.postDataJSON() : {}
const list = (items: unknown[], total = items.length) => route.fulfill({ json: { items, total, page: Number(url.searchParams.get('page') || 1), pageSize: 10, requestId: 'edition-ui' } })
if (method !== 'GET') writes.push({ path, body }); else reads.push(path + url.search)
if (options.fail === path && method !== 'GET' || options.fail === 'reads' && path === '/desktops' || options.fail === 'alerts' && path === '/monitor/alerts') { await route.fulfill({ status: 503, json: { code: 'unavailable', message: '当前服务不可用', requestId: 'edition-ui' } }); return }
if (path === '/auth/me') { await route.fulfill({ json: { id: options.id || 'admin', username: 'admin', displayName: '管理员', role: options.role || 'super_admin' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: [] } }); return }
if (path === '/overview') { await route.fulfill({ json: { activeSessions: 1, desktopCount: desktops.length, onlineTerminals: 0, terminalCount: terminals.length, runningDesktops: 1, failedTasks: 0 } }); return }
if (path === '/desktops') { await list(desktops); return }
if (path === '/users' && method === 'POST') { const user = { ...body, id: 'new-user', terminalId: null, desktopIds: [], enabled: true }; users.push(user); await route.fulfill({ status: 201, json: user }); return }
if (path === '/users') { await list(users); return }
if (path === '/terminals' && method === 'POST') { await route.fulfill({ status: 201, json: { id: 'new-terminal', activationSecret: 'one-time-test-secret' } }); return }
if (path === '/terminals') { await list(terminals); return }
if (path === '/users/batch-bindings') {
users = users.map(u => body.userIds.includes(u.id) ? { ...u, desktopIds: body.action === 'grant' ? [...new Set([...u.desktopIds, ...body.desktopIds])] : u.desktopIds.filter(id => !body.desktopIds.includes(id)) } : u)
await route.fulfill({ json: { affectedUsers: 1 } }); return
}
if (path.startsWith('/desktops/') && path.endsWith('/actions')) { await route.fulfill({ status: 202, json: task }); return }
if (path === '/tasks') { await list(url.searchParams.get('active') ? options.timeout ? [task] : [] : [task]); return }
if (path === '/audit-logs') { await list([{ id: 'a1', target: '张三', action: 'batch_grant_bindings', result: 'success', actor: 'admin', createdAt: new Date().toISOString() }], 21); return }
if (path === '/monitor/alerts') { await route.fulfill({ json: { items: [], total: 0, firingCount: 0, unacknowledgedCount: 0 } }); return }
if (path === '/resources') { await list([]); return }
await route.fulfill({ status: 404, json: { message: '未定义的测试接口' } })
})
return { writes, reads }
}
test('默认通用版五个入口,切换高级版保留专业深链和账号偏好', async ({ page }) => {
const calls = await fixture(page); await page.goto('/')
await expect(page).toHaveURL(/\/workbench$/); await expect(page.getByRole('heading', { name: '工作台', exact: true })).toBeVisible(); await expect(page.locator('.simple-stats .panel').first()).toContainText('运行中的桌面1 / 2 台桌面')
const nav = page.getByRole('navigation', { name: '主导航' }); await expect(nav.getByRole('link')).toHaveCount(5)
await expect(nav.getByRole('link', { name: 'GPU 资源' })).toHaveCount(0)
await page.getByRole('button', { name: '高级版', exact: true }).click(); await expect(page).toHaveURL(/\/overview$/)
await expect(nav.getByRole('link', { name: 'GPU 资源' })).toBeVisible(); await expect(nav.getByRole('link', { name: '镜像模板' })).toBeVisible()
await page.goto('/'); await expect(page).toHaveURL(/\/overview$/)
await page.goto('/people'); await expect(page.getByRole('button', { name: '通用版', exact: true })).toHaveAttribute('aria-pressed', 'true')
await page.goto('/users'); await expect(page.getByRole('heading', { name: '用户与授权' })).toBeVisible()
expect(calls.writes).toHaveLength(0)
})
test('另一个账号默认通用版,存储不可用时仍可切换', async ({ page }) => {
await fixture(page, { id: 'reader' }); await page.addInitScript(() => { localStorage.setItem('vdi.console-mode:another-account', 'advanced'); Storage.prototype.setItem = () => { throw new Error('storage disabled') } })
await page.goto('/'); await expect(page).toHaveURL(/\/workbench$/)
await page.getByRole('button', { name: '高级版', exact: true }).click(); await expect(page).toHaveURL(/\/overview$/)
})
test('授权先展示固定对象方案,确认才提交,追加和撤销保留其余授权', async ({ page }) => {
const calls = await fixture(page); await page.goto('/people')
const row = page.getByRole('row').filter({ hasText: '张三' }).first()
await row.getByRole('button', { name: '分配桌面', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '分配已有桌面' }); await dialog.locator('.el-checkbox').filter({ hasText: '备用桌面' }).click()
await dialog.getByRole('button', { name: '查看方案' }).click(); await expect(dialog.getByText('确认变更方案')).toBeVisible(); expect(calls.writes).toHaveLength(0)
await dialog.getByRole('button', { name: '确认并保存授权' }).dblclick(); await expect(dialog).not.toBeVisible()
expect(calls.writes).toEqual([{ path: '/users/batch-bindings', body: { userIds: ['u1'], desktopIds: ['d2'], action: 'grant' } }])
await expect(row).toContainText('备用桌面'); await expect(row).toContainText('办公桌面'); await expect(row).toContainText('前台盒子')
await row.getByRole('button', { name: '撤销授权' }).click(); const revoke = page.getByRole('dialog', { name: '撤销指定授权' })
await revoke.locator('.el-checkbox').filter({ hasText: '备用桌面' }).click(); await revoke.getByRole('button', { name: '查看方案' }).click(); await revoke.getByRole('button', { name: '确认并保存授权' }).click()
await expect(revoke).not.toBeVisible(); await expect(row).not.toContainText('备用桌面'); await expect(row).toContainText('办公桌面')
expect(calls.writes[1]?.body).toEqual({ userIds: ['u1'], desktopIds: ['d2'], action: 'revoke' })
})
test('授权失败保留方案,不显示成功或自动重发', async ({ page }) => {
const calls = await fixture(page, { fail: '/users/batch-bindings' }); await page.goto('/people')
await page.getByRole('row').filter({ hasText: '张三' }).first().getByRole('button', { name: '分配桌面' }).click()
const dialog = page.getByRole('dialog', { name: '分配已有桌面' }); await dialog.locator('.el-checkbox').filter({ hasText: '备用桌面' }).click(); await dialog.getByRole('button', { name: '查看方案' }).click(); await dialog.getByRole('button', { name: '确认并保存授权' }).click()
await expect(page.getByText(/当前服务不可用/)).toBeVisible(); await expect(dialog).toBeVisible(); expect(calls.writes).toHaveLength(1)
})
for (const role of ['viewer', 'operator']) test(`${role}切换版本不提升权限`, async ({ page }) => {
const calls = await fixture(page, { role }); await page.goto('/people')
await expect(page.getByRole('button', { name: '添加员工' })).toHaveCount(0); await expect(page.getByRole('button', { name: '分配桌面', exact: true })).toHaveCount(0)
if (role === 'viewer') await expect(page.getByRole('button', { name: '正常关机', exact: true })).toHaveCount(0)
await page.getByRole('button', { name: '高级版', exact: true }).click(); await page.goto('/settings'); await expect(page).toHaveURL(/\/forbidden$/)
expect(calls.writes).toHaveLength(0)
})
test('设备诊断只读,区分离线、等待激活与缺少关联', async ({ page }) => {
const calls = await fixture(page); await page.goto('/help?terminal=t1')
await expect(page.getByText('未收到在线心跳,请检查盒子电源、网络和平台地址。')).toBeVisible(); await expect(page.getByText('使用人:张三')).toBeVisible(); await expect(page.getByText('桌面运行且连接服务就绪', { exact: false })).toBeVisible()
await page.goto('/help?terminal=t2'); await expect(page.getByText('设备尚未激活', { exact: false })).toBeVisible(); await expect(page.getByText('请在员工与桌面中关联此设备。')).toBeVisible()
expect(calls.writes).toHaveLength(0)
})
test('设备登记返回真实接口激活信息,关闭后移除且不持久化', async ({ page }) => {
const calls = await fixture(page); await page.goto('/devices'); await page.getByRole('button', { name: '登记新设备' }).click()
await page.getByLabel('新设备名称').fill('新设备'); await page.getByLabel('新设备 SN').fill('SN-NEW'); await page.getByRole('button', { name: '登记并获取激活信息' }).click()
const dialog = page.getByRole('dialog', { name: '请保存设备激活信息' }); await expect(dialog.getByLabel('设备激活密钥')).toHaveValue('one-time-test-secret')
await dialog.getByRole('button', { name: '已保存,关闭' }).click(); await expect(dialog).not.toBeVisible()
expect(await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage }))).not.toContain('one-time-test-secret')
expect(calls.writes[0]).toEqual({ path: '/terminals', body: { name: '新设备', sn: 'SN-NEW', group: '' } })
})
test('电源操作取消不提交,结果未知时阻止重复操作', async ({ page }) => {
const calls = await fixture(page, { timeout: true }); await page.goto('/people')
const backup = page.getByRole('row').filter({ hasText: '备用桌面' }).last(); await expect(backup.getByRole('button', { name: '开机', exact: true })).toBeDisabled()
await page.getByRole('button', { name: '重启桌面', exact: true }).click(); const dialog = page.locator('.el-message-box')
await expect(dialog).toContainText('保存文件'); await dialog.getByRole('button', { name: '取消', exact: true }).click(); expect(calls.writes).toHaveLength(0)
await page.getByRole('button', { name: '重启桌面', exact: true }).click(); await dialog.getByRole('button', { name: '确认提交', exact: true }).click()
await expect.poll(() => calls.writes.length).toBe(1); expect(calls.writes[0]).toEqual({ path: '/desktops/d1/actions', body: { action: 'reboot' } })
})
test('通用记录查历史审计和任务,服务端分页', async ({ page }) => {
const calls = await fixture(page); await page.goto('/records'); await expect(page.getByRole('cell', { name: '批量分配桌面' })).toBeVisible()
await page.locator('.el-pagination .btn-next').click(); await expect.poll(() => calls.reads.some(p => p.includes('/audit-logs?') && p.includes('page=2'))).toBe(true)
await page.getByRole('tab', { name: '设备与桌面任务' }).click(); await expect(page.getByRole('cell', { name: '等待核对' })).toBeVisible()
expect(calls.writes).toHaveLength(0)
})
test('空态、告警失败和平台数据失败有明确提示', async ({ page }) => {
await fixture(page, { empty: true, fail: 'alerts' }); await page.goto('/people'); await expect(page.getByText('暂无员工,请先添加员工')).toBeVisible()
await page.goto('/help'); await expect(page.getByText(/告警读取失败,不能判断是否正常/)).toBeVisible(); await expect(page.getByText('当前没有触发中的告警')).toHaveCount(0)
await page.unroute('**/api/v1/**'); await fixture(page, { fail: 'reads' }); await page.goto('/workbench'); await expect(page.getByText('数据刷新失败,当前信息可能已过期')).toBeVisible()
})
test('通用工作台深浅主题与窄屏导航无溢出', async ({ page }, info) => {
await fixture(page); await page.goto('/workbench'); await expect(page.getByRole('heading', { name: '你想完成什么?' })).toBeVisible()
await page.screenshot({ path: info.outputPath('workbench-dark.png'), fullPage: true })
await page.getByRole('button', { name: /切换浅色主题/ }).click(); await page.screenshot({ path: info.outputPath('workbench-light.png'), fullPage: true })
await page.setViewportSize({ width: 390, height: 844 }); await page.getByRole('button', { name: '打开导航', exact: true }).click(); await expect(page.getByRole('navigation', { name: '主导航' }).getByRole('link', { name: '员工与桌面' })).toBeVisible()
await page.getByRole('button', { name: '关闭导航', exact: true }).click(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.screenshot({ path: info.outputPath('workbench-mobile.png'), fullPage: true })
})

View File

@ -0,0 +1,45 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { role?: string; fail?: boolean; status?: string } = {}) {
const writes: string[] = []
const task = { id: 'gpu-task', kind: 'gpu-prepare', action: 'prepare-desktop', targetId: 'desk', targetName: '显卡办公桌面', actor: 'device:box', status: options.status || 'timeout', message: '等待核对', providerRef: '{}', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'gpu-fixture'))
await page.route('**/api/v1/**', async route => {
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '')
const list = (items: unknown[]) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 20 } })
if (request.method() !== 'GET') {
writes.push(path)
if (path !== '/tasks/gpu-task/gpu-reconcile') { await route.abort(); return }
if (options.fail) { await route.fulfill({ status: 409, json: { code: 'unknown_submission', message: '未记录原 PVE 任务编号,需要在 PVE 手工核对,不能自动重发', requestId: 'gpu-ui' } }); return }
task.status = 'failed'; task.message = '管理员已核对原 PVE 任务及实际电源状态,可重新准备桌面'
await route.fulfill({ json: { reconciled: true, vmid: '100' } }); return
}
if (path === '/auth/me') { await route.fulfill({ json: { id: 'admin', username: 'admin', displayName: '管理员', role: options.role || 'operator' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: [] } }); return }
if (path === '/overview') { await route.fulfill({ json: {} }); return }
if (path === '/tasks') { await list(url.searchParams.get('active') && task.status !== 'timeout' ? [] : [task]); return }
if (['/desktops', '/users', '/terminals', '/resources'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { message: '未定义接口' } })
})
return writes
}
test('高级版运维先确认再核对,取消无变更,成功后不再提供核对按钮', async ({ page }) => {
const writes = await fixture(page); await page.goto('/tasks'); await page.getByRole('button', { name: '显卡办公桌面', exact: true }).click()
const drawer = page.getByRole('dialog', { name: '任务详情' }); await drawer.getByRole('button', { name: '核对显卡准备结果', exact: true }).click()
const confirm = page.locator('.el-message-box'); await expect(confirm).toContainText('不重发电源操作'); await expect(confirm).toContainText('原任务标记为失败')
await confirm.getByRole('button', { name: '取消', exact: true }).click(); expect(writes).toHaveLength(0)
await drawer.getByRole('button', { name: '核对显卡准备结果', exact: true }).click(); await confirm.getByRole('button', { name: '确认提交' }).click()
await expect(drawer).not.toBeVisible(); expect(writes).toEqual(['/tasks/gpu-task/gpu-reconcile'])
await page.getByRole('button', { name: '显卡办公桌面', exact: true }).click(); await expect(drawer.getByRole('button', { name: '核对显卡准备结果' })).toHaveCount(0)
})
test('缺少原任务编号显示后台拒绝,保留未知状态且不自动重发', async ({ page }) => {
const writes = await fixture(page, { fail: true }); await page.goto('/tasks'); await page.getByRole('button', { name: '显卡办公桌面', exact: true }).click()
const drawer = page.getByRole('dialog', { name: '任务详情' }); await drawer.getByRole('button', { name: '核对显卡准备结果' }).click(); await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click()
await expect(page.getByText(/未记录原 PVE 任务编号/)).toBeVisible(); await expect(drawer).toBeVisible(); expect(writes).toEqual(['/tasks/gpu-task/gpu-reconcile'])
})
for (const options of [{ role: 'viewer' }, { role: 'operator', status: 'success' }]) test(`角色${options.role}与状态${options.status || 'timeout'}限制核对`, async ({ page }) => {
const writes = await fixture(page, options); await page.goto('/tasks'); await page.getByRole('button', { name: '显卡办公桌面', exact: true }).click()
await expect(page.getByRole('dialog', { name: '任务详情' }).getByRole('button', { name: '核对显卡准备结果' })).toHaveCount(0); expect(writes).toHaveLength(0)
})

98
console/e2e/mfa.spec.ts Normal file
View File

@ -0,0 +1,98 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { enabled?: boolean; fail?: boolean; loggedOut?: boolean } = {}) {
const writes: { path: string; body: Record<string, string> }[] = []
let enabled = options.enabled ?? false
const codes = Array.from({ length: 8 }, (_, i) => `AAAAAAAA-BBBBBBBB-CCCCCCCC-${String(i).padStart(8, '0')}`)
if (!options.loggedOut) await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'mfa-fixture'))
await page.route('**/api/v1/**', async route => {
const request = route.request(), path = new URL(request.url()).pathname.replace('/api/v1', ''), body = request.postData() ? request.postDataJSON() : {}
if (request.method() !== 'GET') writes.push({ path, body })
const fail = (code: string, message: string, status = 400) => route.fulfill({ status, json: { code, message, requestId: 'mfa-ui' } })
if (path === '/auth/login') {
if (body.password !== 'Correct-password-2026') { await fail('invalid_credentials', '用户名或密码错误', 401); return }
if (!body.code) { await fail('mfa_required', '请输入身份验证器动态码或一次性恢复码', 401); return }
if (body.code !== '123456' && body.code !== codes[0]) { await fail('mfa_invalid', '验证码错误、已使用或已过期', 401); return }
await route.fulfill({ json: { accessToken: 'logged-in-after-mfa', expiresIn: 3600, user: { id: 'reader', username: 'reader', displayName: '查看员', role: 'viewer' } } }); return
}
if (path === '/auth/me') { await route.fulfill({ json: { id: 'reader', username: 'reader', displayName: '查看员', role: 'viewer' } }); return }
if (path === '/auth/mfa' && request.method() === 'GET') { await route.fulfill({ json: { enabled, recoveryRemaining: enabled ? 8 : 0 } }); return }
if (path === '/auth/mfa/setup') {
if (body.password !== 'Correct-password-2026') { await fail('current_password_invalid', '当前密码不正确'); return }
await route.fulfill({ json: { secret: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP', provisioningUri: 'otpauth://totp/VDI%20Cloud:reader?secret=example', expiresAt: new Date(Date.now() + 600000).toISOString() } }); return
}
if (path === '/auth/mfa/confirm') {
if (options.fail || body.code !== '123456') { await fail('mfa_invalid', '验证码错误或已过期'); return }
enabled = true; await route.fulfill({ json: { recoveryCodes: codes } }); return
}
if (path === '/auth/mfa/recovery') { await route.fulfill({ json: { recoveryCodes: codes } }); return }
if (path === '/auth/mfa' && request.method() === 'DELETE') { enabled = false; await route.fulfill({ status: 204 }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['admin-mfa'], pveConfigured: true } }); return }
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
if (['/resources', '/desktops', '/users', '/terminals', '/tasks'].includes(path)) { await route.fulfill({ json: { items: [], total: 0, page: 1, pageSize: 100, requestId: 'mfa-ui' } }); return }
await route.fulfill({ status: 404, json: { message: '测试接口未定义' } })
})
return { writes, codes }
}
test('只读人员也可绑定本人验证器,确认后才开启,恢复码下载和关闭后不再显示', async ({ page }, info) => {
const calls = await fixture(page); await page.goto('/overview'); await page.getByRole('button', { name: '双重验证', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '本人双重验证', exact: true })
await expect(dialog.getByText('未开启', { exact: true })).toBeVisible()
await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026')
await dialog.getByRole('button', { name: '开始绑定身份验证器' }).click()
await expect(dialog.getByLabel('身份验证器密钥')).toBeVisible()
expect(calls.writes.map(x => x.path)).toEqual(['/auth/mfa/setup'])
await dialog.getByRole('textbox', { name: '绑定动态码' }).fill('123456'); await dialog.getByRole('button', { name: '验证并开启' }).click()
await expect(dialog.getByText('恢复码仅显示这一次,请先保存')).toBeVisible()
await expect(dialog.getByText(calls.codes[0]!, { exact: true })).toBeVisible()
const download = page.waitForEvent('download'); await dialog.getByRole('button', { name: '下载恢复码' }).click(); expect((await download).suggestedFilename()).toBe('vdi-cloud-recovery-codes.txt')
await page.screenshot({ path: info.outputPath('mfa-recovery-dark.png'), fullPage: true, animations: 'disabled' })
await dialog.getByRole('button', { name: '已保存,关闭' }).click(); await page.getByRole('button', { name: '双重验证', exact: true }).click()
await expect(dialog.getByText('已开启', { exact: true })).toBeVisible(); await expect(dialog.getByText(calls.codes[0]!, { exact: true })).toHaveCount(0)
expect(await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage }))).not.toContain('JBSWY3')
})
test('验证码错误保持未完成绑定,窄屏和浅色布局可用,关闭清除密钥', async ({ page }, info) => {
await fixture(page, { fail: true }); await page.goto('/overview'); await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 })
await page.getByRole('button', { name: '双重验证', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '本人双重验证' })
await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026'); await dialog.getByRole('button', { name: '开始绑定身份验证器' }).click()
await dialog.getByRole('textbox', { name: '绑定动态码' }).fill('111111'); await dialog.getByRole('button', { name: '验证并开启' }).click()
await expect(dialog.getByText(/验证码错误或已过期/)).toBeVisible(); await expect(dialog.getByText('恢复码仅显示这一次,请先保存')).toHaveCount(0)
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await page.screenshot({ path: info.outputPath('mfa-setup-mobile-light.png'), fullPage: true, animations: 'disabled' })
await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await page.getByRole('button', { name: '双重验证', exact: true }).click(); await expect(dialog.getByText('未开启', { exact: true })).toBeVisible(); await expect(dialog.getByLabel('身份验证器密钥')).toHaveCount(0)
})
test('更新恢复码和关闭需要密码及验证码,取消不提交', async ({ page }) => {
const calls = await fixture(page, { enabled: true }); await page.goto('/overview'); await page.getByRole('button', { name: '双重验证', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '本人双重验证' })
await dialog.getByRole('button', { name: '关闭双重验证', exact: true }).click()
await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026'); await dialog.getByRole('textbox', { name: '双重验证确认码' }).fill('123456')
await dialog.getByRole('button', { name: '确认关闭双重验证' }).click(); await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click(); expect(calls.writes).toHaveLength(0)
await dialog.getByRole('button', { name: '确认关闭双重验证' }).click(); await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click()
await expect(dialog.getByText('未开启', { exact: true })).toBeVisible(); expect(calls.writes[0]?.body).toEqual({ password: 'Correct-password-2026', code: '123456' })
})
test('重新生成恢复码经过确认后显示新码并支持下载', async ({ page }) => {
const calls = await fixture(page, { enabled: true }); await page.goto('/overview'); await page.getByRole('button', { name: '双重验证', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '本人双重验证' })
await dialog.getByRole('button', { name: '重新生成恢复码', exact: true }).click()
await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026'); await dialog.getByRole('textbox', { name: '双重验证确认码' }).fill(calls.codes[0]!)
await dialog.getByRole('button', { name: '生成新恢复码', exact: true }).click(); expect(calls.writes).toHaveLength(0)
await page.locator('.el-message-box').getByRole('button', { name: '确认提交', exact: true }).click()
await expect(dialog.getByText('恢复码仅显示这一次,请先保存')).toBeVisible()
expect(calls.writes[0]?.path).toBe('/auth/mfa/recovery'); expect(calls.writes[0]?.body.code).toBe(calls.codes[0])
const download = page.waitForEvent('download'); await dialog.getByRole('button', { name: '下载恢复码' }).click(); expect((await download).suggestedFilename()).toBe('vdi-cloud-recovery-codes.txt')
})
for (const recovery of [false, true]) test(`登录先验证密码,动态码${recovery ? '使用恢复码' : '错误后重试'}成功前不保存令牌`, async ({ page }) => {
const calls = await fixture(page, { loggedOut: true }); await page.goto('/login')
await page.getByRole('textbox', { name: '用户名', exact: true }).fill('reader'); await page.getByLabel('密码', { exact: true }).fill('incorrect'); await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByText(/用户名或密码错误/)).toBeVisible(); await expect(page.getByRole('textbox', { name: '登录动态码或恢复码' })).toHaveCount(0)
await page.getByLabel('密码', { exact: true }).fill('Correct-password-2026'); await page.getByRole('button', { name: '登录', exact: true }).click()
const input = page.getByRole('textbox', { name: '登录动态码或恢复码' }); await expect(input).toBeVisible(); expect(await page.evaluate(() => sessionStorage.getItem('vdi.accessToken'))).toBeNull()
await input.fill('111111'); await page.getByRole('button', { name: '登录', exact: true }).click(); await expect(page.getByText(/验证码错误、已使用或已过期/)).toBeVisible(); await expect(input).toHaveValue('')
await input.fill(recovery ? calls.codes[0]! : '123456'); await page.getByRole('button', { name: '登录', exact: true }).click(); await expect(page).toHaveURL(/\/workbench$/)
expect(await page.evaluate(() => sessionStorage.getItem('vdi.accessToken'))).toBe('logged-in-after-mfa')
})

View File

@ -0,0 +1,90 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { fail?: boolean; pending?: boolean; slow?: boolean } = {}) {
const queries: URLSearchParams[] = [], exports: URLSearchParams[] = [], writes: string[] = [], now = new Date().toISOString()
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'task-history-fixture'))
const task = (i: number, extra = {}) => ({ id: `task-${i}`, kind: 'desktop', action: 'start', targetId: `desk-${i}`, targetName: `历史桌面 ${i}`, actor: 'root', status: 'success', message: '真实回执已保存', providerRef: '', createdAt: now, updatedAt: now, ...extra })
await page.route('**/api/v1/**', async route => {
const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '')
const list = (items: unknown[], extra = {}) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'task-ui', ...extra } })
if (request.method() !== 'GET') { writes.push(path); await route.fulfill({ status: 403, json: { message: '不应发送业务写请求' } }); return }
if (path === '/auth/me') { await route.fulfill({ json: { id: 'reader', username: 'reader', displayName: '任务查看员', role: options.pending ? 'operator' : 'viewer' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['task-history'], pveConfigured: false } }); return }
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: options.pending ? 1 : 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
if (path === '/tasks/export') { exports.push(url.searchParams); await route.fulfill({ contentType: 'text/csv; charset=utf-8', body: '\ufeff任务标识,状态\r\ntask-1,success\r\n' }); return }
if (path === '/tasks') {
queries.push(url.searchParams)
const p = Number(url.searchParams.get('page') || 1), size = Number(url.searchParams.get('pageSize') || 20), active = url.searchParams.get('active') === 'true'
if (active) {
const total = options.pending ? 101 : 0
await list(Array.from({ length: Math.max(0, Math.min(size, total - (p - 1) * size)) }, (_, i) => task(2000 + (p - 1) * size + i, { status: 'queued', targetId: (p - 1) * size + i === 100 ? 'desk-pending' : `pending-${i}` })), { total, page: p, pageSize: size }); return
}
const keyword = url.searchParams.get('keyword') || ''
if (options.fail && keyword) { await route.fulfill({ status: 500, json: { code: 'database_error', message: '任务查询暂不可用', requestId: 'task-ui' } }); return }
if (options.slow && keyword === 'slow') await new Promise(resolve => setTimeout(resolve, 700))
const filtered = !!url.searchParams.get('actor') || !!keyword, total = options.pending ? 1500 : filtered ? 1 : 45
await list(Array.from({ length: Math.max(0, Math.min(size, total - (p - 1) * size)) }, (_, i) => task((p - 1) * size + i + 1, filtered ? { targetName: keyword || '筛选桌面', actor: url.searchParams.get('actor') || 'root' } : {})), { total, page: p, pageSize: size }); return
}
if (path === '/desktops' && options.pending) { await list([{ id: 'desk-pending', name: '有旧任务的桌面', vmid: 9000, node: 'test', os: 'Ubuntu', pool: '', provider: 'proxmox', powerState: 'stopped', serviceState: 'unknown', setupRequired: false, endpoint: '192.0.2.1', width: 1920, height: 1080, fps: 30, updatedAt: now }]); return }
if (['/resources', '/desktops', '/users', '/terminals', '/audit-logs'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { message: '未定义的测试接口' } })
})
return { queries, exports, writes }
}
test('任务历史分页、详情和深浅窄屏,历史读取不依赖全局近期任务', async ({ page }, info) => {
const calls = await fixture(page); await page.goto('/tasks')
await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible()
const initialPages = calls.queries.filter(q => q.get('pageSize') === '20')
const cutoffBeforePaging = initialPages[initialPages.length - 1]?.get('to')
await page.locator('.el-pagination .btn-next').click(); await expect(page.getByRole('button', { name: '历史桌面 21', exact: true })).toBeVisible()
await page.getByRole('button', { name: '历史桌面 21', exact: true }).click()
const drawer = page.getByRole('dialog', { name: '任务详情' })
await expect(drawer.getByText('task-21', { exact: true })).toBeVisible()
await expect(drawer.getByRole('button', { name: /核对/ })).toHaveCount(0)
await drawer.locator('.el-drawer__close-btn').click()
await page.screenshot({ path: info.outputPath('task-history-dark.png'), fullPage: true, animations: 'disabled' })
await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 })
await page.screenshot({ path: info.outputPath('task-history-mobile.png'), fullPage: true, animations: 'disabled' })
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
const pages = calls.queries.filter(q => q.get('pageSize') === '20')
expect(pages[pages.length - 1]?.get('page')).toBe('2'); expect(pages[pages.length - 1]?.get('to')).toBe(cutoffBeforePaging)
expect(calls.writes).toEqual([])
})
test('任务筛选和导出沿用已应用条件,修改草稿后导出不混用', async ({ page }) => {
const calls = await fixture(page); await page.goto('/tasks'); await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible()
await page.getByRole('textbox', { name: '任务操作者' }).fill('root_%')
await page.locator('.el-select').filter({ has: page.getByRole('combobox', { name: '任务状态筛选' }) }).click(); await page.getByRole('option', { name: '成功', exact: true }).click()
await page.getByRole('combobox', { name: '任务动作' }).click(); await page.getByRole('option', { name: '开机', exact: true }).click()
await page.getByRole('combobox', { name: '任务类型' }).click(); await page.getByRole('option', { name: '桌面电源', exact: true }).click()
await page.getByRole('combobox', { name: '任务时间范围' }).first().click(); await page.getByRole('button', { name: '最近 7 天', exact: true }).click()
await page.getByRole('button', { name: '查询任务', exact: true }).click(); await expect(page.getByRole('button', { name: '筛选桌面', exact: true })).toBeVisible()
await page.getByRole('textbox', { name: '搜索任务' }).fill('尚未应用')
const wait = page.waitForEvent('download'); await page.getByRole('button', { name: '导出任务 CSV' }).click()
expect((await wait).suggestedFilename()).toMatch(/执行任务.*\.csv$/)
expect(Object.fromEntries(calls.exports[0]!)).toMatchObject({ actor: 'root_%', status: 'success', action: 'start', kind: 'desktop', keyword: '' })
expect(calls.exports[0]?.get('from')).toBeTruthy(); expect(calls.exports[0]?.get('page')).toBeNull()
await page.getByRole('button', { name: '重置', exact: true }).click(); await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible()
expect(calls.writes).toEqual([])
})
test('查询失败清理旧结果并禁用导出,旧响应不能覆盖新条件', async ({ page }) => {
await fixture(page, { fail: true }); await page.goto('/tasks'); await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible()
await page.getByRole('textbox', { name: '搜索任务' }).fill('failure'); await page.getByRole('button', { name: '查询任务', exact: true }).click()
await expect(page.getByText(/任务查询暂不可用/)).toBeVisible(); await expect(page.getByRole('button', { name: '导出任务 CSV' })).toBeDisabled()
await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toHaveCount(0)
await page.unrouteAll(); await fixture(page, { slow: true }); await page.reload(); await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible()
await page.getByRole('textbox', { name: '搜索任务' }).fill('slow'); await page.getByRole('textbox', { name: '搜索任务' }).press('Enter')
await page.getByRole('textbox', { name: '搜索任务' }).fill('new'); await page.getByRole('textbox', { name: '搜索任务' }).press('Enter')
await expect(page.getByRole('button', { name: 'new', exact: true })).toBeVisible()
await expect.poll(async () => { await page.waitForTimeout(800); return page.getByRole('button', { name: 'slow', exact: true }).count() }).toBe(0)
})
test('全局只取近期一页并分页拉全旧活动任务,旧任务仍阻止操作桌面', async ({ page }) => {
const calls = await fixture(page, { pending: true }); await page.goto('/desktops')
await expect(page.getByRole('button', { name: '任务执行中', exact: true })).toBeDisabled()
expect(calls.queries.some(q => q.get('active') === 'true' && q.get('page') === '2')).toBe(true)
expect(calls.queries.some(q => q.get('active') !== 'true' && q.get('page') === '2')).toBe(false)
expect(calls.writes).toEqual([])
})

View File

@ -0,0 +1,94 @@
import { test, expect, type Page } from '@playwright/test'
async function fixture(page: Page, options: { role?: string; stale?: boolean; resolved?: boolean; fail?: boolean } = {}) {
const writes: { path: string; body: Record<string, unknown> }[] = [], now = new Date().toISOString()
let acknowledged = false
await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'terminal-alert-fixture'))
const rules: Record<string, unknown>[] = [{ id: 'temp-rule', name: '终端温度高水位', metric: 'terminal_temperature', threshold: 80, holdSeconds: 120, enabled: true }]
await page.route('**/api/v1/**', async route => {
const request = route.request(), path = new URL(request.url()).pathname.replace('/api/v1', '')
const list = (items: unknown[], extra = {}) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'alert-ui', ...extra } })
if (request.method() !== 'GET') {
const body = request.postDataJSON(); writes.push({ path, body })
if (path.endsWith('/acknowledge')) { acknowledged = true; await route.fulfill({ json: { accepted: true } }); return }
if (path.startsWith('/monitor/rules')) {
if (options.fail) { await route.fulfill({ status: 400, json: { message: '温度阈值超出范围', code: 'invalid_request', requestId: 'alert-ui' } }); return }
const rule = { ...body, id: path.endsWith('/temp-rule') ? 'temp-rule' : 'created-rule' }
if (path.endsWith('/temp-rule')) rules[0] = rule; else rules.push(rule)
await route.fulfill({ json: rule }); return
}
await route.fulfill({ status: 403, json: { message: '不应出现的写操作' } }); return
}
if (path === '/auth/me') { await route.fulfill({ json: { id: 'alerts-reader', username: 'reader', displayName: '告警人员', role: options.role || 'super_admin' } }); return }
if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['terminal-metric-alerts'], pveConfigured: false } }); return }
if (path === '/overview') { await route.fulfill({ json: { terminalCount: 1, onlineTerminals: 1, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return }
if (path === '/monitor/rules') { await list(rules); return }
if (path === '/monitor/alerts') {
await list([{ id: 'temp-alert', ruleId: 'temp-rule', ruleName: '终端温度高水位', metric: 'terminal_temperature', targetId: 'box1', targetName: '测试盒子', value: 86.5, threshold: 80, status: options.resolved ? 'resolved' : 'firing', startedAt: now, openedAt: now, resolvedAt: options.resolved ? now : null, resolveReason: options.resolved ? 'condition_cleared' : '', acknowledgedAt: acknowledged ? now : null, acknowledgedBy: acknowledged ? 'reader' : '', dataFresh: !options.stale }], { firingCount: options.resolved ? 0 : 1, unacknowledgedCount: acknowledged || options.resolved ? 0 : 1 }); return
}
if (['/resources', '/desktops', '/users', '/terminals', '/tasks', '/audit-logs'].includes(path)) { await list([]); return }
await route.fulfill({ status: 404, json: { message: '测试接口未定义', requestId: 'alert-ui' } })
})
return writes
}
test('新增 SoC 温度规则使用摄氏度和独立上限,切换内存使用百分比', async ({ page }, info) => {
const writes = await fixture(page); await page.goto('/monitor')
await expect(page.getByRole('cell', { name: '86.5 / 80 °C', exact: true })).toBeVisible()
await page.getByRole('tab', { name: '告警规则' }).click()
await expect(page.getByRole('cell', { name: '80°C / 120 秒', exact: true })).toBeVisible()
await page.getByRole('button', { name: '新增规则', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '新增告警规则' })
await dialog.locator('.el-select').click(); await page.getByRole('option', { name: '终端 SoC 温度', exact: true }).click()
await expect(dialog.getByText('SoC 温度阈值(°C)', { exact: true })).toBeVisible()
await expect(dialog.getByRole('spinbutton', { name: '告警阈值' })).toHaveAttribute('aria-valuemax', '150')
await expect(dialog.getByText(/重复读取不推进/)).toBeVisible()
await dialog.getByRole('textbox', { name: '规则名称' }).fill('盒子过热')
await page.screenshot({ path: info.outputPath('terminal-alert-create-dark.png'), fullPage: true, animations: 'disabled' })
await dialog.getByRole('button', { name: '保存规则', exact: true }).click()
await expect(dialog).toBeHidden()
expect(writes[0]?.body).toMatchObject({ name: '盒子过热', metric: 'terminal_temperature', threshold: 80, holdSeconds: 120, enabled: true })
await page.getByRole('button', { name: '新增规则', exact: true }).click()
await dialog.locator('.el-select').click(); await page.getByRole('option', { name: '终端内存', exact: true }).click()
await expect(dialog.getByText('使用率阈值(%)', { exact: true })).toBeVisible()
await expect(dialog.getByRole('spinbutton', { name: '告警阈值' })).toHaveAttribute('aria-valuemax', '100')
await dialog.getByRole('button', { name: '取消', exact: true }).click()
expect(writes).toHaveLength(1)
})
test('过期数据保留告警和最近值,确认不表示恢复', async ({ page }, info) => {
const writes = await fixture(page, { role: 'operator', stale: true }); await page.goto('/monitor')
await expect(page.getByText('数据已过期或缺失;显示最近告警值,等待有效采样', { exact: true })).toBeVisible()
await page.getByRole('button', { name: '确认告警' }).click()
await expect(page.getByRole('cell', { name: '正在告警', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '确认告警' })).toHaveCount(0)
await page.getByRole('button', { name: '切换浅色主题' }).click()
await page.setViewportSize({ width: 390, height: 844 })
await page.screenshot({ path: info.outputPath('terminal-alert-stale-mobile-light.png'), fullPage: true, animations: 'disabled' })
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391)
await page.getByRole('tab', { name: '告警规则' }).click()
await expect(page.getByRole('button', { name: '新增规则' })).toHaveCount(0)
expect(writes).toHaveLength(1)
})
test('只读人员可看恢复事件和摄氏度,无确认或规则写入口', async ({ page }) => {
const writes = await fixture(page, { role: 'viewer', stale: true, resolved: true }); await page.goto('/monitor')
await expect(page.getByRole('cell', { name: '已恢复', exact: true })).toBeVisible()
await expect(page.getByText('指标恢复', { exact: true })).toBeVisible()
await expect(page.getByText(/数据已过期或缺失/)).toHaveCount(0)
await expect(page.getByRole('button', { name: '确认告警' })).toHaveCount(0)
await page.getByRole('tab', { name: '告警规则' }).click()
await expect(page.getByRole('button', { name: '编辑规则' })).toHaveCount(0)
expect(writes).toEqual([])
})
test('保存错误保留表单和数值,取消不改变规则', async ({ page }) => {
const writes = await fixture(page, { fail: true }); await page.goto('/monitor')
await page.getByRole('tab', { name: '告警规则' }).click(); await page.getByRole('button', { name: '编辑规则' }).click()
const dialog = page.getByRole('dialog', { name: '编辑告警规则' })
await dialog.getByRole('button', { name: '保存规则', exact: true }).click()
await expect(page.getByText(/温度阈值超出范围/)).toBeVisible()
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: '取消', exact: true }).click(); await expect(dialog).toBeHidden()
expect(writes).toHaveLength(1)
})

View File

@ -0,0 +1 @@
export const auditActionLabels: Record<string, string> = { export_diagnostics: '导出诊断包', read_diagnostics: '读取终端诊断', 'batch-start': '批量开机提交', 'batch-shutdown': '批量关机提交', 'batch-reboot': '批量重启提交', reconcile_batch_power: '核对批量电源', 'snapshot-create': '创建快照', 'snapshot-delete': '删除快照', 'snapshot-rollback': '还原快照', reconcile_snapshot: '核对快照', 'configure-hardware': '调整 CPU/内存', 'expand-disk': '磁盘扩容', reconcile_hardware: '核对规格', save_pve_connection: '保存 PVE 接入', create_terminal_policy: '创建终端策略', deploy_terminal_policy: '下发终端策略', terminal_policy_applied: '策略应用成功', terminal_policy_failed: '策略应用失败', update_terminal: '更新终端资料', disable_terminal: '停用终端', reactivate_terminal: '重新激活终端', update_user: '更新用户资料', batch_grant_bindings: '批量分配桌面', batch_revoke_bindings: '批量撤销桌面', create_administrator: '新增管理员', update_administrator: '更新管理员', reset_administrator_password: '重置管理员密码', change_own_password: '修改本人密码', reconcile_clone: '核对克隆', clone: '克隆桌面', register_template: '登记模板', update_template: '更新模板', validate_template: '验证模板', save_pool: '保存桌面池', update_pool_members: '更新池成员', configure_desktop: '配置连接', start: '开机', shutdown: '正常关机', reboot: '重启', 'reset-session': '重置会话', 'collect-logs': '收集日志', sync: '同步资源', login: '登录', logout: '退出登录', create_user: '创建用户', enroll_terminal: '登记终端', update_bindings: '更新授权', import_desktop: '纳管桌面', save_alert_rule: '保存告警规则', acknowledge_alert: '确认告警', export_audit_logs: '导出操作审计', export_tasks: '导出任务记录', revoke_administrator_session: '撤销管理员登录', enable_mfa: '开启双重验证', disable_mfa: '关闭双重验证', rotate_mfa_recovery: '更新恢复码', 'prepare-desktop': '准备独占显卡桌面', 'reconcile-gpu-switch': '核对显卡切换' }

View File

@ -2,6 +2,9 @@ import createClient from 'openapi-fetch'
import type { paths, components } from './schema' import type { paths, components } from './schema'
export type PveConnectionInput = components['schemas']['PveConnectionInput'] export type PveConnectionInput = components['schemas']['PveConnectionInput']
export type DiagnosticReport = components['schemas']['DiagnosticReport']
export type DiagnosticEvent = components['schemas']['DiagnosticEvent']
export type DiagnosticQuery = NonNullable<paths['/diagnostics']['get']['parameters']['query']>
export type PowerBatchInput = components['schemas']['PowerBatchInput'] export type PowerBatchInput = components['schemas']['PowerBatchInput']
export type PowerBatch = components['schemas']['PowerBatch'] export type PowerBatch = components['schemas']['PowerBatch']
export type PowerBatchDetail = components['schemas']['PowerBatchDetail'] export type PowerBatchDetail = components['schemas']['PowerBatchDetail']
@ -12,6 +15,8 @@ export type SnapshotItem = components['schemas']['SnapshotItem']
export type HardwareInput = components['schemas']['HardwareInput'] export type HardwareInput = components['schemas']['HardwareInput']
export type VMHardware = components['schemas']['VMHardware'] export type VMHardware = components['schemas']['VMHardware']
export type Admin = components['schemas']['Administrator'] export type Admin = components['schemas']['Administrator']
export type MFAStatus = components['schemas']['MFAStatus']
export type MFASetup = components['schemas']['MFASetup']
export type ImportPreview = components['schemas']['CsvImportPreview'] export type ImportPreview = components['schemas']['CsvImportPreview']
export type ImportResult = components['schemas']['CsvImportResult'] export type ImportResult = components['schemas']['CsvImportResult']
export type CsvImportInput = components['schemas']['CsvImportInput'] export type CsvImportInput = components['schemas']['CsvImportInput']
@ -28,6 +33,7 @@ export type PolicyDeployment = components['schemas']['PolicyDeployment']
export type PolicyDeploymentInput = components['schemas']['PolicyDeploymentInput'] export type PolicyDeploymentInput = components['schemas']['PolicyDeploymentInput']
export type PolicyResult = components['schemas']['PolicyResult'] export type PolicyResult = components['schemas']['PolicyResult']
export type AdministratorAccount = components['schemas']['AdministratorAccount'] export type AdministratorAccount = components['schemas']['AdministratorAccount']
export type AdministratorSession = components['schemas']['AdministratorSession']
export type CreateAdministrator = components['schemas']['CreateAdministrator'] export type CreateAdministrator = components['schemas']['CreateAdministrator']
export type UpdateAdministrator = components['schemas']['UpdateAdministrator'] export type UpdateAdministrator = components['schemas']['UpdateAdministrator']
export type Desktop = components['schemas']['Desktop'] export type Desktop = components['schemas']['Desktop']
@ -48,6 +54,8 @@ export type GPUInventory = components['schemas']['GPUInventory']
export type GPUItem = components['schemas']['GPUItem'] export type GPUItem = components['schemas']['GPUItem']
export type Task = components['schemas']['Task'] export type Task = components['schemas']['Task']
export type Audit = components['schemas']['Audit'] export type Audit = components['schemas']['Audit']
export type AuditQuery = NonNullable<paths['/audit-logs']['get']['parameters']['query']>
export type TaskQuery = NonNullable<paths['/tasks']['get']['parameters']['query']>
export type Overview = components['schemas']['Overview'] export type Overview = components['schemas']['Overview']
export type Settings = components['schemas']['Settings'] export type Settings = components['schemas']['Settings']
export type BindingInput = components['schemas']['BindingInput'] export type BindingInput = components['schemas']['BindingInput']
@ -80,6 +88,9 @@ async function result<T>(promise: Promise<Result<T>>): Promise<T> {
const commandHeaders = () => ({ 'Idempotency-Key': crypto.randomUUID() }) const commandHeaders = () => ({ 'Idempotency-Key': crypto.randomUUID() })
const query = { page: 1, pageSize: 100 } const query = { page: 1, pageSize: 100 }
export const api = { export const api = {
diagnostics: (filters: DiagnosticQuery) => result(client.GET('/diagnostics', { params: { query: filters } })),
exportDiagnostics: (filters: DiagnosticQuery) => result(client.GET('/diagnostics/export', { params: { query: filters }, parseAs: 'blob' })),
collectTerminalLogs: (id: string, key: string) => result(client.POST('/terminals/{id}/commands', { params: { path: { id }, header: { 'Idempotency-Key': key } }, body: { action: 'collect-logs' } })),
gpuInventory: (node: string) => result(client.GET('/resources/gpus', { params: { query: { node } } })), gpuInventory: (node: string) => result(client.GET('/resources/gpus', { params: { query: { node } } })),
pveConnection: () => result(client.GET('/settings/pve')), pveConnection: () => result(client.GET('/settings/pve')),
checkPveConnection: (body: PveConnectionInput) => result(client.POST('/settings/pve/check', { body })), checkPveConnection: (body: PveConnectionInput) => result(client.POST('/settings/pve/check', { body })),
@ -89,6 +100,7 @@ export const api = {
checkDesktopPowerBatch: (body: PowerBatchInput) => result(client.POST('/desktop-batches/check', { body })), checkDesktopPowerBatch: (body: PowerBatchInput) => result(client.POST('/desktop-batches/check', { body })),
submitDesktopPowerBatch: (body: PowerBatchInput, key: string) => result(client.POST('/desktop-batches', { params: { header: { 'Idempotency-Key': key } }, body })), submitDesktopPowerBatch: (body: PowerBatchInput, key: string) => result(client.POST('/desktop-batches', { params: { header: { 'Idempotency-Key': key } }, body })),
reconcileBatchPower: (id: string) => result(client.POST('/tasks/{id}/batch-power-reconcile', { params: { path: { id } } })), reconcileBatchPower: (id: string) => result(client.POST('/tasks/{id}/batch-power-reconcile', { params: { path: { id } } })),
reconcileGPUPrepare: (id: string) => result(client.POST('/tasks/{id}/gpu-reconcile', { params: { path: { id } } })),
desktopSnapshots: (id: string) => result(client.GET('/desktops/{id}/snapshots', { params: { path: { id } } })), desktopSnapshots: (id: string) => result(client.GET('/desktops/{id}/snapshots', { params: { path: { id } } })),
checkDesktopSnapshot: (id: string, body: SnapshotInput) => result(client.POST('/desktops/{id}/snapshots/check', { params: { path: { id } }, body })), checkDesktopSnapshot: (id: string, body: SnapshotInput) => result(client.POST('/desktops/{id}/snapshots/check', { params: { path: { id } }, body })),
changeDesktopSnapshot: (id: string, body: SnapshotInput, key: string) => result(client.POST('/desktops/{id}/snapshots/actions', { params: { path: { id }, header: { 'Idempotency-Key': key } }, body })), changeDesktopSnapshot: (id: string, body: SnapshotInput, key: string) => result(client.POST('/desktops/{id}/snapshots/actions', { params: { path: { id }, header: { 'Idempotency-Key': key } }, body })),
@ -112,11 +124,18 @@ export const api = {
policyDeployments: (page = 1) => result(client.GET('/terminal-policy-deployments', { params: { query: { ...query, page } } })), policyDeployments: (page = 1) => result(client.GET('/terminal-policy-deployments', { params: { query: { ...query, page } } })),
deployPolicy: (body: PolicyDeploymentInput, key: string) => result(client.POST('/terminal-policy-deployments', { params: { header: { 'Idempotency-Key': key } }, body })), deployPolicy: (body: PolicyDeploymentInput, key: string) => result(client.POST('/terminal-policy-deployments', { params: { header: { 'Idempotency-Key': key } }, body })),
policyResults: (id: string, page = 1) => result(client.GET('/terminal-policy-deployments/{id}/results', { params: { path: { id }, query: { ...query, page } } })), policyResults: (id: string, page = 1) => result(client.GET('/terminal-policy-deployments/{id}/results', { params: { path: { id }, query: { ...query, page } } })),
login: (username: string, password: string) => result(client.POST('/auth/login', { body: { username, password } })), login: (username: string, password: string, code?: string) => result(client.POST('/auth/login', { body: { username, password, ...(code ? { code } : {}) } })),
mfaStatus: () => result(client.GET('/auth/mfa')),
beginMFA: (password: string) => result(client.POST('/auth/mfa/setup', { body: { password } })),
confirmMFA: (code: string) => result(client.POST('/auth/mfa/confirm', { body: { code } })),
rotateMFARecovery: (password: string, code: string) => result(client.POST('/auth/mfa/recovery', { body: { password, code } })),
disableMFA: (password: string, code: string) => result(client.DELETE('/auth/mfa', { body: { password, code } })),
me: () => result(client.GET('/auth/me')), me: () => result(client.GET('/auth/me')),
logout: () => result(client.POST('/auth/logout')), logout: () => result(client.POST('/auth/logout')),
changeOwnPassword: (currentPassword: string, newPassword: string) => result(client.POST('/auth/password', { body: { currentPassword, newPassword } })), changeOwnPassword: (currentPassword: string, newPassword: string) => result(client.POST('/auth/password', { body: { currentPassword, newPassword } })),
administrators: (page = 1) => result(client.GET('/administrators', { params: { query: { ...query, page } } })), administrators: (page = 1) => result(client.GET('/administrators', { params: { query: { ...query, page } } })),
administratorSessions: (id: string, page = 1) => result(client.GET('/administrators/{id}/sessions', { params: { path: { id }, query: { page, pageSize: 20 } } })),
revokeAdministratorSession: (id: string, sessionKey: string) => result(client.DELETE('/administrators/{id}/sessions/{sessionKey}', { params: { path: { id, sessionKey } } })),
createAdministrator: (body: CreateAdministrator) => result(client.POST('/administrators', { body })), createAdministrator: (body: CreateAdministrator) => result(client.POST('/administrators', { body })),
updateAdministrator: (id: string, body: UpdateAdministrator) => result(client.PUT('/administrators/{id}', { params: { path: { id } }, body })), updateAdministrator: (id: string, body: UpdateAdministrator) => result(client.PUT('/administrators/{id}', { params: { path: { id } }, body })),
resetAdministratorPassword: (id: string, password: string) => result(client.POST('/administrators/{id}/password', { params: { path: { id } }, body: { password } })), resetAdministratorPassword: (id: string, password: string) => result(client.POST('/administrators/{id}/password', { params: { path: { id } }, body: { password } })),
@ -127,9 +146,13 @@ export const api = {
terminals: (page = 1) => result(client.GET('/terminals', { 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 } } })), users: (page = 1) => result(client.GET('/users', { params: { query: { ...query, page } } })),
tasks: (page = 1) => result(client.GET('/tasks', { params: { query: { ...query, page } } })), tasks: (page = 1) => result(client.GET('/tasks', { params: { query: { ...query, page } } })),
activeTasks: (page = 1) => result(client.GET('/tasks', { params: { query: { ...query, page, active: true } } })),
taskHistory: (filters: TaskQuery = {}) => result(client.GET('/tasks', { params: { query: filters } })),
exportTasks: (filters: TaskQuery) => result(client.GET('/tasks/export', { params: { query: filters }, parseAs: 'blob' })),
taskDiagnostics: (id: string) => result(client.GET('/tasks/{id}/diagnostics', { params: { path: { id } } })), taskDiagnostics: (id: string) => result(client.GET('/tasks/{id}/diagnostics', { params: { path: { id } } })),
reconcileProvision: (id: string) => result(client.POST('/tasks/{id}/reconcile', { params: { path: { id } } })), reconcileProvision: (id: string) => result(client.POST('/tasks/{id}/reconcile', { params: { path: { id } } })),
audits: (page = 1) => result(client.GET('/audit-logs', { params: { query: { ...query, page } } })), audits: (filters: AuditQuery = {}) => result(client.GET('/audit-logs', { params: { query: filters } })),
exportAudits: (filters: AuditQuery) => result(client.GET('/audit-logs/export', { params: { query: filters }, parseAs: 'blob' })),
discoverVMs: () => result(client.GET('/resources/vms', { params: { query } })), discoverVMs: () => result(client.GET('/resources/vms', { params: { query } })),
templates: (page = 1) => result(client.GET('/templates', { params: { query: { ...query, page } } })), templates: (page = 1) => result(client.GET('/templates', { params: { query: { ...query, page } } })),
discoverTemplates: (page = 1) => result(client.GET('/resources/templates', { params: { query: { ...query, page } } })), discoverTemplates: (page = 1) => result(client.GET('/resources/templates', { params: { query: { ...query, page } } })),
@ -166,4 +189,8 @@ export async function allPages<T>(loader: (page: number) => Promise<{ items: T[]
} }
return items return items
} }
export async function recentAndActiveTasks(): Promise<Task[]> {
const [recent, active] = await Promise.all([api.tasks(), allPages(api.activeTasks)])
return [...new Map([...recent.items, ...active].map(task => [task.id, task])).values()]
}
export const errorMessage = (e: unknown) => e instanceof ApiError ? `${e.message}${e.requestId ? `(请求 ${e.requestId})` : ''}` : e instanceof Error ? e.message : '操作失败,请重试' export const errorMessage = (e: unknown) => e instanceof ApiError ? `${e.message}${e.requestId ? `(请求 ${e.requestId})` : ''}` : e instanceof Error ? e.message : '操作失败,请重试'

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
.simple-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.simple-stats .panel{padding:22px}.simple-stats span{color:var(--vdi-muted);font-size:13px}.simple-stats strong{display:block;font-size:32px;margin-top:14px}.simple-stats small{font-size:12px;color:var(--vdi-muted);font-weight:400}.simple-section{padding:24px;margin-top:22px}.simple-section h2{font-size:18px;margin-bottom:12px}.simple-shortcuts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-top:22px}.simple-shortcuts a{padding:22px;border:1px solid var(--vdi-border);border-radius:12px;background:var(--vdi-panel-2)}.simple-shortcuts strong{display:block;color:var(--vdi-accent);font-size:15px}.simple-shortcuts span{display:block;margin-top:12px;font-size:12px;color:var(--vdi-muted);line-height:1.8}.simple-issues{display:grid;gap:18px;margin:22px 0}.simple-issues a{font-size:14px}.simple-check{display:flex;align-items:flex-start;gap:14px;border-top:1px solid var(--vdi-border);padding:18px 0}.simple-check:first-of-type{margin-top:20px}.simple-check p{color:var(--vdi-muted);line-height:1.8;margin:7px 0 0;font-size:13px}.simple-check strong{font-size:14px}.simple-check .el-tag{flex-shrink:0}.simple-checks{display:grid;margin:20px 0;max-height:45vh;overflow:auto}.simple-checks .el-checkbox{white-space:normal;height:auto;min-height:36px}.simple-checks .el-checkbox__label{white-space:normal}.simple-plan{line-height:1.9;padding:16px;border-radius:12px;background:var(--vdi-panel-2);margin-top:16px}.simple-plan ul{padding-left:20px}.simple-row{display:flex;align-items:center;justify-content:space-between;gap:12px}.simple-section>.el-select{max-width:450px;margin-bottom:18px}@media(max-width:1000px){.simple-shortcuts{grid-template-columns:1fr}.simple-stats{gap:10px}.simple-stats .panel{padding:16px}.simple-stats strong{font-size:24px}}@media(max-width:550px){.simple-stats{grid-template-columns:1fr}.simple-stats strong{margin-top:8px}.simple-section{padding:18px}.simple-check{gap:10px}.simple-section h2{font-size:17px}}

View File

@ -0,0 +1,21 @@
import { describe, expect, test } from 'vitest'
import { diagnoseTerminal } from './diagnosis'
import type { Desktop, Terminal, User } from '../api'
const terminal = { id: 't', name: '盒子', status: 'online', enabled: true } as Terminal
const user = { id: 'u', displayName: '员工', enabled: true, terminalId: 't', desktopIds: ['d'] } as User
const desktop = { id: 'd', name: '桌面', powerState: 'running', serviceState: 'ready', setupRequired: false } as Desktop
describe('终端诊断的连接前提', () => {
test('运行但服务未检测时不能报告全部通过', () => {
const result = diagnoseTerminal(terminal, [user], [{ ...desktop, serviceState: 'unknown' }])
expect(result.slice(-1)[0]?.ok).toBe(false)
expect(result.slice(-1)[0]?.detail).toContain('不代表一定不可用')
})
test('已停用设备、员工与待初始化桌面分别说明', () => {
const result = diagnoseTerminal({ ...terminal, enabled: false }, [{ ...user, enabled: false }], [{ ...desktop, setupRequired: true }])
expect(result.filter(c => !c.ok).map(c => c.title)).toEqual(['设备已启用', '员工已启用', '桌面'])
})
test('缺少关联时不猜测使用人;失效桌面授权不能报告已分配', () => {
expect(diagnoseTerminal(terminal, [], [desktop])).toHaveLength(3)
expect(diagnoseTerminal(terminal, [user], []).slice(-1)[0]?.ok).toBe(false)
})
})

View File

@ -0,0 +1,20 @@
import type { Desktop, Terminal, User } from '../api'
export interface Check { title: string; ok: boolean; detail: string }
export function diagnoseTerminal(terminal: Terminal, users: User[], desktops: Desktop[]): Check[] {
const user = users.find(u => u.terminalId === terminal.id)
const assigned = desktops.filter(d => user?.desktopIds.includes(d.id))
const checks: Check[] = [
{ title: '设备已启用', ok: terminal.enabled !== false && terminal.status !== 'disabled', detail: terminal.enabled === false || terminal.status === 'disabled' ? '设备已停用,请联系管理员重新激活。' : '设备未被停用。' },
{ title: '设备在线', ok: terminal.status === 'online', detail: terminal.status === 'online' ? '平台收到设备在线心跳。' : terminal.status === 'unregistered' ? '设备尚未激活,请在盒子上配置登记时的激活信息。' : '未收到在线心跳,请检查盒子电源、网络和平台地址。' },
{ title: '已关联员工', ok: !!user, detail: user ? `使用人:${user.displayName}` : '请在员工与桌面中关联此设备。' },
]
if (!user) return checks
checks.push({ title: '员工已启用', ok: user.enabled, detail: user.enabled ? '员工可申请桌面连接。' : '员工已停用,请由管理员核查。' })
checks.push({ title: '已分配桌面', ok: assigned.length > 0, detail: assigned.length ? `已分配 ${assigned.length} 台桌面。` : '请为员工分配已有桌面;失效授权需由管理员清理。' })
for (const desktop of assigned) {
checks.push({ title: desktop.name, ok: desktop.powerState === 'running' && desktop.serviceState === 'ready' && !desktop.setupRequired,
detail: desktop.setupRequired ? '桌面仍需完成初始化,请联系运维。' : desktop.powerState !== 'running' ? '桌面尚未运行,请先开机。' : desktop.serviceState !== 'ready' ? '桌面已运行,但平台尚未确认连接服务就绪;不代表一定不可用,请检查连接配置或联系运维。' : '桌面运行且连接服务就绪;实际网络和画面仍需连接后验证。' })
}
return checks
}

View File

@ -5,8 +5,10 @@ import { ElMessage } from 'element-plus'
import { api, allPages, errorMessage, type AdministratorAccount, type UpdateAdministrator } from '../api' import { api, allPages, errorMessage, type AdministratorAccount, type UpdateAdministrator } from '../api'
import { useAuth } from '../stores/auth' import { useAuth } from '../stores/auth'
import { useAction } from '../composables/useAction' import { useAction } from '../composables/useAction'
import AdministratorSessions from './AdministratorSessions.vue'
const auth = useAuth(), { confirm } = useAction() const auth = useAuth(), { confirm } = useAction()
const items = ref<AdministratorAccount[]>([]), loading = ref(false), busy = ref(false), error = ref(''), formError = ref(''), keyword = ref('') const items = ref<AdministratorAccount[]>([]), loading = ref(false), busy = ref(false), error = ref(''), formError = ref(''), keyword = ref('')
const sessionTarget = ref<AdministratorAccount>()
const visible = ref(false), editing = ref<AdministratorAccount>(), resetTarget = ref<AdministratorAccount>() const visible = ref(false), editing = ref<AdministratorAccount>(), resetTarget = ref<AdministratorAccount>()
const form = reactive({ username: '', displayName: '', role: 'viewer' as AdministratorAccount['role'], enabled: true, password: '', confirmation: '' }) const form = reactive({ username: '', displayName: '', role: 'viewer' as AdministratorAccount['role'], enabled: true, password: '', confirmation: '' })
const reset = reactive({ password: '', confirmation: '' }) const reset = reactive({ password: '', confirmation: '' })
@ -16,7 +18,7 @@ const passwordValid = (password: string, confirmation: string) => [...password].
const canSave = computed(() => !!form.displayName.trim() && (!!editing.value || (/^[a-zA-Z0-9][a-zA-Z0-9._-]{2,79}$/.test(form.username) && passwordValid(form.password, form.confirmation)))) const canSave = computed(() => !!form.displayName.trim() && (!!editing.value || (/^[a-zA-Z0-9][a-zA-Z0-9._-]{2,79}$/.test(form.username) && passwordValid(form.password, form.confirmation))))
async function load(quiet = false) { if (!quiet) loading.value = true; try { items.value = await allPages(api.administrators); error.value = '' } catch (e) { error.value = errorMessage(e) } finally { loading.value = false } } async function load(quiet = false) { if (!quiet) loading.value = true; try { items.value = await allPages(api.administrators); error.value = '' } catch (e) { error.value = errorMessage(e) } finally { loading.value = false } }
let timer: ReturnType<typeof setInterval> let timer: ReturnType<typeof setInterval>
onMounted(() => { void load(); timer = setInterval(() => { if (!document.hidden && !busy.value && !visible.value && !resetTarget.value) void load(true) }, 5000) }) onMounted(() => { void load(); timer = setInterval(() => { if (!document.hidden && !busy.value && !visible.value && !resetTarget.value && !sessionTarget.value) void load(true) }, 5000) })
onUnmounted(() => clearInterval(timer)) onUnmounted(() => clearInterval(timer))
function edit(item?: AdministratorAccount) { editing.value = item; formError.value = ''; Object.assign(form, { username: item?.username || '', displayName: item?.displayName || '', role: item?.role || 'viewer', enabled: item?.enabled ?? true, password: '', confirmation: '' }); visible.value = true } function edit(item?: AdministratorAccount) { editing.value = item; formError.value = ''; Object.assign(form, { username: item?.username || '', displayName: item?.displayName || '', role: item?.role || 'viewer', enabled: item?.enabled ?? true, password: '', confirmation: '' }); visible.value = true }
watch(visible, open => { if (!open) { form.password = ''; form.confirmation = ''; formError.value = '' } }) watch(visible, open => { if (!open) { form.password = ''; form.confirmation = ''; formError.value = '' } })
@ -44,10 +46,11 @@ async function resetPassword() {
<div class="account-heading"><div><div class="section-kicker">ADMINISTRATOR ACCOUNTS</div><h2>管理员账号</h2><p class="subtext">维护控制台账号、角色与启用状态</p></div><div class="actions"><el-button :icon="Refresh" :loading="loading" @click="load()">刷新</el-button><el-button type="primary" :icon="Plus" @click="edit()">新增管理员</el-button></div></div> <div class="account-heading"><div><div class="section-kicker">ADMINISTRATOR ACCOUNTS</div><h2>管理员账号</h2><p class="subtext">维护控制台账号、角色与启用状态</p></div><div class="actions"><el-button :icon="Refresh" :loading="loading" @click="load()">刷新</el-button><el-button type="primary" :icon="Plus" @click="edit()">新增管理员</el-button></div></div>
<el-alert v-if="error" type="error" :title="error" :closable="false" show-icon style="margin:20px 0" /> <el-alert v-if="error" type="error" :title="error" :closable="false" show-icon style="margin:20px 0" />
<div class="toolbar" style="margin-top:24px"><el-input v-model="keyword" placeholder="搜索账号 / 显示名称" aria-label="搜索管理员" clearable /></div> <div class="toolbar" style="margin-top:24px"><el-input v-model="keyword" placeholder="搜索账号 / 显示名称" aria-label="搜索管理员" clearable /></div>
<el-table v-loading="loading" :data="filtered" empty-text="暂无符合条件的管理员"><el-table-column label="账号" min-width="150"><template #default="{ row }"><strong>{{ row.username }}</strong><el-tag v-if="row.id === auth.user?.id" size="small" type="info" style="margin-left:8px">本人</el-tag></template></el-table-column><el-table-column prop="displayName" label="显示名称" min-width="135" /><el-table-column label="角色" min-width="130"><template #default="{ row }">{{ roles[row.role as keyof typeof roles] }}</template></el-table-column><el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag></template></el-table-column><el-table-column label="操作" min-width="180"><template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="edit(row)">编辑</el-button><el-button v-if="row.id !== auth.user?.id" link :disabled="busy" @click="resetTarget = row">重置密码</el-button></template></el-table-column></el-table> <el-table v-loading="loading" :data="filtered" empty-text="暂无符合条件的管理员"><el-table-column label="账号" min-width="150"><template #default="{ row }"><strong>{{ row.username }}</strong><el-tag v-if="row.id === auth.user?.id" size="small" type="info" style="margin-left:8px">本人</el-tag></template></el-table-column><el-table-column prop="displayName" label="显示名称" min-width="135" /><el-table-column label="角色" min-width="130"><template #default="{ row }">{{ roles[row.role as keyof typeof roles] }}</template></el-table-column><el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag></template></el-table-column><el-table-column label="操作" min-width="240"><template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="edit(row)">编辑</el-button><el-button link type="primary" :disabled="busy" @click="sessionTarget = row">在线登录</el-button><el-button v-if="row.id !== auth.user?.id" link :disabled="busy" @click="resetTarget = row">重置密码</el-button></template></el-table-column></el-table>
<p class="account-note">本人密码通过右上角钥匙按钮修改。不能停用自己或修改自己的角色;至少保留一个启用的超级管理员。停用账号会保留历史审计。</p> <p class="account-note">本人密码通过右上角钥匙按钮修改。不能停用自己或修改自己的角色;至少保留一个启用的超级管理员。停用账号会保留历史审计。</p>
<el-dialog v-model="visible" :title="editing ? '编辑管理员' : '新增管理员'" width="520px" :close-on-click-modal="false" :close-on-press-escape="!busy" :show-close="!busy"><el-alert v-if="formError" type="error" :title="formError" :closable="false" show-icon /><el-form label-position="top" :disabled="busy"><el-form-item label="账号"><el-input v-model="form.username" :disabled="!!editing" maxlength="80" autocomplete="off" aria-label="管理员账号" placeholder="3~80 位字母、数字、点、下划线或短横线" /></el-form-item><el-form-item label="显示名称"><el-input v-model="form.displayName" maxlength="80" aria-label="管理员显示名称" /></el-form-item><el-form-item label="角色"><el-select v-model="form.role" :disabled="editing?.id === auth.user?.id" aria-label="管理员角色"><el-option v-for="(label, value) in roles" :key="value" :value="value" :label="label" /></el-select></el-form-item><el-form-item v-if="editing" label="启用"><el-switch v-model="form.enabled" :disabled="editing.id === auth.user?.id" aria-label="管理员启用状态" /></el-form-item><template v-else><el-form-item label="初始密码"><el-input v-model="form.password" type="password" show-password maxlength="72" autocomplete="new-password" aria-label="管理员初始密码" /></el-form-item><el-form-item label="确认初始密码"><el-input v-model="form.confirmation" type="password" show-password maxlength="72" autocomplete="new-password" aria-label="确认管理员初始密码" /></el-form-item><p class="subtext">密码至少 12 个字符,UTF-8 编码不超过 72 字节。</p></template></el-form><template #footer><el-button :disabled="busy" @click="visible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!canSave" @click="save">保存管理员</el-button></template></el-dialog> <el-dialog v-model="visible" :title="editing ? '编辑管理员' : '新增管理员'" width="520px" :close-on-click-modal="false" :close-on-press-escape="!busy" :show-close="!busy"><el-alert v-if="formError" type="error" :title="formError" :closable="false" show-icon /><el-form label-position="top" :disabled="busy"><el-form-item label="账号"><el-input v-model="form.username" :disabled="!!editing" maxlength="80" autocomplete="off" aria-label="管理员账号" placeholder="3~80 位字母、数字、点、下划线或短横线" /></el-form-item><el-form-item label="显示名称"><el-input v-model="form.displayName" maxlength="80" aria-label="管理员显示名称" /></el-form-item><el-form-item label="角色"><el-select v-model="form.role" :disabled="editing?.id === auth.user?.id" aria-label="管理员角色"><el-option v-for="(label, value) in roles" :key="value" :value="value" :label="label" /></el-select></el-form-item><el-form-item v-if="editing" label="启用"><el-switch v-model="form.enabled" :disabled="editing.id === auth.user?.id" aria-label="管理员启用状态" /></el-form-item><template v-else><el-form-item label="初始密码"><el-input v-model="form.password" type="password" show-password maxlength="72" autocomplete="new-password" aria-label="管理员初始密码" /></el-form-item><el-form-item label="确认初始密码"><el-input v-model="form.confirmation" type="password" show-password maxlength="72" autocomplete="new-password" aria-label="确认管理员初始密码" /></el-form-item><p class="subtext">密码至少 12 个字符,UTF-8 编码不超过 72 字节。</p></template></el-form><template #footer><el-button :disabled="busy" @click="visible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!canSave" @click="save">保存管理员</el-button></template></el-dialog>
<el-dialog :model-value="!!resetTarget" :title="`重置密码 · ${resetTarget?.username || ''}`" width="480px" :close-on-click-modal="false" :close-on-press-escape="!busy" :show-close="!busy" @close="resetTarget = undefined"><el-alert v-if="formError" type="error" :title="formError" :closable="false" /><el-form label-position="top" :disabled="busy"><el-form-item label="新密码"><el-input v-model="reset.password" type="password" show-password autocomplete="new-password" aria-label="重置后的新密码" maxlength="72" /></el-form-item><el-form-item label="确认新密码"><el-input v-model="reset.confirmation" type="password" show-password autocomplete="new-password" aria-label="确认重置后的新密码" maxlength="72" /></el-form-item><p class="subtext">至少 12 个字符,UTF-8 编码不超过 72 字节。保存后该账号所有登录失效。</p></el-form><template #footer><el-button :disabled="busy" @click="resetTarget = undefined">取消</el-button><el-button type="primary" :loading="busy" :disabled="!passwordValid(reset.password, reset.confirmation)" @click="resetPassword">重置密码</el-button></template></el-dialog> <el-dialog :model-value="!!resetTarget" :title="`重置密码 · ${resetTarget?.username || ''}`" width="480px" :close-on-click-modal="false" :close-on-press-escape="!busy" :show-close="!busy" @close="resetTarget = undefined"><el-alert v-if="formError" type="error" :title="formError" :closable="false" /><el-form label-position="top" :disabled="busy"><el-form-item label="新密码"><el-input v-model="reset.password" type="password" show-password autocomplete="new-password" aria-label="重置后的新密码" maxlength="72" /></el-form-item><el-form-item label="确认新密码"><el-input v-model="reset.confirmation" type="password" show-password autocomplete="new-password" aria-label="确认重置后的新密码" maxlength="72" /></el-form-item><p class="subtext">至少 12 个字符,UTF-8 编码不超过 72 字节。保存后该账号所有登录失效。</p></el-form><template #footer><el-button :disabled="busy" @click="resetTarget = undefined">取消</el-button><el-button type="primary" :loading="busy" :disabled="!passwordValid(reset.password, reset.confirmation)" @click="resetPassword">重置密码</el-button></template></el-dialog>
<AdministratorSessions :account="sessionTarget" @close="sessionTarget = undefined" />
</template> </template>
<style scoped> <style scoped>
.account-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;flex-wrap:wrap}.account-heading h2{margin-top:6px}.account-note{margin-top:22px;font-size:12px;line-height:1.8;color:var(--vdi-muted)} .account-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;flex-wrap:wrap}.account-heading h2{margin-top:6px}.account-note{margin-top:22px;font-size:12px;line-height:1.8;color:var(--vdi-muted)}

View File

@ -0,0 +1,56 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { api, errorMessage, type AdministratorAccount, type AdministratorSession } from '../api'
import { useAction } from '../composables/useAction'
const props = defineProps<{ account?: AdministratorAccount }>()
const emit = defineEmits<{ close: [] }>()
const { confirm } = useAction()
const items = ref<AdministratorSession[]>([]), total = ref(0), page = ref(1), loading = ref(false), busy = ref(false), error = ref('')
let generation = 0
onBeforeUnmount(() => { generation++ })
const time = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '升级前登录,时间未知'
async function load() {
const account = props.account, request = ++generation
if (!account) return
loading.value = true; error.value = ''; items.value = []
try {
const result = await api.administratorSessions(account.id, page.value)
if (request !== generation) return
total.value = result.total
if (page.value > 1 && !result.items.length && result.total <= (page.value - 1) * 20) { page.value = Math.max(1, Math.ceil(result.total / 20)); await load(); return }
items.value = result.items
} catch (e) { if (request === generation) { error.value = errorMessage(e); total.value = 0 } }
finally { if (request === generation) loading.value = false }
}
watch(() => props.account, account => { generation++; items.value = []; total.value = 0; page.value = 1; error.value = ''; if (account) void load() }, { immediate: true })
async function revoke(session: AdministratorSession) {
const account = props.account
if (!account || busy.value || session.current) return
busy.value = true
try {
if (!await confirm(`撤销登录 · ${account.username}`, `撤销来自 ${session.clientIp || '未知来源'} 的这次登录?该登录将无法继续请求管理接口,需要重新登录。`)) return
await api.revokeAdministratorSession(account.id, session.key)
ElMessage.success('已撤销指定登录'); await load()
} catch (e) { error.value = errorMessage(e); items.value = []; total.value = 0 }
finally { busy.value = false }
}
</script>
<template>
<el-dialog :model-value="!!account" :title="`在线登录 · ${account?.username || ''}`" width="920px" :close-on-click-modal="false" :close-on-press-escape="!busy" :show-close="!busy" @close="emit('close')">
<p class="session-note">显示尚未退出、撤销或到期的登录凭据,不代表浏览器此刻仍在使用。当前登录请通过侧栏退出;撤销其他登录需要确认。来源信息仅供排查。</p>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<div class="session-toolbar"><span>共 {{ total }} 次有效登录</span><el-button :disabled="busy" :loading="loading" @click="load">刷新登录列表</el-button></div>
<el-table v-loading="loading" :data="items" empty-text="没有有效登录">
<el-table-column label="来源地址" min-width="155"><template #default="{ row }"><span>{{ row.clientIp || '升级前登录,来源未知' }}</span><el-tag v-if="row.current" size="small" type="success" class="current-session">当前登录</el-tag></template></el-table-column>
<el-table-column label="登录 / 到期时间" min-width="205"><template #default="{ row }"><div>{{ time(row.createdAt) }}</div><div class="secondary">到期 {{ time(row.expiresAt) }}</div></template></el-table-column>
<el-table-column prop="userAgent" label="浏览器 / 客户端" min-width="220" show-overflow-tooltip><template #default="{ row }">{{ row.userAgent || '未知客户端' }}</template></el-table-column>
<el-table-column label="操作" width="115" fixed="right"><template #default="{ row }"><el-button link type="danger" :disabled="row.current || busy || loading" @click="revoke(row)">撤销登录</el-button></template></el-table-column>
</el-table>
<el-pagination v-model:current-page="page" :disabled="busy || loading" :total="total" :page-size="20" layout="total, prev, pager, next" @current-change="load" />
<template #footer><el-button :disabled="busy" @click="emit('close')">关闭</el-button></template>
</el-dialog>
</template>
<style scoped>
.session-note{color:var(--vdi-muted);font-size:12px;line-height:1.8;margin-bottom:18px}.session-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:18px 0;font-size:12px;color:var(--vdi-muted)}.current-session{display:block;width:fit-content;margin-top:7px}.el-pagination{margin-top:20px;flex-wrap:wrap}.secondary{margin-top:5px;font-size:11px}
</style>

View File

@ -0,0 +1,70 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api, errorMessage, type Audit, type AuditQuery } from '../api'
import { auditActionLabels } from '../api/audit-labels'
import StatusTag from './StatusTag.vue'
const rows = ref<Audit[]>([]), total = ref(0), page = ref(1), busy = ref(false), exporting = ref(false), error = ref(''), loaded = ref(false)
const keyword = ref(''), actor = ref(''), action = ref(''), result = ref(''), range = ref<[Date, Date] | null>(null)
const applied = ref<AuditQuery>({})
const shortcuts = [1, 7, 30, 90].map(days => ({ text: days === 1 ? '最近 24 小时' : `最近 ${days} 天`, value: () => { const end = new Date(); return [new Date(end.getTime() - days * 86400000), end] } }))
const results: Record<string, string> = { success: '成功', submitted: '已提交', failed: '失败', timeout: '结果未确认', denied: '无权限' }
const time = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false })
let generation = 0
onBeforeUnmount(() => { generation++ })
async function load(reset = false) {
if (reset) {
if (range.value && range.value[1] <= range.value[0]) { ElMessage.warning('结束时间必须晚于开始时间'); return }
applied.value = { keyword: keyword.value, actor: actor.value, action: action.value, result: result.value, from: range.value?.[0].toISOString(), to: range.value?.[1].toISOString() || new Date().toISOString() }
page.value = 1
}
const request = ++generation
busy.value = true; error.value = ''; loaded.value = false; rows.value = []
try {
const response = await api.audits({ ...applied.value, page: page.value, pageSize: 20 })
if (request !== generation) return
rows.value = response.items; total.value = response.total; loaded.value = true
} catch (e) { if (request === generation) { error.value = errorMessage(e); total.value = 0 } }
finally { if (request === generation) busy.value = false }
}
function clear() { keyword.value = ''; actor.value = ''; action.value = ''; result.value = ''; range.value = null; void load(true) }
async function download() {
exporting.value = true
try {
const blob = await api.exportAudits(applied.value)
const url = URL.createObjectURL(blob), link = document.createElement('a')
link.href = url; link.download = `操作审计-${new Date().toISOString().slice(0, 10)}.csv`; link.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
ElMessage.success('已生成筛选结果的 CSV 文件')
} catch (e) { ElMessage.error(errorMessage(e)) } finally { exporting.value = false }
}
onMounted(() => { void load(true) })
</script>
<template>
<div class="audit-filters">
<el-input v-model="keyword" aria-label="审计关键词" placeholder="对象 / 请求标识 / 动作代码" maxlength="200" clearable @keyup.enter="load(true)" />
<el-input v-model="actor" aria-label="审计操作者" placeholder="操作者包含" maxlength="200" clearable @keyup.enter="load(true)" />
<el-select v-model="action" aria-label="审计动作" placeholder="全部动作" filterable allow-create clearable><el-option v-for="(label, value) in auditActionLabels" :key="value" :value="value" :label="label" /></el-select>
<el-select v-model="result" aria-label="审计结果" placeholder="全部结果" filterable allow-create clearable><el-option v-for="(label, value) in results" :key="value" :value="value" :label="label" /></el-select>
<el-date-picker v-model="range" type="datetimerange" aria-label="审计时间范围" start-placeholder="开始时间" end-placeholder="结束时间" :shortcuts="shortcuts" format="YYYY-MM-DD HH:mm:ss" />
<div class="audit-actions"><el-button type="primary" :loading="busy" @click="load(true)">查询审计</el-button><el-button :disabled="busy" @click="clear">重置</el-button><el-button :loading="exporting" :disabled="busy || !loaded || !total || total > 10000" @click="download">导出查询结果 CSV</el-button></div>
</div>
<p class="subtext audit-hint">不选时间可查询全部历史。导出按已查询条件,包含全部匹配页,单次最多 10000 条。CSV 时间为 UTC,动作与结果保留原始代码。</p>
<p v-if="loaded && applied.to" class="subtext audit-hint">查询结果截至 {{ time(applied.to) }},更新条件后点击“查询审计”。</p>
<el-alert v-if="loaded && total > 10000" title="匹配记录超过 10000 条,请缩小时间范围或增加筛选条件后导出。" type="warning" :closable="false" show-icon />
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<el-table v-loading="busy" :data="rows" empty-text="没有匹配的审计记录">
<el-table-column prop="actor" label="操作者" min-width="110" />
<el-table-column label="操作" min-width="150"><template #default="{ row }">{{ auditActionLabels[row.action] || row.action }}<div class="mono secondary">{{ row.action }}</div></template></el-table-column>
<el-table-column prop="target" label="对象" min-width="180" show-overflow-tooltip />
<el-table-column label="结果" width="125"><template #default="{ row }"><StatusTag :value="row.result" /></template></el-table-column>
<el-table-column label="时间" min-width="180"><template #default="{ row }">{{ time(row.createdAt) }}</template></el-table-column>
<el-table-column prop="requestId" label="请求标识" min-width="200" show-overflow-tooltip />
</el-table>
<el-pagination v-model:current-page="page" :disabled="busy" :total="total" :page-size="20" layout="total, prev, pager, next" @current-change="load()" />
</template>
<style scoped>
.audit-filters{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.audit-filters>.el-input{width:220px}.audit-filters>.el-select{width:180px}.audit-filters :deep(.el-date-editor){width:390px;flex-grow:0;max-width:100%;box-sizing:border-box}.audit-actions{display:flex;gap:10px;flex-wrap:wrap}.audit-actions .el-button{margin:0}.audit-hint{line-height:1.7;margin:10px 0}.el-alert{margin:12px 0}.el-pagination{margin-top:20px;flex-wrap:wrap}.mono{font-size:11px;margin-top:4px;overflow-wrap:anywhere}
@media(max-width:600px){.audit-filters>.el-input,.audit-filters>.el-select{width:100%}.audit-filters :deep(.el-date-editor){width:100%;min-width:0}.audit-actions{width:100%}.audit-actions .el-button:last-child{width:100%}}
</style>

View File

@ -0,0 +1,87 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { api, errorMessage, type MFAStatus, type MFASetup } from '../api'
import { useAction } from '../composables/useAction'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const { confirm } = useAction()
const status = ref<MFAStatus>(), setup = ref<MFASetup>(), recovery = ref<string[]>([]), password = ref(''), code = ref(''), error = ref(''), loading = ref(false), busy = ref(false), action = ref<'disable' | 'rotate'>()
let generation = 0
onBeforeUnmount(() => { generation++ })
watch(() => props.modelValue, open => {
generation++; status.value = undefined; setup.value = undefined; recovery.value = []; password.value = ''; code.value = ''; error.value = ''; action.value = undefined
if (open) void load()
})
async function load() {
const request = ++generation; loading.value = true; error.value = ''
try { const result = await api.mfaStatus(); if (request === generation) status.value = result }
catch (e) { if (request === generation) error.value = errorMessage(e) }
finally { if (request === generation) loading.value = false }
}
async function begin() {
if (busy.value || !password.value) return
busy.value = true; error.value = ''
try { setup.value = await api.beginMFA(password.value); password.value = ''; code.value = '' }
catch (e) { error.value = errorMessage(e) } finally { busy.value = false }
}
async function enable() {
if (busy.value || !/^\d{6}$/.test(code.value)) return
busy.value = true; error.value = ''
try { recovery.value = (await api.confirmMFA(code.value)).recoveryCodes; setup.value = undefined; code.value = ''; await load(); ElMessage.success('双重验证已开启,其他登录已失效') }
catch (e) { error.value = errorMessage(e) } finally { busy.value = false }
}
async function change() {
if (busy.value || !action.value || !password.value || !code.value.trim()) return
busy.value = true; error.value = ''
try {
const disable = action.value === 'disable'
if (!await confirm(disable ? '关闭双重验证' : '重新生成恢复码', disable ? '关闭后登录只需密码,其他登录将失效。确认关闭?' : '现有恢复码将全部作废,其他登录将失效。确认重新生成?')) return
if (disable) { await api.disableMFA(password.value, code.value.trim()); ElMessage.success('已关闭双重验证') }
else { recovery.value = (await api.rotateMFARecovery(password.value, code.value.trim())).recoveryCodes; ElMessage.success('已更新恢复码,旧恢复码全部作废') }
action.value = undefined; password.value = ''; code.value = ''; await load()
} catch (e) { error.value = errorMessage(e) } finally { busy.value = false }
}
function choose(value: 'disable' | 'rotate') { action.value = value; password.value = ''; code.value = ''; error.value = '' }
function download() {
const blob = new Blob([`VDI Cloud 一次性恢复码\n每条只能使用一次,请离线妥善保存。\n\n${recovery.value.join('\n')}\n`], { type: 'text/plain;charset=utf-8' }), url = URL.createObjectURL(blob), link = document.createElement('a')
link.href = url; link.download = 'vdi-cloud-recovery-codes.txt'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
}
</script>
<template>
<el-dialog :model-value="modelValue" title="本人双重验证" width="560px" :close-on-click-modal="false" :close-on-press-escape="!busy && !recovery.length" :show-close="!busy && !recovery.length" @update:model-value="emit('update:modelValue', $event)">
<p class="mfa-note">使用身份验证器生成的 6 位动态码,或一次性恢复码完成登录。仅修改本人账号;开启、关闭或更新恢复码会撤销其他登录。</p>
<el-alert v-if="error" type="error" :title="error" :closable="false" show-icon />
<div v-loading="loading" class="mfa-body">
<template v-if="recovery.length">
<el-alert type="warning" title="恢复码仅显示这一次,请先保存" :closable="false" show-icon />
<p class="mfa-note">身份验证器不可用时,可用密码和一条恢复码登录。每条只能使用一次;关闭此窗口后无法再次查看。</p>
<div class="recovery-codes"><code v-for="value in recovery" :key="value">{{ value }}</code></div>
<el-button @click="download">下载恢复码</el-button>
</template>
<template v-else-if="setup">
<p class="mfa-note">在身份验证器中手动添加账号:名称为 VDI Cloud,密钥如下,使用基于时间的 6 位验证码(每 30 秒更新)。</p>
<div class="mfa-secret" aria-label="身份验证器密钥">{{ setup.secret }}</div>
<p class="mfa-note">绑定有效至 {{ new Date(setup.expiresAt).toLocaleTimeString('zh-CN') }}。输入动态码确认后才会启用。</p>
<el-form label-position="top" :disabled="busy" @submit.prevent="enable"><el-form-item label="身份验证器动态码"><el-input v-model="code" aria-label="绑定动态码" autocomplete="one-time-code" inputmode="numeric" maxlength="6" /></el-form-item></el-form>
<el-button type="primary" :disabled="!/^\d{6}$/.test(code)" :loading="busy" @click="enable">验证并开启</el-button>
</template>
<template v-else-if="status">
<div class="mfa-state"><strong>当前状态</strong><el-tag :type="status.enabled ? 'success' : 'info'">{{ status.enabled ? '已开启' : '未开启' }}</el-tag></div>
<p v-if="status.enabled" class="mfa-note">剩余 {{ status.recoveryRemaining }} 条恢复码。同一动态码只能成功使用一次,刚使用后请等待验证器刷新。</p>
<div v-if="status.enabled && !action" class="mfa-actions"><el-button @click="choose('rotate')">重新生成恢复码</el-button><el-button type="danger" plain @click="choose('disable')">关闭双重验证</el-button></div>
<el-form v-if="!status.enabled || action" label-position="top" :disabled="busy" @submit.prevent="status.enabled ? change() : begin()">
<el-form-item label="当前密码"><el-input v-model="password" type="password" show-password autocomplete="current-password" aria-label="双重验证当前密码" maxlength="200" /></el-form-item>
<el-form-item v-if="action" label="动态码或恢复码"><el-input v-model="code" autocomplete="one-time-code" aria-label="双重验证确认码" maxlength="80" /></el-form-item>
<el-button v-if="!status.enabled" type="primary" :loading="busy" :disabled="!password" @click="begin">开始绑定身份验证器</el-button>
<template v-else><el-button type="primary" :loading="busy" :disabled="!password || !code.trim()" @click="change">{{ action === 'disable' ? '确认关闭双重验证' : '生成新恢复码' }}</el-button><el-button :disabled="busy" @click="action = undefined; password = ''; code = ''">返回</el-button></template>
</el-form>
</template>
<el-button v-else-if="error" @click="load">重新加载验证设置</el-button>
</div>
<template #footer><el-button :disabled="busy" @click="emit('update:modelValue', false)">{{ recovery.length ? '已保存,关闭' : '关闭' }}</el-button></template>
</el-dialog>
</template>
<style scoped>
.mfa-note{font-size:12px;color:var(--vdi-muted);line-height:1.8;margin:14px 0}.mfa-body{min-height:80px;margin-top:18px}.mfa-state{display:flex;justify-content:space-between;align-items:center;margin:18px 0}.mfa-secret{padding:16px;background:var(--vdi-panel-2);border:1px solid var(--vdi-border);border-radius:10px;word-break:break-all;letter-spacing:2px;font-family:monospace;user-select:all}.mfa-actions{display:flex;flex-wrap:wrap;gap:10px;margin:20px 0}.mfa-actions .el-button{margin:0}.recovery-codes{display:grid;gap:10px;margin:18px 0;font-size:12px;overflow-wrap:anywhere}.recovery-codes code{padding:8px 10px;background:var(--vdi-panel-2);border-radius:6px;user-select:all}.mfa-body .el-form{margin-top:20px}
</style>

View File

@ -0,0 +1,94 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { api, errorMessage, type Task, type TaskQuery } from '../api'
import { auditActionLabels as labels } from '../api/audit-labels'
import { usePlatform } from '../stores/platform'
import { useAuth } from '../stores/auth'
import { useAction } from '../composables/useAction'
import { ElMessage } from 'element-plus'
import StatusTag from './StatusTag.vue'
const data = usePlatform(), auth = useAuth(), { busy, run, confirm } = useAction()
const confirmingGPU = ref(false)
const rows = ref<Task[]>([]), total = ref(0), page = ref(1), loading = ref(false), loaded = ref(false), error = ref(''), exporting = ref(false), selected = ref<Task | null>(null)
const keyword = ref(''), actor = ref(''), status = ref(''), action = ref(''), kind = ref(''), range = ref<[Date, Date] | null>(null), applied = ref<TaskQuery>({})
const statuses: Record<string, string> = { queued: '排队中', running: '执行中', success: '成功', failed: '失败', timeout: '结果未确认' }
const kinds: Record<string, string> = { desktop: '桌面电源', terminal: '终端命令', provision: '克隆发放', hardware: '规格调整', snapshot: '快照', 'batch-desktop': '批量电源子任务', 'gpu-prepare': '显卡桌面准备', resource: '资源同步' }
const shortcuts = [1, 7, 30, 90].map(days => ({ text: days === 1 ? '最近 24 小时' : `最近 ${days} 天`, value: () => { const end = new Date(); return [new Date(end.getTime() - days * 86400000), end] } }))
const time = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false })
let generation = 0
onBeforeUnmount(() => { generation++ })
async function load(reset = false, background = false) {
if (reset) {
if (range.value && range.value[1] <= range.value[0]) { ElMessage.warning('结束时间必须晚于开始时间'); return }
applied.value = { keyword: keyword.value, actor: actor.value, status: status.value, action: action.value, kind: kind.value, from: range.value?.[0].toISOString(), to: range.value?.[1].toISOString() || new Date().toISOString() }
page.value = 1
}
const request = ++generation
if (!background) { loading.value = true; rows.value = []; loaded.value = false }
error.value = ''
try {
const response = await api.taskHistory({ ...applied.value, page: page.value, pageSize: 20 })
if (request !== generation) return
rows.value = response.items; total.value = response.total; loaded.value = true
const current = rows.value.find(task => task.id === selected.value?.id)
if (current) selected.value = current
} catch (e) { if (request === generation) { error.value = errorMessage(e); rows.value = []; total.value = 0; loaded.value = false } }
finally { if (request === generation) loading.value = false }
}
watch(() => data.updatedAt, () => { if (!loading.value) void load(false, true) })
function clear() { keyword.value = ''; actor.value = ''; status.value = ''; action.value = ''; kind.value = ''; range.value = null; void load(true) }
async function reconcile(work: () => Promise<unknown>, message: string) { if (await run(work, message)) { selected.value = null; await load() } }
async function reconcileGPU() {
const task = selected.value
if (!task || task.kind !== 'gpu-prepare' || task.status !== 'timeout' || !auth.canOperate || busy.value || confirmingGPU.value) return
confirmingGPU.value = true
try {
if (!await confirm(`核对显卡准备结果 · ${task.targetName}`, '只查询原 PVE 任务及组内桌面的实际电源状态,不重发电源操作。核对完成会结束此未确认流程并释放预留;原任务标记为失败,需要重新准备时请另行操作。')) return
await reconcile(() => api.reconcileGPUPrepare(task.id), '已核对原任务并结束未确认流程,未重发电源操作')
} finally { confirmingGPU.value = false }
}
async function downloadDiagnostics(id: string) {
try {
const diagnostic = await api.taskDiagnostics(id), 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(); setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (e) { ElMessage.error(errorMessage(e)) }
}
async function download() {
exporting.value = true
try {
const blob = await api.exportTasks(applied.value), url = URL.createObjectURL(blob), link = document.createElement('a')
link.href = url; link.download = `执行任务-${new Date().toISOString().slice(0, 10)}.csv`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
ElMessage.success('已生成筛选任务的 CSV 文件')
} catch (e) { ElMessage.error(errorMessage(e)) } finally { exporting.value = false }
}
onMounted(() => { void load(true) })
</script>
<template>
<div class="task-filters">
<el-input v-model="keyword" aria-label="搜索任务" placeholder="目标 / 任务标识 / 执行结果" maxlength="200" clearable @keyup.enter="load(true)" />
<el-input v-model="actor" aria-label="任务操作者" placeholder="操作者包含" maxlength="200" clearable @keyup.enter="load(true)" />
<el-select v-model="status" aria-label="任务状态筛选" placeholder="全部任务状态" clearable><el-option v-for="(label, value) in statuses" :key="value" :label="label" :value="value" /></el-select>
<el-select v-model="action" aria-label="任务动作" placeholder="全部动作" filterable allow-create clearable><el-option v-for="(label, value) in labels" :key="value" :label="label" :value="value" /></el-select>
<el-select v-model="kind" aria-label="任务类型" placeholder="全部类型" filterable allow-create clearable><el-option v-for="(label, value) in kinds" :key="value" :label="label" :value="value" /></el-select>
<el-date-picker v-model="range" type="datetimerange" aria-label="任务时间范围" start-placeholder="开始时间" end-placeholder="结束时间" :shortcuts="shortcuts" format="YYYY-MM-DD HH:mm:ss" />
<div class="task-actions"><el-button type="primary" :loading="loading" @click="load(true)">查询任务</el-button><el-button :disabled="loading" @click="clear">重置</el-button><el-button :loading="exporting" :disabled="loading || !loaded || !total || total > 10000" @click="download">导出任务 CSV</el-button></div>
</div>
<p class="subtext task-hint">不选时间可查全部历史;按提交时间筛选。任务状态随平台刷新,新增任务请重新查询。导出包含全部匹配页,单次最多 10000 条,时间为 UTC。</p>
<p v-if="loaded && applied.to" class="subtext task-hint">查询范围截至 {{ time(applied.to) }},修改条件后点击“查询任务”。</p>
<el-alert v-if="error" type="error" :title="error" :closable="false" show-icon />
<el-alert v-if="loaded && total > 10000" type="warning" title="匹配任务超过 10000 条,请缩小时间范围或增加筛选条件后导出。" :closable="false" show-icon />
<el-table v-loading="loading" :data="rows" empty-text="没有匹配的执行任务">
<el-table-column label="操作目标 / 任务" min-width="220"><template #default="{ row }"><el-button link type="primary" @click="selected = row">{{ row.targetName }}</el-button><div class="mono secondary">{{ row.id }}</div></template></el-table-column>
<el-table-column label="操作 / 类型" min-width="150"><template #default="{ row }">{{ labels[row.action] || row.action }}<div class="secondary">{{ kinds[row.kind] || row.kind }}</div></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="120" />
<el-table-column label="提交 / 更新时间" min-width="185"><template #default="{ row }">{{ time(row.createdAt) }}<div class="secondary">{{ time(row.updatedAt) }}</div></template></el-table-column>
<el-table-column prop="message" label="执行结果" min-width="240" show-overflow-tooltip />
</el-table>
<el-pagination v-model:current-page="page" :disabled="loading" :total="total" :page-size="20" layout="total, prev, pager, next" @current-change="load()" />
<el-drawer :model-value="!!selected" title="任务详情" size="580px" @close="selected = null"><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><el-button v-if="selected?.kind === 'provision' && selected.status === 'timeout' && selected.providerRef && auth.canAdmin" :loading="busy" style="margin-top:20px" @click="reconcile(() => api.reconcileProvision(selected!.id), '已提交核对,只查询原 PVE 任务')">核对克隆结果</el-button><el-button v-if="selected?.kind === 'hardware' && selected.status === 'timeout' && auth.canAdmin" :loading="busy" style="margin-top:20px" @click="reconcile(() => api.reconcileHardware(selected!.id), '已核对实际规格,未重发修改')">核对规格结果</el-button><el-button v-if="selected?.kind === 'snapshot' && selected.status === 'timeout' && auth.canAdmin" :loading="busy" style="margin-top:20px" @click="reconcile(() => api.reconcileSnapshot(selected!.id), '已核对原快照任务,未重发修改')">核对快照结果</el-button><el-button v-if="selected?.kind === 'batch-desktop' && selected.status === 'timeout' && auth.canOperate" :loading="busy" style="margin-top:20px" @click="reconcile(() => api.reconcileBatchPower(selected!.id), '已核对原电源任务,未重发操作')">核对电源结果</el-button><el-button v-if="selected?.kind === 'gpu-prepare' && selected.status === 'timeout' && auth.canOperate" :loading="busy" :disabled="confirmingGPU" style="margin-top:20px" @click="reconcileGPU">核对显卡准备结果</el-button><p v-if="selected && auth.canOperate && ['terminal', 'desktop', 'gpu-prepare', 'batch-desktop', 'hardware', 'snapshot', 'provision'].includes(selected.kind)" style="margin-top:20px"><router-link :to="{ path: '/diagnostics', query: { targetType: selected.kind === 'terminal' ? 'terminal' : 'desktop', targetId: selected.targetId } }">查看操作对象关联诊断 →</router-link></p><p class="subtext" style="margin-top:22px">结果未确认时先核对实际设备状态,再决定是否重新提交。系统不会自动重放操作。</p></el-drawer>
</template>
<style scoped>
.task-filters{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.task-filters>.el-input{width:220px}.task-filters>.el-select{width:180px}.task-filters :deep(.el-date-editor){width:390px;flex-grow:0;max-width:100%;box-sizing:border-box}.task-actions{display:flex;gap:10px;flex-wrap:wrap}.task-actions .el-button{margin:0}.task-hint{line-height:1.7;margin:10px 0}.el-alert{margin:12px 0}.el-pagination{margin-top:20px;flex-wrap:wrap}.mono{font-size:11px;overflow-wrap:anywhere;margin-top:4px}
@media(max-width:600px){.task-filters>.el-input,.task-filters>.el-select{width:100%}.task-filters :deep(.el-date-editor){width:100%;min-width:0}.task-actions{width:100%}.task-actions .el-button:last-child{width:100%}}
</style>

View File

@ -1,13 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Monitor, Menu, Cpu, User, List, Setting, DataAnalysis, Connection, ArrowRight, SwitchButton, Close, Refresh, Files, Collection, Key } from '@element-plus/icons-vue' import { Monitor, Menu, Cpu, User, List, Setting, DataAnalysis, Connection, ArrowRight, SwitchButton, Close, Refresh, Files, Collection, Key, Lock } from '@element-plus/icons-vue'
import { useAuth } from '../stores/auth' import { useAuth } from '../stores/auth'
import { usePlatform } from '../stores/platform' import { usePlatform } from '../stores/platform'
import { useEdition, type Edition } from '../stores/edition'
import '../common/common.css'
import ThemeToggle from '../components/ThemeToggle.vue' import ThemeToggle from '../components/ThemeToggle.vue'
import ChangePasswordDialog from '../components/ChangePasswordDialog.vue' import ChangePasswordDialog from '../components/ChangePasswordDialog.vue'
import MFADialog from '../components/MFADialog.vue'
import brandMark from '../assets/brand-mark.svg' import brandMark from '../assets/brand-mark.svg'
const auth = useAuth(), data = usePlatform(), route = useRoute(), router = useRouter() const auth = useAuth(), data = usePlatform(), route = useRoute(), router = useRouter()
const edition = useEdition()
const commonItems = [
{ path: '/workbench', title: '工作台', icon: DataAnalysis, group: 'work' },
{ path: '/people', title: '员工与桌面', icon: User, group: 'work' },
{ path: '/devices', title: '终端设备', icon: Monitor, group: 'work' },
{ path: '/help', title: '问题处理', icon: Connection, group: 'work' },
{ path: '/records', title: '操作记录', icon: List, group: 'work' },
]
const items = [ const items = [
{ path: '/overview', title: '云桌面总览', icon: DataAnalysis, group: 'work' }, { path: '/overview', title: '云桌面总览', icon: DataAnalysis, group: 'work' },
{ path: '/desktops', title: '桌面管理', icon: Monitor, group: 'work' }, { path: '/desktops', title: '桌面管理', icon: Monitor, group: 'work' },
@ -21,14 +32,18 @@ const items = [
{ path: '/pools', title: '桌面池', icon: Collection, group: 'manage' }, { path: '/pools', title: '桌面池', icon: Collection, group: 'manage' },
{ path: '/users', title: '用户与授权', icon: User, group: 'manage' }, { path: '/users', title: '用户与授权', icon: User, group: 'manage' },
{ path: '/tasks', title: '任务与审计', icon: List, group: 'manage' }, { path: '/tasks', title: '任务与审计', icon: List, group: 'manage' },
{ path: '/diagnostics', title: '日志与诊断', icon: Files, group: 'manage' },
{ path: '/settings', title: '系统设置', icon: Setting, group: 'manage' }, { path: '/settings', title: '系统设置', icon: Setting, group: 'manage' },
] ]
const navigation = computed(() => edition.mode === 'common' ? commonItems : items)
const groups = computed(() => edition.mode === 'common' ? [{ id: 'work', label: '日常管理' }] : [{ id: 'work', label: '云桌面工作台' }, { id: 'manage', label: '资源与管理' }])
async function changeEdition(value: Edition) { edition.select(value); await router.push(edition.home) }
const roles: Record<string, string> = { super_admin: '超级管理员', operator: '运维人员', viewer: '只读人员' } const roles: Record<string, string> = { super_admin: '超级管理员', operator: '运维人员', viewer: '只读人员' }
const pending = computed(() => data.tasks.filter(t => t.status === 'queued' || t.status === 'running').length) 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 avatar = computed(() => (auth.user?.displayName || auth.user?.username || '管').slice(0, 1))
const mockMode = import.meta.env.VITE_API_MODE === 'mock' const mockMode = import.meta.env.VITE_API_MODE === 'mock'
const navOpen = ref(false) const navOpen = ref(false)
const passwordVisible = ref(false) const passwordVisible = ref(false), mfaVisible = ref(false)
watch(() => route.path, () => { navOpen.value = false }) watch(() => route.path, () => { navOpen.value = false })
let timer: ReturnType<typeof setInterval> let timer: ReturnType<typeof setInterval>
onMounted(() => { void data.refresh(); timer = setInterval(() => { if (!document.hidden) void data.refresh() }, 5000) }) onMounted(() => { void data.refresh(); timer = setInterval(() => { if (!document.hidden) void data.refresh() }, 5000) })
@ -39,20 +54,24 @@ async function logout() { try { await auth.logout() } catch { /* Local session i
<div class="console-shell"> <div class="console-shell">
<button v-if="navOpen" class="nav-scrim" aria-label="关闭导航遮罩" @click="navOpen = false" /> <button v-if="navOpen" class="nav-scrim" aria-label="关闭导航遮罩" @click="navOpen = false" />
<aside id="console-navigation" class="sidebar" :class="{ 'is-open': navOpen }"> <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="brand-row"><router-link :to="edition.home" 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> <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="edition-switch" role="group" aria-label="控制台版本"><button :class="{ selected: edition.mode === 'common' }" :aria-pressed="edition.mode === 'common'" @click="changeEdition('common')">通用版</button><button :class="{ selected: edition.mode === 'advanced' }" :aria-pressed="edition.mode === 'advanced'" @click="changeEdition('advanced')">高级版</button></div>
<nav aria-label="主导航"><template v-for="group in groups" :key="group.id"><div class="nav-label">{{ group.label }}</div><router-link v-for="item in navigation.filter(i => i.group === group.id && (i.path !== '/settings' || auth.canAdmin) && (i.path !== '/diagnostics' || auth.canOperate))" :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> <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> </aside>
<div class="workspace"> <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><el-button text aria-label="修改密码" title="修改本人密码" @click="passwordVisible = true"><el-icon :size="18"><Key /></el-icon></el-button><ThemeToggle /></div></header> <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><el-button text aria-label="修改密码" title="修改本人密码" @click="passwordVisible = true"><el-icon :size="18"><Key /></el-icon></el-button><el-button text aria-label="双重验证" title="本人双重验证" @click="mfaVisible = true"><el-icon :size="18"><Lock /></el-icon></el-button><el-button v-if="auth.canAdmin && edition.mode === 'common'" text aria-label="系统设置" title="系统设置" @click="router.push('/settings')"><el-icon :size="18"><Setting /></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> <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" /><template v-if="route.path === '/gpus'">GPU 清单按需读取,请手动刷新</template><template v-else>{{ data.updatedAt ? `最近刷新 ${data.updatedAt}` : '等待加载' }}<span class="refresh-note"> · 每 5 秒更新</span></template></span><router-link to="/tasks">{{ pending ? `${pending} 个任务等待回执` : '查看任务与审计' }} <el-icon><ArrowRight /></el-icon></router-link></footer> <footer class="workspace-footer"><span><span class="refresh-dot" /><template v-if="route.path === '/gpus'">GPU 清单按需读取,请手动刷新</template><template v-else>{{ data.updatedAt ? `最近刷新 ${data.updatedAt}` : '等待加载' }}<span class="refresh-note"> · 每 5 秒更新</span></template></span><router-link :to="edition.mode === 'common' ? '/records' : '/tasks'">{{ pending ? `${pending} 个任务等待回执` : (edition.mode === 'common' ? '查看操作记录' : '查看任务与审计') }} <el-icon><ArrowRight /></el-icon></router-link></footer>
</div> </div>
<ChangePasswordDialog v-model="passwordVisible" /> <ChangePasswordDialog v-model="passwordVisible" />
<MFADialog v-model="mfaVisible" />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.edition-switch{display:flex;gap:4px;background:var(--vdi-panel-2);padding:4px;border:1px solid var(--vdi-border);border-radius:10px}.edition-switch button{flex:1;min-height:34px;border:0;border-radius:7px;color:var(--vdi-muted);background:transparent;cursor:pointer;font:inherit;font-size:12px}.edition-switch button.selected{color:var(--vdi-accent);background:var(--vdi-accent-soft);font-weight:650}
.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} .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: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: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}}

View File

@ -1,10 +1,16 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuth } from '../stores/auth' import { useAuth } from '../stores/auth'
import { useEdition } from '../stores/edition'
export const router = createRouter({ history: createWebHistory(), routes: [ export const router = createRouter({ history: createWebHistory(), routes: [
{ path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } }, { path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } },
{ path: '/', component: () => import('../layouts/ConsoleLayout.vue'), children: [ { path: '/', component: () => import('../layouts/ConsoleLayout.vue'), children: [
{ path: '', redirect: '/overview' }, { path: '', component: () => import('../views/CommonWorkbench.vue') },
{ path: 'workbench', component: () => import('../views/CommonWorkbench.vue'), meta: { title: '工作台', edition: 'common' } },
{ path: 'people', component: () => import('../views/CommonPeople.vue'), meta: { title: '员工与桌面', edition: 'common' } },
{ path: 'devices', component: () => import('../views/CommonDevices.vue'), meta: { title: '终端设备', edition: 'common' } },
{ path: 'help', component: () => import('../views/CommonHelp.vue'), meta: { title: '问题处理', edition: 'common' } },
{ path: 'records', component: () => import('../views/CommonRecords.vue'), meta: { title: '操作记录', edition: 'common' } },
{ path: 'overview', component: () => import('../views/Overview.vue'), meta: { title: '总览' } }, { path: 'overview', component: () => import('../views/Overview.vue'), meta: { title: '总览' } },
{ path: 'resources', component: () => import('../views/Resources.vue'), meta: { title: '计算资源' } }, { path: 'resources', component: () => import('../views/Resources.vue'), meta: { title: '计算资源' } },
{ path: 'gpus', component: () => import('../views/GPUs.vue'), meta: { title: 'GPU 资源' } }, { path: 'gpus', component: () => import('../views/GPUs.vue'), meta: { title: 'GPU 资源' } },
@ -17,6 +23,7 @@ export const router = createRouter({ history: createWebHistory(), routes: [
{ path: 'terminal-policies', component: () => import('../views/TerminalPolicies.vue'), meta: { title: '终端策略' } }, { path: 'terminal-policies', component: () => import('../views/TerminalPolicies.vue'), meta: { title: '终端策略' } },
{ path: 'users', component: () => import('../views/Users.vue'), meta: { title: '用户与授权' } }, { path: 'users', component: () => import('../views/Users.vue'), meta: { title: '用户与授权' } },
{ path: 'tasks', component: () => import('../views/Tasks.vue'), meta: { title: '任务与审计' } }, { path: 'tasks', component: () => import('../views/Tasks.vue'), meta: { title: '任务与审计' } },
{ path: 'diagnostics', component: () => import('../views/Diagnostics.vue'), meta: { title: '日志与诊断', operateOnly: true } },
{ path: 'settings', component: () => import('../views/Settings.vue'), meta: { title: '系统设置', adminOnly: true } }, { path: 'settings', component: () => import('../views/Settings.vue'), meta: { title: '系统设置', adminOnly: true } },
{ path: 'forbidden', component: () => import('../views/Forbidden.vue'), meta: { title: '无访问权限' } }, { path: 'forbidden', component: () => import('../views/Forbidden.vue'), meta: { title: '无访问权限' } },
{ path: ':pathMatch(.*)*', component: () => import('../views/NotFound.vue'), meta: { title: '页面不存在' } }, { path: ':pathMatch(.*)*', component: () => import('../views/NotFound.vue'), meta: { title: '页面不存在' } },
@ -25,6 +32,11 @@ export const router = createRouter({ history: createWebHistory(), routes: [
router.beforeEach(async to => { router.beforeEach(async to => {
const auth = useAuth(); await auth.restore() const auth = useAuth(); await auth.restore()
if (!to.meta.public && !auth.user) return { path: '/login', query: { redirect: to.fullPath } } if (!to.meta.public && !auth.user) return { path: '/login', query: { redirect: to.fullPath } }
if (to.path === '/login' && auth.user) return '/overview' if (to.path === '/' && auth.user) return useEdition().home
if (to.path === '/login' && auth.user) return useEdition().home
if (to.meta.adminOnly && !auth.canAdmin) return '/forbidden' if (to.meta.adminOnly && !auth.canAdmin) return '/forbidden'
if (to.meta.operateOnly && !auth.canOperate) return '/forbidden'
if (auth.user && !to.meta.public && !['/settings', '/forbidden'].includes(to.path) && to.matched.some(r => r.path === to.path && !r.path.includes(':'))) {
useEdition().select(to.meta.edition === 'common' ? 'common' : 'advanced')
}
}) })

View File

@ -12,8 +12,8 @@ export const useAuth = defineStore('auth', () => {
const canOperate = computed(() => !!user.value && user.value.role !== 'viewer') const canOperate = computed(() => !!user.value && user.value.role !== 'viewer')
const canAdmin = computed(() => user.value?.role === 'super_admin') const canAdmin = computed(() => user.value?.role === 'super_admin')
function clear() { user.value = null; token.value = ''; initialized.value = true; sessionStorage.removeItem(tokenKey); setAccessToken('') } function clear() { user.value = null; token.value = ''; initialized.value = true; sessionStorage.removeItem(tokenKey); setAccessToken('') }
async function login(username: string, password: string) { async function login(username: string, password: string, code?: string) {
const data = await api.login(username, password) const data = await api.login(username, password, code)
token.value = data.accessToken; user.value = data.user; initialized.value = true token.value = data.accessToken; user.value = data.user; initialized.value = true
setAccessToken(token.value); sessionStorage.setItem(tokenKey, token.value) setAccessToken(token.value); sessionStorage.setItem(tokenKey, token.value)
} }

View File

@ -0,0 +1,19 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useAuth } from './auth'
export type Edition = 'common' | 'advanced'
export const useEdition = defineStore('edition', () => {
const auth = useAuth(), mode = ref<Edition>('common')
const key = () => `vdi.console-mode:${auth.user?.id || ''}`
watch(() => auth.user?.id, id => {
mode.value = 'common'
if (id) { try { if (localStorage.getItem(key()) === 'advanced') mode.value = 'advanced' } catch { /* Storage can be unavailable. */ } }
}, { immediate: true, flush: 'sync' })
function select(value: Edition) {
mode.value = value
if (auth.user) { try { localStorage.setItem(key(), value) } catch { /* Navigation still works without persistence. */ } }
}
const home = computed(() => mode.value === 'common' ? '/workbench' : '/overview')
return { mode, home, select }
})

View File

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

View File

@ -0,0 +1,24 @@
<script setup lang="ts">
import { computed, reactive, ref } 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 search = ref(''), page = ref(1), creating = ref(false), secret = ref(''), activationSN = ref('')
const form = reactive({ name: '', sn: '' })
const filtered = computed(() => data.terminals.filter(t => `${t.name} ${t.sn}`.toLowerCase().includes(search.value.toLowerCase())))
const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10))
async function enroll() {
if (!auth.canAdmin || !form.name.trim() || !form.sn.trim()) return
if (await run(async () => { const result = await api.enroll({ name: form.name.trim(), sn: form.sn.trim(), group: '' }); secret.value = result.activationSecret; activationSN.value = form.sn.trim() }, '设备已登记,请在盒子上完成激活')) { creating.value = false; form.name = ''; form.sn = '' }
}
</script>
<template>
<div class="page-heading"><div><h1>终端设备</h1><p class="subtext">查看盒子是否在线,以及当前使用人</p></div><el-button v-if="auth.canAdmin" type="primary" @click="creating = true">登记新设备</el-button></div>
<section class="panel"><div class="toolbar"><el-input v-model="search" aria-label="查找设备" placeholder="设备名称或机身 SN" clearable @input="page = 1" /></div><el-table :data="rows" empty-text="暂无设备,请登记新设备"><el-table-column label="设备" min-width="180"><template #default="{ row }">{{ row.name }}<div class="secondary">{{ row.sn }}</div></template></el-table-column><el-table-column label="在线状态" min-width="105"><template #default="{ row }"><StatusTag :value="row.status" /></template></el-table-column><el-table-column label="使用人" min-width="135"><template #default="{ row }">{{ data.users.find(u => u.terminalId === row.id)?.displayName || '尚未关联' }}</template></el-table-column><el-table-column label="当前桌面" min-width="150"><template #default="{ row }">{{ data.desktops.find(d => d.id === row.sessionDesktopId)?.name || '无活动连接' }}</template></el-table-column><el-table-column label="操作" width="100"><template #default="{ row }"><router-link :to="{ path: '/help', query: { terminal: row.id } }">检查问题</router-link></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="prev, pager, next" /></section>
<p class="subtext">登记后需要在设备上填写激活信息。设备关联、远程重启、收集日志和策略下发位于高级版。</p>
<el-dialog v-model="creating" title="登记新设备" width="480px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy" @submit.prevent="enroll"><el-form-item label="设备名称" required><el-input v-model="form.name" aria-label="新设备名称" maxlength="80" /></el-form-item><el-form-item label="机身 SN" required><el-input v-model="form.sn" aria-label="新设备 SN" maxlength="100" /></el-form-item></el-form><template #footer><el-button :disabled="busy" @click="creating = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!form.name.trim() || !form.sn.trim()" @click="enroll">登记并获取激活信息</el-button></template></el-dialog>
<el-dialog :model-value="!!secret" title="请保存设备激活信息" width="500px" :close-on-click-modal="false" @close="secret = ''"><p>设备 SN:{{ activationSN }}</p><p class="subtext">激活密钥仅显示这一次,请由设备管理员填入盒子配置。</p><el-input :model-value="secret" readonly type="password" show-password aria-label="设备激活密钥" /><template #footer><el-button type="primary" @click="secret = ''">已保存,关闭</el-button></template></el-dialog>
</template>

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api, errorMessage, type AlertRecord } from '../api'
import { usePlatform } from '../stores/platform'
import { diagnoseTerminal } from '../common/diagnosis'
const data = usePlatform(), route = useRoute(), selected = ref(''), alerts = ref<AlertRecord[]>([]), total = ref(0), loading = ref(false), error = ref('')
watch(() => route.query.terminal, id => { selected.value = typeof id === 'string' ? id : '' }, { immediate: true })
const terminal = computed(() => data.terminals.find(t => t.id === selected.value))
const checks = computed(() => terminal.value ? diagnoseTerminal(terminal.value, data.users, data.desktops) : [])
async function load() {
if (loading.value) return
loading.value = true; error.value = ''
try { const result = await api.alerts(1, 'firing'); alerts.value = result.items; total.value = result.total }
catch (e) { error.value = errorMessage(e); alerts.value = [] }
finally { loading.value = false }
}
onMounted(load)
watch(() => data.updatedAt, load)
</script>
<template>
<div class="page-heading"><div><h1>问题处理</h1><p class="subtext">先找原因,再决定下一步;检查过程不会修改设备</p></div></div>
<section class="panel simple-section"><h2>盒子为什么没有桌面?</h2><el-select v-model="selected" aria-label="选择要检查的设备" placeholder="选择设备" filterable clearable><el-option v-for="t in data.terminals" :key="t.id" :value="t.id" :label="`${t.name} / ${t.sn}`" /></el-select><p v-if="!selected" class="subtext">选择后检查在线状态、员工关联与桌面条件。</p><el-alert v-else-if="!terminal" title="该设备已不存在,请重新选择" type="warning" :closable="false" /><div v-for="(check, i) in checks" :key="i" class="simple-check"><el-tag :type="check.ok ? 'success' : 'warning'">{{ check.ok ? '已通过' : '需处理' }}</el-tag><div><strong>{{ check.title }}</strong><p>{{ check.detail }}</p></div></div><p v-if="terminal" class="subtext">检查依据为最近一次成功刷新;平台数据刷新失败时请先重新加载。全部通过仍不能替代实际连接验收。</p></section>
<section class="panel simple-section"><div class="simple-row"><h2>当前告警</h2><el-button :loading="loading" @click="load">刷新告警</el-button></div><el-alert v-if="error" type="error" :closable="false" :title="error" /><el-empty v-else-if="!loading && !alerts.length" description="当前没有触发中的告警" :image-size="64" /><div v-for="alert in alerts" :key="alert.id" class="simple-check"><el-tag type="warning">需关注</el-tag><div><strong>{{ alert.targetName }} · {{ alert.ruleName }}</strong><p>{{ new Date(alert.openedAt).toLocaleString('zh-CN') }} 开始 · {{ alert.acknowledgedAt ? '已有人关注' : '尚未确认' }}</p></div></div><p class="subtext">{{ error ? '告警读取失败,不能判断是否正常。' : `当前 ${total} 条,展示前 20 条。` }}历史、阈值调整和确认操作请进入高级版的监控告警。</p></section>
</template>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { api, type User, type Desktop, type DesktopAction } from '../api'
import { usePlatform } from '../stores/platform'
import { useAuth } from '../stores/auth'
import { useAction } from '../composables/useAction'
import StatusTag from '../components/StatusTag.vue'
const data = usePlatform(), auth = useAuth(), { busy, run, confirm } = useAction()
const search = ref(''), page = ref(1), creating = ref(false), editing = ref(false), reviewing = ref(false), person = ref<User | null>(null)
const form = reactive({ username: '', displayName: '' }), selected = ref<string[]>([]), action = ref<'grant' | 'revoke'>('grant')
const filtered = computed(() => data.users.filter(u => `${u.displayName} ${u.username}`.toLowerCase().includes(search.value.toLowerCase())))
const rows = computed(() => filtered.value.slice((page.value - 1) * 10, page.value * 10))
const desktopName = (id: string) => data.desktops.find(d => d.id === id)?.name || '已失效的桌面'
const choices = computed(() => data.desktops.filter(d => action.value === 'grant' ? !person.value?.desktopIds.includes(d.id) : person.value?.desktopIds.includes(d.id)))
const desktopSearch = ref(''), desktopPage = ref(1)
const desktops = computed(() => data.desktops.filter(d => d.name.toLowerCase().includes(desktopSearch.value.toLowerCase())))
const desktopRows = computed(() => desktops.value.slice((desktopPage.value - 1) * 10, desktopPage.value * 10))
const pending = (id: string) => data.tasks.some(t => t.targetId === id && ['queued', 'running', 'timeout'].includes(t.status))
async function power(desktop: Desktop, operation: DesktopAction) {
if (!auth.canOperate || pending(desktop.id) || busy.value || data.error) return
const label = { start: '开机', shutdown: '正常关机', reboot: '重启桌面' }[operation]
if (!await confirm(`${label} · ${desktop.name}`, operation === 'start' ? '将提交开机任务,连接服务仍需稍后就绪。' : '会中断该桌面上的使用,请先通知使用人保存文件。')) return
if (pending(desktop.id) || data.error) return
await run(() => api.desktopAction(desktop.id, operation), '操作已提交,请在操作记录中查看实际结果')
}
function open(user: User, operation: 'grant' | 'revoke') { person.value = { ...user, desktopIds: [...user.desktopIds] }; action.value = operation; selected.value = []; reviewing.value = false; editing.value = true }
async function create() {
if (!auth.canAdmin || !form.username.trim() || !form.displayName.trim()) return
if (await run(() => api.createUser({ username: form.username.trim(), displayName: form.displayName.trim() }), '员工已添加,请继续分配桌面')) { creating.value = false; form.username = ''; form.displayName = '' }
}
async function save() {
if (!auth.canAdmin || !person.value || !selected.value.length || !reviewing.value || busy.value) return
const userId = person.value.id, desktopIds = [...selected.value], operation = action.value
if (await run(() => api.batchBindings({ userIds: [userId], desktopIds, action: operation }), operation === 'grant' ? '桌面授权已分配' : '指定桌面授权已撤销')) editing.value = false
}
</script>
<template>
<div class="page-heading"><div><h1>员工与桌面</h1><p class="subtext">给员工分配已有桌面,其他授权自动保留</p></div><el-button v-if="auth.canAdmin" type="primary" @click="creating = true">添加员工</el-button></div>
<section class="panel"><div class="toolbar"><el-input v-model="search" aria-label="查找员工" placeholder="输入姓名或账号" clearable @input="page = 1" /></div><el-table :data="rows" empty-text="暂无员工,请先添加员工"><el-table-column label="员工" min-width="150"><template #default="{ row }">{{ row.displayName }}<div class="secondary">{{ row.username }} · {{ row.enabled ? '已启用' : '已停用' }}</div></template></el-table-column><el-table-column label="已分配桌面" min-width="210"><template #default="{ row }"><div v-for="id in row.desktopIds" :key="id">{{ desktopName(id) }} <StatusTag v-if="data.desktops.find(d => d.id === id)" :value="data.desktops.find(d => d.id === id)!.powerState" /></div><span v-if="!row.desktopIds.length" class="secondary">尚未分配</span></template></el-table-column><el-table-column label="使用设备" min-width="145"><template #default="{ row }">{{ data.terminals.find(t => t.id === row.terminalId)?.name || '尚未关联设备' }}</template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" min-width="180"><template #default="{ row }"><el-button link type="primary" @click="open(row, 'grant')">分配桌面</el-button><el-button link :disabled="!row.desktopIds.length" @click="open(row, 'revoke')">撤销授权</el-button></template></el-table-column></el-table><el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="prev, pager, next" /></section>
<section class="panel simple-section"><h2>桌面日常操作</h2><div class="toolbar"><el-input v-model="desktopSearch" aria-label="查找桌面" placeholder="桌面名称" clearable @input="desktopPage = 1" /></div><el-table :data="desktopRows" empty-text="暂无已有桌面,请由运维纳管"><el-table-column prop="name" label="桌面" min-width="150" /><el-table-column label="使用人" min-width="150"><template #default="{ row }">{{ data.users.filter(u => u.desktopIds.includes(row.id)).map(u => u.displayName).join('、') || '尚未分配' }}</template></el-table-column><el-table-column label="状态" min-width="180"><template #default="{ row }"><StatusTag :value="row.powerState" /><div class="secondary">{{ row.setupRequired ? '等待初始化' : row.serviceState === 'ready' ? '连接服务就绪' : '连接服务尚未确认就绪' }}</div><el-tag v-if="pending(row.id)" type="warning" size="small">任务未完成,请先核对</el-tag></template></el-table-column><el-table-column v-if="auth.canOperate" label="操作" min-width="200"><template #default="{ row }"><el-button v-if="row.powerState === 'stopped'" link type="primary" :disabled="busy || pending(row.id) || !!data.error" @click="power(row, 'start')">开机</el-button><template v-if="row.powerState === 'running'"><el-button link :disabled="busy || pending(row.id) || !!data.error" @click="power(row, 'shutdown')">正常关机</el-button><el-button link :disabled="busy || pending(row.id) || !!data.error" @click="power(row, 'reboot')">重启桌面</el-button></template></template></el-table-column></el-table><el-pagination v-model:current-page="desktopPage" :total="desktops.length" :page-size="10" layout="prev, pager, next" /></section>
<p class="subtext">分配表示允许后续连接,不会新建桌面或自动开机。关联设备、批量导入、员工停用等管理操作可在高级版中完成。</p>
<el-dialog v-model="creating" title="添加员工" width="460px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy" @submit.prevent="create"><el-form-item label="员工姓名" required><el-input v-model="form.displayName" aria-label="员工姓名" maxlength="80" /></el-form-item><el-form-item label="员工账号" required><el-input v-model="form.username" aria-label="员工账号" maxlength="80" /></el-form-item><p class="subtext">此账号用于关联盒子与桌面授权,桌面系统密码单独管理。</p></el-form><template #footer><el-button :disabled="busy" @click="creating = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!form.displayName.trim() || !form.username.trim()" @click="create">添加员工</el-button></template></el-dialog>
<el-dialog v-model="editing" :title="action === 'grant' ? '分配已有桌面' : '撤销指定授权'" width="560px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><template v-if="person"><h3>{{ person.displayName }} · {{ person.username }}</h3><p v-if="!person.enabled" class="subtext">员工目前已停用,保存授权后仍需管理员启用才能连接。</p><template v-if="!reviewing"><p class="subtext">选择要{{ action === 'grant' ? '分配' : '撤销' }}的桌面:</p><el-checkbox-group v-model="selected" class="simple-checks"><el-checkbox v-for="d in choices" :key="d.id" :value="d.id">{{ d.name }} · {{ d.os }}</el-checkbox></el-checkbox-group><el-empty v-if="!choices.length" description="没有符合条件的桌面" :image-size="64" /></template><div v-else class="simple-plan"><h3>确认变更方案</h3><p>为 {{ person.displayName }} {{ action === 'grant' ? '追加分配' : '撤销以下' }} {{ selected.length }} 台桌面的使用权限:</p><ul><li v-for="id in selected" :key="id">{{ desktopName(id) }}</li></ul><p>其他桌面授权、员工状态和设备关联保持原值。已有串流不会被强制断开。</p></div></template><template #footer><el-button :disabled="busy" @click="reviewing ? reviewing = false : editing = false">{{ reviewing ? '返回修改' : '取消' }}</el-button><el-button v-if="!reviewing" type="primary" :disabled="!selected.length" @click="reviewing = true">查看方案</el-button><el-button v-else type="primary" :loading="busy" @click="save">确认并保存授权</el-button></template></el-dialog>
</template>

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { api, errorMessage, type Task, type Audit } from '../api'
import { auditActionLabels } from '../api/audit-labels'
import { usePlatform } from '../stores/platform'
import StatusTag from '../components/StatusTag.vue'
const data = usePlatform(), rows = ref<Task[]>([]), page = ref(1), total = ref(0), loading = ref(false), error = ref(''), search = ref(''), keyword = ref('')
const tab = ref('changes'), changes = ref<Audit[]>([])
let generation = 0
onBeforeUnmount(() => { generation++ })
async function load(reset = false) {
if (reset) { page.value = 1; keyword.value = search.value }
const request = ++generation; loading.value = true; error.value = ''
try {
const filters = { page: page.value, pageSize: 10, keyword: keyword.value }
if (tab.value === 'changes') { const result = await api.audits(filters); if (request === generation) { changes.value = result.items; total.value = result.total } }
else { const result = await api.taskHistory(filters); if (request === generation) { rows.value = result.items; total.value = result.total } }
}
catch (e) { if (request === generation) { error.value = errorMessage(e); rows.value = []; changes.value = []; total.value = 0 } }
finally { if (request === generation) loading.value = false }
}
onMounted(() => load())
watch(() => data.updatedAt, () => { void load() })
</script>
<template>
<div class="page-heading"><div><h1>操作记录</h1><p class="subtext">查看员工授权、配置变更与桌面任务结果</p></div></div><section class="panel"><el-tabs v-model="tab" @tab-change="load(true)"><el-tab-pane label="人员与配置变更" name="changes" /><el-tab-pane label="设备与桌面任务" name="tasks" /></el-tabs><div class="toolbar"><el-input v-model="search" aria-label="查找操作记录" placeholder="对象名称或结果" clearable @keyup.enter="load(true)" /><el-button type="primary" :loading="loading" @click="load(true)">查询记录</el-button></div><el-alert v-if="error" type="error" :title="error" :closable="false" /><el-table v-if="tab === 'changes'" v-loading="loading" :data="changes" empty-text="没有匹配的变更记录"><el-table-column prop="target" label="对象" min-width="180" /><el-table-column label="操作" min-width="140"><template #default="{ row }">{{ auditActionLabels[row.action] || row.action }}</template></el-table-column><el-table-column prop="result" label="记录结果" min-width="210" /><el-table-column prop="actor" label="操作人" min-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><el-table v-else v-loading="loading" :data="rows" empty-text="没有匹配的任务"><el-table-column prop="targetName" label="对象" min-width="150" /><el-table-column label="操作" min-width="120"><template #default="{ row }">{{ auditActionLabels[row.action] || row.action }}</template></el-table-column><el-table-column label="结果" min-width="110"><template #default="{ row }"><StatusTag :value="row.status" /></template></el-table-column><el-table-column prop="message" label="说明" min-width="240" /><el-table-column prop="actor" label="操作人" min-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><el-pagination v-model:current-page="page" :disabled="loading" :total="total" :page-size="10" layout="total, prev, pager, next" @current-change="load()" /></section><p class="subtext">结果未确认不代表失败或成功,应由运维核对。更详细的筛选、结果核对和 CSV 导出位于高级版“任务与审计”。</p>
</template>

View File

@ -0,0 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import { usePlatform } from '../stores/platform'
import { useAuth } from '../stores/auth'
const data = usePlatform(), auth = useAuth()
const running = computed(() => data.desktops.filter(d => d.powerState === 'running').length)
const unassigned = computed(() => data.users.filter(u => u.enabled && !u.desktopIds.length).length)
const offline = computed(() => data.terminals.filter(t => t.enabled !== false && t.status !== 'disabled' && t.status !== 'online').length)
const unresolved = computed(() => data.tasks.filter(t => t.status === 'timeout').length)
</script>
<template>
<div class="page-heading"><div><h1>工作台</h1><p class="subtext">{{ auth.user?.displayName }},在这里完成日常桌面管理</p></div><el-tag>通用版</el-tag></div>
<div class="simple-stats"><section class="panel"><span>运行中的桌面</span><strong>{{ running }}<small> / {{ data.desktops.length }} 台桌面</small></strong></section><section class="panel"><span>在线设备</span><strong>{{ data.terminals.filter(t => t.status === 'online').length }}<small> / {{ data.terminals.length }} 台</small></strong></section><section class="panel"><span>正在使用</span><strong>{{ data.overview?.activeSessions ?? '—' }}<small> 个连接</small></strong></section></div>
<section class="panel simple-section"><h2>你想完成什么?</h2><p class="subtext">选择一件事,按步骤完成。</p><div class="simple-shortcuts"><router-link to="/people"><strong>员工与桌面 →</strong><span>添加员工,分配或撤销已有桌面</span></router-link><router-link to="/devices"><strong>查看终端设备 →</strong><span>看在线状态,登记新盒子</span></router-link><router-link to="/help"><strong>桌面打不开 →</strong><span>检查设备、使用人和桌面连接条件</span></router-link></div></section>
<section class="panel simple-section"><h2>需要关注</h2><div class="simple-issues"><router-link to="/people">{{ unassigned }} 位已启用员工尚未分配桌面 →</router-link><router-link to="/devices">{{ offline }} 台设备离线或等待激活 →</router-link><router-link to="/records">{{ unresolved }} 个任务结果尚未确认 →</router-link></div><p class="subtext">数据来自平台最近刷新。连接服务就绪后,仍需在终端验证实际连接和画面;告警详情见问题处理。</p></section>
</template>

View File

@ -51,7 +51,7 @@ async function submitImport() { if (!form.name.trim() || !form.node) return; if
<el-table v-else :data="rows" empty-text="暂无符合条件的桌面"><el-table-column v-if="auth.canOperate" label="选择" width="60"><template #default="{ row }"><el-checkbox :model-value="batchIds.includes(row.id)" @change="toggleDesktop(row.id)" :aria-label="`选择桌面 ${row.name}`" :disabled="batchIds.length >= 50 && !batchIds.includes(row.id)" /></template></el-table-column><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.setupRequired ? '待初始化' : 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-table v-else :data="rows" empty-text="暂无符合条件的桌面"><el-table-column v-if="auth.canOperate" label="选择" width="60"><template #default="{ row }"><el-checkbox :model-value="batchIds.includes(row.id)" @change="toggleDesktop(row.id)" :aria-label="`选择桌面 ${row.name}`" :disabled="batchIds.length >= 50 && !batchIds.includes(row.id)" /></template></el-table-column><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.setupRequired ? '待初始化' : 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" /> <el-pagination v-model:current-page="page" :total="filtered.length" :page-size="10" layout="total, prev, pager, next" />
</section> </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.setupRequired ? '待初始化,暂不能连接' : 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">从模板创建后需完成系统初始化和 Sunshine 配对,再配置连接地址。关机后可管理快照;公共池自动还原待接入。</p><el-button v-if="auth.canAdmin" style="margin-bottom:18px" @click="openConnection(selected)">配置连接</el-button><el-button v-if="auth.canAdmin && selected.provider === 'proxmox'" style="margin-bottom:18px" :disabled="activeTask(selected)" @click="hardwareDesktop = selected">规格管理</el-button><el-button v-if="selected.provider === 'proxmox'" style="margin-bottom:18px" @click="snapshotDesktop = selected">快照与还原</el-button><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-drawer :model-value="!!selected" title="桌面详情" size="560px" @close="selectedId = ''"><template v-if="selected"><h2>{{ selected.name }}</h2><router-link v-if="auth.canOperate" :to="{ path: '/diagnostics', query: { targetType: 'desktop', targetId: selected.id } }">查看此桌面关联诊断 →</router-link><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.setupRequired ? '待初始化,暂不能连接' : 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">从模板创建后需完成系统初始化和 Sunshine 配对,再配置连接地址。关机后可管理快照;公共池自动还原待接入。</p><el-button v-if="auth.canAdmin" style="margin-bottom:18px" @click="openConnection(selected)">配置连接</el-button><el-button v-if="auth.canAdmin && selected.provider === 'proxmox'" style="margin-bottom:18px" :disabled="activeTask(selected)" @click="hardwareDesktop = selected">规格管理</el-button><el-button v-if="selected.provider === 'proxmox'" style="margin-bottom:18px" @click="snapshotDesktop = selected">快照与还原</el-button><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> <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>
<DesktopBatchDialog v-model="batchAction" :desktop-ids="batchIds" /><CloneDesktopDialog v-model="cloneVisible" /><DesktopHardwareDialog v-model="hardwareDesktop" /><DesktopSnapshotsDialog v-model="snapshotDesktop" /> <DesktopBatchDialog v-model="batchAction" :desktop-ids="batchIds" /><CloneDesktopDialog v-model="cloneVisible" /><DesktopHardwareDialog v-model="hardwareDesktop" /><DesktopSnapshotsDialog v-model="snapshotDesktop" />
<el-dialog :model-value="!!connectionDesktop" title="配置桌面连接" width="560px" :close-on-click-modal="false" @close="connectionDesktop = undefined"><el-form label-position="top" :disabled="busy"><el-alert v-if="connectionDesktop?.setupRequired" title="新桌面尚未初始化,完成以下检查后才能向终端签发连接票据。" type="warning" :closable="false" /><el-form-item label="IP 或主机名"><el-input v-model="connection.endpoint" placeholder="例如 192.168.1.200,不含协议和端口" maxlength="253" /></el-form-item><el-form-item label="请求宽度"><el-input-number v-model="connection.width" :min="640" :max="7680" :precision="0" /></el-form-item><el-form-item label="请求高度"><el-input-number v-model="connection.height" :min="480" :max="4320" :precision="0" /></el-form-item><el-form-item label="请求帧率"><el-input-number v-model="connection.fps" :min="10" :max="120" :precision="0" /></el-form-item><el-checkbox v-if="connectionDesktop?.setupRequired" v-model="connection.initialized">已完成系统身份、IP、Sunshine 服务和终端配对</el-checkbox><p class="subtext" style="margin-top:16px">保存会使旧连接票据失效;有活动连接时请先结束会话。</p></el-form><template #footer><el-button :disabled="busy" @click="connectionDesktop = undefined">取消</el-button><el-button type="primary" :loading="busy" :disabled="!connection.endpoint.trim() || (!!connectionDesktop?.setupRequired && !connection.initialized)" @click="saveConnection">保存连接配置</el-button></template></el-dialog> <el-dialog :model-value="!!connectionDesktop" title="配置桌面连接" width="560px" :close-on-click-modal="false" @close="connectionDesktop = undefined"><el-form label-position="top" :disabled="busy"><el-alert v-if="connectionDesktop?.setupRequired" title="新桌面尚未初始化,完成以下检查后才能向终端签发连接票据。" type="warning" :closable="false" /><el-form-item label="IP 或主机名"><el-input v-model="connection.endpoint" placeholder="例如 192.168.1.200,不含协议和端口" maxlength="253" /></el-form-item><el-form-item label="请求宽度"><el-input-number v-model="connection.width" :min="640" :max="7680" :precision="0" /></el-form-item><el-form-item label="请求高度"><el-input-number v-model="connection.height" :min="480" :max="4320" :precision="0" /></el-form-item><el-form-item label="请求帧率"><el-input-number v-model="connection.fps" :min="10" :max="120" :precision="0" /></el-form-item><el-checkbox v-if="connectionDesktop?.setupRequired" v-model="connection.initialized">已完成系统身份、IP、Sunshine 服务和终端配对</el-checkbox><p class="subtext" style="margin-top:16px">保存会使旧连接票据失效;有活动连接时请先结束会话。</p></el-form><template #footer><el-button :disabled="busy" @click="connectionDesktop = undefined">取消</el-button><el-button type="primary" :loading="busy" :disabled="!connection.endpoint.trim() || (!!connectionDesktop?.setupRequired && !connection.initialized)" @click="saveConnection">保存连接配置</el-button></template></el-dialog>

View File

@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { api, errorMessage, type DiagnosticReport, type DiagnosticQuery, type DiagnosticEvent, type Task } from '../api'
import { auditActionLabels } from '../api/audit-labels'
import { usePlatform } from '../stores/platform'
import { useAuth } from '../stores/auth'
import { useAction } from '../composables/useAction'
import StatusTag from '../components/StatusTag.vue'
const data = usePlatform(), auth = useAuth(), route = useRoute(), { confirm } = useAction()
const end = new Date()
const form = reactive({ kind: 'terminal' as 'terminal' | 'desktop', id: '', source: '' as NonNullable<DiagnosticQuery['source']>, range: [new Date(end.getTime() - 86400000), end] as [Date, Date] | null })
const sources = { tasks: '执行任务', sessions: '桌面会话', alerts: '监控告警', audits: '操作审计', logs: '终端日志' }
const lifecycle: Record<string, string> = { 'session-reserved': '开始连接', 'session-connected': '连接已建立', 'session-ended': '连接结束', 'alert-opened': '告警触发', 'alert-resolved': '告警恢复' }
const report = ref<DiagnosticReport | null>(null), applied = ref<DiagnosticQuery | null>(null), page = ref(1), loading = ref(false), error = ref(''), exporting = ref(false), collecting = ref(false)
const collection = ref<Task | null>(null), collectionTarget = ref('')
const currentCollection = computed(() => data.tasks.find(t => t.id === collection.value?.id) || collection.value)
const objects = computed(() => form.kind === 'terminal' ? data.terminals.map(t => ({ id: t.id, label: `${t.name} / ${t.sn}` })) : data.desktops.map(d => ({ id: d.id, label: `${d.name} / VM ${d.vmid}` })))
const terminal = computed(() => report.value?.subject.kind === 'terminal' ? data.terminals.find(t => t.id === report.value!.subject.id) : undefined)
const pending = computed(() => !!terminal.value && data.tasks.some(t => t.kind === 'terminal' && t.targetId === terminal.value!.id && ['queued', 'running', 'timeout'].includes(t.status)))
const canCollect = computed(() => auth.canOperate && report.value?.subject.kind === 'terminal' && report.value.subject.state === 'online' && terminal.value?.status === 'online' && terminal.value?.enabled !== false && !data.error && !pending.value)
const shortcuts = [1, 24, 168].map(hours => ({ text: hours === 1 ? '最近 1 小时' : hours === 24 ? '最近 24 小时' : '最近 7 天', value: () => { const to = new Date(); return [new Date(to.getTime() - hours * 3600000), to] } }))
const time = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false })
let generation = 0, exportGeneration = 0, collectionKey = '', collectionKeyTarget = ''
onBeforeUnmount(() => { generation++; exportGeneration++ })
watch(() => [form.kind, form.id, form.source, form.range], () => { generation++; exportGeneration++; report.value = null; applied.value = null; error.value = ''; loading.value = false }, { flush: 'sync', deep: true })
function changeKind() { form.id = '' }
async function load(reset = false) {
if (reset) {
if (!form.id || !form.range || form.range[1] <= form.range[0] || form.range[1].getTime() - form.range[0].getTime() > 7 * 86400000) { ElMessage.warning('请选择对象和有效时间范围,最长 7 天'); return }
applied.value = { targetType: form.kind, targetId: form.id, source: form.source, from: form.range[0].toISOString(), to: form.range[1].toISOString() }; page.value = 1
}
if (!applied.value) return
const version = ++generation, filters = { ...applied.value, page: page.value, pageSize: 20 }
loading.value = true; error.value = ''; report.value = null
try { const result = await api.diagnostics(filters); if (version === generation) report.value = result }
catch (e) { if (version === generation) error.value = errorMessage(e) }
finally { if (version === generation) loading.value = false }
}
async function download() {
if (!report.value || !applied.value || exporting.value || loading.value) return
const filters = { ...applied.value }, version = exportGeneration
exporting.value = true
try {
const blob = await api.exportDiagnostics(filters)
if (version !== exportGeneration) return
const url = URL.createObjectURL(blob), link = document.createElement('a'); link.href = url; link.download = `vdi-diagnostics-${filters.targetType}-${new Date().toISOString().slice(0, 10)}.zip`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
ElMessage.success('诊断包已生成,包含当前筛选的全部匹配事件')
} catch (e) { if (version === exportGeneration) ElMessage.error(errorMessage(e)) }
finally { exporting.value = false }
}
async function collect() {
if (!canCollect.value || collecting.value || !report.value) return
const subject = { ...report.value.subject }; collecting.value = true
try {
if (!await confirm(`收集终端日志 · ${subject.name}`, '将向此设备提交日志采集命令并等待回执。不会重启设备、断开串流或操作桌面电源;提交成功不代表已取得日志。')) return
if (!canCollect.value || report.value?.subject.id !== subject.id) return
if (!collectionKey || collectionKeyTarget !== subject.id) { collectionKey = crypto.randomUUID(); collectionKeyTarget = subject.id }
const task = await api.collectTerminalLogs(subject.id, collectionKey)
collection.value = task; collectionTarget.value = subject.id; collectionKey = ''
ElMessage.success('日志采集任务已提交,等待设备回执'); await data.refresh()
} catch (e) { ElMessage.error(`${errorMessage(e)};请刷新任务核对,重试同一设备时复用本次请求标识。`) }
finally { collecting.value = false }
}
function showCollection() {
form.kind = 'terminal'; form.id = collectionTarget.value; form.source = 'logs'; const to = new Date(); form.range = [new Date(to.getTime() - 3600000), to]; void load(true)
}
function details(event: DiagnosticEvent): Record<string, unknown> { try { return JSON.parse(event.details) } catch { return {} } }
function metadata(event: DiagnosticEvent) { const value = { ...details(event) }; delete value.logTail; return JSON.stringify(value, null, 2) }
function applyRoute() {
const kind = route.query.targetType, id = route.query.targetId
if ((kind === 'terminal' || kind === 'desktop') && typeof id === 'string') { form.kind = kind; form.id = id; void load(true) }
}
onMounted(applyRoute)
watch(() => [route.query.targetType, route.query.targetId], applyRoute)
</script>
<template>
<div class="page-heading"><div><h1>日志与诊断</h1><p class="subtext">按设备或桌面关联日志、会话、告警与操作,追查故障经过</p></div><el-button :loading="exporting" :disabled="!report || loading || report.total > 2000" @click="download">导出诊断包</el-button></div>
<section class="panel diagnosis-filters"><el-radio-group v-model="form.kind" aria-label="诊断对象类型" @change="changeKind"><el-radio-button value="terminal">终端设备</el-radio-button><el-radio-button value="desktop">云桌面</el-radio-button></el-radio-group><div class="diagnosis-query"><el-select v-model="form.id" aria-label="诊断对象" :placeholder="form.kind === 'terminal' ? '选择设备名称或 SN' : '选择桌面名称或 VMID'" filterable clearable><el-option v-for="object in objects" :key="object.id" :value="object.id" :label="object.label" /></el-select><el-date-picker v-model="form.range" type="datetimerange" aria-label="诊断时间范围" start-placeholder="开始时间" end-placeholder="结束时间" :shortcuts="shortcuts" format="YYYY-MM-DD HH:mm:ss" /><el-select v-model="form.source" aria-label="诊断记录来源" placeholder="全部记录来源" clearable><el-option v-for="(label, key) in sources" :key="key" :value="key" :label="label" /></el-select><el-button type="primary" :loading="loading" :disabled="!form.id" @click="load(true)">查询诊断</el-button></div><p class="subtext">最长查询 7 天。查询和导出使用相同对象、时间及来源;查询不会自动采集日志或修改设备。</p></section>
<section v-if="currentCollection" class="panel diagnosis-collection"><strong>本次日志采集 · {{ currentCollection.targetName }}</strong><StatusTag :value="currentCollection.status" /><p>{{ currentCollection.message }}</p><span class="mono secondary">{{ currentCollection.id }}</span><el-button v-if="currentCollection.status === 'success'" link type="primary" @click="showCollection">查看本次采集</el-button><router-link v-else to="/tasks">查看采集任务 →</router-link></section>
<el-alert v-if="error" type="error" :title="error" :closable="false" show-icon class="notice" />
<section v-if="loading" class="panel diagnosis-empty"><el-skeleton :rows="6" animated /></section>
<template v-else-if="report">
<section class="panel diagnosis-subject"><div><h2>{{ report.subject.name }}</h2><p class="mono secondary">{{ report.subject.id }}<span v-if="report.subject.node"> · {{ report.subject.node }} / VM {{ report.subject.vmid }}</span></p><p class="secondary">当前快照 {{ time(report.observedAt) }}<span v-if="report.subject.lastSeen"> · 最近心跳 {{ time(report.subject.lastSeen) }}</span></p></div><div class="diagnosis-subject-actions"><StatusTag :value="report.subject.state" /><el-button v-if="report.subject.kind === 'terminal' && auth.canOperate" :loading="collecting" :disabled="!canCollect" @click="collect">收集此设备日志</el-button></div><p v-if="report.subject.kind === 'terminal'" class="diagnosis-footnote">{{ pending ? '该设备有未完成或结果未知的命令,请先到任务中心核对。' : report.subject.state !== 'online' ? '设备当前不在线,恢复在线后再采集新日志。' : '采集需要设备回执;已存在的日志可在下方查看。' }}</p></section>
<div class="diagnosis-counts"><div v-for="(label, key) in sources" :key="key" class="panel"><span>{{ label }}</span><strong>{{ report.counts[key] }}</strong></div></div>
<section class="panel diagnosis-timeline"><div class="diagnosis-timeline-heading"><div><h2>关联事件</h2><p class="subtext">{{ time(report.from) }} — {{ time(report.to) }} · {{ report.source ? sources[report.source as keyof typeof sources] : '全部来源' }} · 共 {{ report.total }} 条</p></div><el-tag type="info">按事件时间倒序</el-tag></div><el-alert v-if="report.total > 2000" type="warning" title="事件超过 2000 条,请缩小时间范围或选择记录来源后导出" :closable="false" class="notice" /><el-empty v-if="!report.items.length" description="此范围内没有匹配记录;未采集或未上报不代表没有故障" :image-size="90" /><el-timeline v-else><el-timeline-item v-for="event in report.items" :key="event.id" :timestamp="time(event.time)" placement="top"><article class="diagnosis-event"><div class="diagnosis-event-heading"><el-tag size="small" type="info">{{ sources[event.source] }}</el-tag><strong>{{ lifecycle[event.action] || auditActionLabels[event.action] || event.action }}</strong><StatusTag :value="event.status" /></div><p>{{ event.message }}</p><p class="secondary">{{ event.targetName }}<span v-if="event.actor"> · {{ event.actor }}</span></p><el-collapse><el-collapse-item title="查看关联标识与详情" :name="event.id"><pre v-if="typeof details(event).logTail === 'string'" class="diagnosis-log">{{ details(event).logTail || '设备回传的日志片段为空' }}</pre><pre>{{ metadata(event) }}</pre></el-collapse-item></el-collapse></article></el-timeline-item></el-timeline><el-pagination v-model:current-page="page" :disabled="loading" :total="report.total" :page-size="20" layout="total, prev, pager, next" @current-change="load()" /></section>
<section class="panel diagnosis-notes"><h3>查询范围说明</h3><p v-for="note in report.notes" :key="note" class="subtext">{{ note }}</p></section>
</template>
<section v-else-if="!error" class="panel diagnosis-empty"><el-empty description="选择需要排查的设备或桌面,再查询诊断记录" :image-size="110" /></section>
</template>
<style scoped>
.diagnosis-filters{padding:22px}.diagnosis-query{display:grid;grid-template-columns:minmax(190px,1fr) minmax(320px,1.5fr) 160px auto;gap:12px;margin:18px 0}.diagnosis-query :deep(.el-date-editor){width:100%;min-width:0}.diagnosis-subject{display:flex;justify-content:space-between;align-items:center;gap:18px;flex-wrap:wrap;margin-top:20px}.diagnosis-subject h2{font-size:19px;margin-bottom:8px}.diagnosis-subject p{line-height:1.8;font-size:12px}.diagnosis-subject-actions{display:flex;align-items:center;gap:14px}.diagnosis-footnote{width:100%;color:var(--vdi-muted)}.diagnosis-counts{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin:20px 0}.diagnosis-counts .panel{padding:18px;margin:0}.diagnosis-counts span{font-size:12px;color:var(--vdi-muted)}.diagnosis-counts strong{display:block;font-size:26px;margin-top:12px}.diagnosis-timeline-heading{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:26px;flex-wrap:wrap}.diagnosis-timeline h2{font-size:18px}.diagnosis-timeline :deep(.el-timeline){padding-left:4px}.diagnosis-event{border:1px solid var(--vdi-border);border-radius:12px;padding:16px;background:var(--vdi-panel-2);overflow-wrap:anywhere}.diagnosis-event-heading{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.diagnosis-event-heading strong{font-size:14px}.diagnosis-event p{font-size:13px;line-height:1.9;margin-top:9px}.diagnosis-event pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;line-height:1.8;max-height:380px;overflow:auto}.diagnosis-log{padding:16px;background:var(--vdi-bg);border:1px solid var(--vdi-border);border-radius:8px;margin:10px 0}.diagnosis-event :deep(.el-collapse){margin-top:12px;--el-collapse-header-bg-color:transparent;--el-collapse-content-bg-color:transparent}.diagnosis-collection{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.diagnosis-collection p{width:100%;font-size:13px}.diagnosis-notes{margin-top:20px}.diagnosis-notes h3{font-size:14px}.diagnosis-notes p{margin-top:12px}.diagnosis-empty{margin-top:20px}@media(max-width:1250px){.diagnosis-query{grid-template-columns:1fr 1fr}.diagnosis-query :deep(.el-date-editor){grid-column:1/-1;grid-row:2}.diagnosis-query>.el-button{grid-column:1/-1}}@media(max-width:600px){.diagnosis-query{grid-template-columns:minmax(0,1fr)}.diagnosis-query :deep(.el-date-editor){height:62px;flex-wrap:wrap;gap:4px}.diagnosis-filters{padding:18px}.diagnosis-counts{grid-template-columns:repeat(2,minmax(0,1fr))}.diagnosis-event{padding:12px}.diagnosis-subject-actions{flex-wrap:wrap}.diagnosis-counts strong{font-size:23px}}
</style>

View File

@ -1,24 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref } from 'vue' import { reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Monitor, Connection, Lock, ArrowRight } from '@element-plus/icons-vue' import { Monitor, Connection, Lock, ArrowRight } from '@element-plus/icons-vue'
import { useAuth } from '../stores/auth' import { useAuth } from '../stores/auth'
import { errorMessage } from '../api' import { ApiError, errorMessage } from '../api'
import ThemeToggle from '../components/ThemeToggle.vue' import ThemeToggle from '../components/ThemeToggle.vue'
import brandMark from '../assets/brand-mark.svg' import brandMark from '../assets/brand-mark.svg'
const auth = useAuth(), router = useRouter(), route = useRoute() const auth = useAuth(), router = useRouter(), route = useRoute()
const form = reactive({ username: '', password: '' }), busy = ref(false), error = ref('') const form = reactive({ username: '', password: '', code: '' }), busy = ref(false), error = ref(''), mfaRequired = ref(false)
watch(() => form.username, () => { mfaRequired.value = false; form.code = ''; error.value = '' })
const demo = import.meta.env.VITE_API_MODE === 'mock' || import.meta.env.VITE_DEMO_HINT === 'true' const demo = import.meta.env.VITE_API_MODE === 'mock' || import.meta.env.VITE_DEMO_HINT === 'true'
async function submit() { async function submit() {
if (!form.username.trim() || !form.password) { error.value = '请输入用户名和密码'; return } if (!form.username.trim() || !form.password) { error.value = '请输入用户名和密码'; return }
busy.value = true; error.value = '' 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) } try { await auth.login(form.username.trim(), form.password, form.code.trim()); form.password = ''; form.code = ''; const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/') && !route.query.redirect.startsWith('//') ? route.query.redirect : '/'; await router.replace(redirect) }
catch (e) { error.value = errorMessage(e) } catch (e) { if (e instanceof ApiError && (e.code === 'mfa_required' || e.code === 'mfa_invalid')) { mfaRequired.value = true; form.code = '' }; error.value = errorMessage(e) }
finally { busy.value = false } finally { busy.value = false }
} }
</script> </script>
<template> <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> <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-form-item v-if="mfaRequired" label="动态码或恢复码"><el-input v-model="form.code" name="code" aria-label="登录动态码或恢复码" autocomplete="one-time-code" size="large" maxlength="80" 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> </template>
<style scoped> <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} .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}

View File

@ -6,7 +6,7 @@ import { useAuth } from '../stores/auth'
import { useAction } from '../composables/useAction' import { useAction } from '../composables/useAction'
import ResourceTrend from '../components/ResourceTrend.vue' import ResourceTrend from '../components/ResourceTrend.vue'
const data = usePlatform(), auth = useAuth(), { run, busy: saving } = useAction() const data = usePlatform(), auth = useAuth(), { run, busy: saving } = useAction()
const metricLabels: Record<string, string> = { node_cpu: '节点 CPU', node_memory: '节点内存', node_storage: '节点存储', node_unavailable: '节点不可用 / 采集异常', terminal_offline: '终端离线' } const metricLabels: Record<string, string> = { node_cpu: '节点 CPU', node_memory: '节点内存', node_storage: '节点存储', node_unavailable: '节点不可用 / 采集异常', terminal_offline: '终端离线', terminal_cpu: '终端 CPU', terminal_memory: '终端内存', terminal_temperature: '终端 SoC 温度' }
const rules = ref<AlertRule[]>([]), alerts = ref<AlertRecord[]>([]), points = ref<ResourcePoint[]>([]) const rules = ref<AlertRule[]>([]), alerts = ref<AlertRecord[]>([]), points = ref<ResourcePoint[]>([])
const resourceId = ref(''), hours = ref(24), bucket = ref(300), from = ref(Date.now() - 86400000), to = ref(Date.now()) const resourceId = ref(''), hours = ref(24), bucket = ref(300), from = ref(Date.now() - 86400000), to = ref(Date.now())
const status = ref(''), keyword = ref(''), page = ref(1), total = ref(0), firing = ref(0), unacknowledged = ref(0), loading = ref(false), error = ref(''), chartError = ref('') const status = ref(''), keyword = ref(''), page = ref(1), total = ref(0), firing = ref(0), unacknowledged = ref(0), loading = ref(false), error = ref(''), chartError = ref('')
@ -14,6 +14,9 @@ const nodes = computed(() => data.resources.filter(r => r.provider === 'proxmox'
const tab = ref('alerts'), dialog = ref(false), editId = ref('') const tab = ref('alerts'), dialog = ref(false), editId = ref('')
const form = reactive<AlertRuleInput>({ name: '', metric: 'node_cpu', threshold: 90, holdSeconds: 120, enabled: true }) const form = reactive<AlertRuleInput>({ name: '', metric: 'node_cpu', threshold: 90, holdSeconds: 120, enabled: true })
const offline = computed(() => ['node_unavailable', 'terminal_offline'].includes(form.metric)) const offline = computed(() => ['node_unavailable', 'terminal_offline'].includes(form.metric))
const temperature = computed(() => form.metric === 'terminal_temperature')
const terminalMetric = computed(() => ['terminal_cpu', 'terminal_memory', 'terminal_temperature'].includes(form.metric))
const unit = (metric: string) => ['node_unavailable', 'terminal_offline'].includes(metric) ? '秒' : metric === 'terminal_temperature' ? '°C' : '%'
const stamp = (v?: string | null) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '—' const stamp = (v?: string | null) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '—'
const reasons: Record<string, string> = { condition_cleared: '指标恢复', rule_changed: '规则变更', target_unavailable: '目标移除或停用' } const reasons: Record<string, string> = { condition_cleared: '指标恢复', rule_changed: '规则变更', target_unavailable: '目标移除或停用' }
async function chart() { async function chart() {
@ -42,7 +45,7 @@ function edit(rule?: AlertRule) {
Object.assign(form, rule ? { name: rule.name, metric: rule.metric, threshold: rule.threshold, holdSeconds: rule.holdSeconds, enabled: rule.enabled } : { name: '', metric: 'node_cpu', threshold: 90, holdSeconds: 120, enabled: true }) Object.assign(form, rule ? { name: rule.name, metric: rule.metric, threshold: rule.threshold, holdSeconds: rule.holdSeconds, enabled: rule.enabled } : { name: '', metric: 'node_cpu', threshold: 90, holdSeconds: 120, enabled: true })
dialog.value = true dialog.value = true
} }
function metricChanged() { form.threshold = offline.value ? 180 : 90; form.holdSeconds = offline.value ? 0 : 120 } function metricChanged() { form.threshold = offline.value ? 180 : temperature.value ? 80 : 90; form.holdSeconds = offline.value ? 0 : 120 }
async function save() { async function save() {
if (!form.name.trim()) return if (!form.name.trim()) return
const ok = await run(() => api.saveAlertRule(editId.value, { ...form, name: form.name.trim() }), '告警规则已保存') const ok = await run(() => api.saveAlertRule(editId.value, { ...form, name: form.name.trim() }), '告警规则已保存')
@ -54,7 +57,7 @@ onMounted(() => { void load(); timer = setInterval(() => { if (!document.hidden
onUnmounted(() => clearInterval(timer)) onUnmounted(() => clearInterval(timer))
</script> </script>
<template> <template>
<div class="page-heading"><div><h1>监控告警</h1><p class="subtext">节点历史趋势与终端离线告警 · 每 15 秒刷新</p></div><el-button :loading="loading" @click="load()">刷新监控</el-button></div> <div class="page-heading"><div><h1>监控告警</h1><p class="subtext">节点与终端指标告警 · 每 15 秒刷新</p></div><el-button :loading="loading" @click="load()">刷新监控</el-button></div>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="notice" /> <el-alert v-if="error" type="error" :title="error" :closable="false" class="notice" />
<div class="monitor-summary"><article class="panel"><span>正在告警</span><strong :class="firing ? 'tone-danger' : 'tone-ok'">{{ firing }}</strong></article><article class="panel"><span>等待确认</span><strong>{{ unacknowledged }}</strong></article><article class="panel"><span>启用规则</span><strong>{{ rules.filter(r => r.enabled).length }}</strong></article></div> <div class="monitor-summary"><article class="panel"><span>正在告警</span><strong :class="firing ? 'tone-danger' : 'tone-ok'">{{ firing }}</strong></article><article class="panel"><span>等待确认</span><strong>{{ unacknowledged }}</strong></article><article class="panel"><span>启用规则</span><strong>{{ rules.filter(r => r.enabled).length }}</strong></article></div>
<section class="panel"> <section class="panel">
@ -65,15 +68,15 @@ onUnmounted(() => clearInterval(timer))
<el-tabs v-model="tab"><el-tab-pane label="告警事件" name="alerts" /><el-tab-pane label="告警规则" name="rules" /></el-tabs> <el-tabs v-model="tab"><el-tab-pane label="告警事件" name="alerts" /><el-tab-pane label="告警规则" name="rules" /></el-tabs>
<template v-if="tab === 'alerts'"> <template v-if="tab === 'alerts'">
<div class="alert-filters"><el-input v-model="keyword" aria-label="搜索告警" placeholder="规则或目标名称" clearable @keyup.enter="load(true)" /><el-select v-model="status" aria-label="告警状态"><el-option value="" label="全部状态" /><el-option value="firing" label="正在告警" /><el-option value="resolved" label="已恢复" /></el-select><el-button :loading="loading" @click="load(true)">查询告警</el-button></div> <div class="alert-filters"><el-input v-model="keyword" aria-label="搜索告警" placeholder="规则或目标名称" clearable @keyup.enter="load(true)" /><el-select v-model="status" aria-label="告警状态"><el-option value="" label="全部状态" /><el-option value="firing" label="正在告警" /><el-option value="resolved" label="已恢复" /></el-select><el-button :loading="loading" @click="load(true)">查询告警</el-button></div>
<el-table :data="alerts" empty-text="暂无告警事件"><el-table-column label="规则 / 目标" min-width="190"><template #default="{ row }"><strong>{{ row.ruleName }}</strong><div class="subtext">{{ row.targetName }}</div></template></el-table-column><el-table-column label="状态" min-width="110"><template #default="{ row }"><el-tag :type="row.status === 'firing' ? 'danger' : 'success'">{{ row.status === 'firing' ? '正在告警' : '已恢复' }}</el-tag></template></el-table-column><el-table-column label="观测值 / 阈值" min-width="160"><template #default="{ row }">{{ row.value.toFixed(1) }} / {{ row.threshold }} {{ row.metric.endsWith('offline') || row.metric === 'node_unavailable' ? '秒' : '%' }}<div v-if="row.metric === 'node_unavailable'" class="subtext">含节点离线状态检测</div></template></el-table-column><el-table-column label="发生 / 恢复" min-width="185"><template #default="{ row }"><div>{{ stamp(row.openedAt) }}</div><div class="subtext">{{ row.resolvedAt ? stamp(row.resolvedAt) : '持续中' }}</div><div class="subtext">{{ reasons[row.resolveReason] }}</div></template></el-table-column><el-table-column label="确认" min-width="150"><template #default="{ row }"><span v-if="row.acknowledgedAt">{{ row.acknowledgedBy }}<div class="subtext">{{ stamp(row.acknowledgedAt) }}</div></span><el-button v-else-if="auth.canOperate && row.status === 'firing'" link type="primary" :disabled="saving" @click="acknowledge(row)">确认告警</el-button><span v-else class="secondary">未确认</span></template></el-table-column></el-table> <el-table :data="alerts" empty-text="暂无告警事件"><el-table-column label="规则 / 目标" min-width="190"><template #default="{ row }"><strong>{{ row.ruleName }}</strong><div class="subtext">{{ row.targetName }}</div></template></el-table-column><el-table-column label="状态" min-width="110"><template #default="{ row }"><el-tag :type="row.status === 'firing' ? 'danger' : 'success'">{{ row.status === 'firing' ? '正在告警' : '已恢复' }}</el-tag></template></el-table-column><el-table-column label="观测值 / 阈值" min-width="160"><template #default="{ row }">{{ row.value.toFixed(1) }} / {{ row.threshold }} {{ unit(row.metric) }}<div v-if="row.status === 'firing' && row.dataFresh === false" class="data-unknown" role="status">数据已过期或缺失;显示最近告警值,等待有效采样</div><div v-if="row.metric === 'node_unavailable'" class="subtext">含节点离线状态检测</div></template></el-table-column><el-table-column label="发生 / 恢复" min-width="185"><template #default="{ row }"><div>{{ stamp(row.openedAt) }}</div><div class="subtext">{{ row.resolvedAt ? stamp(row.resolvedAt) : '持续中' }}</div><div class="subtext">{{ reasons[row.resolveReason] }}</div></template></el-table-column><el-table-column label="确认" min-width="150"><template #default="{ row }"><span v-if="row.acknowledgedAt">{{ row.acknowledgedBy }}<div class="subtext">{{ stamp(row.acknowledgedAt) }}</div></span><el-button v-else-if="auth.canOperate && row.status === 'firing'" link type="primary" :disabled="saving" @click="acknowledge(row)">确认告警</el-button><span v-else class="secondary">未确认</span></template></el-table-column></el-table>
<el-pagination v-model:current-page="page" :page-size="20" :total="total" layout="prev, pager, next, total" @current-change="load()" /><p class="subtext retention-note">确认表示已知悉,指标恢复后自动结束告警。已恢复事件保留 90 天。</p> <el-pagination v-model:current-page="page" :page-size="20" :total="total" layout="prev, pager, next, total" @current-change="load()" /><p class="subtext retention-note">确认表示已知悉,指标恢复后自动结束告警。已恢复事件保留 90 天。</p>
</template> </template>
<template v-else><div class="panel-heading"><p class="subtext">水位按持续时间触发;离线按秒数触发。修改规则后重新计时。</p><el-button v-if="auth.canAdmin" type="primary" @click="edit()">新增规则</el-button></div><el-table :data="rules" empty-text="暂无规则"><el-table-column prop="name" label="规则名称" min-width="190" /><el-table-column label="指标" min-width="190"><template #default="{ row }">{{ metricLabels[row.metric] }}</template></el-table-column><el-table-column label="阈值 / 持续" min-width="155"><template #default="{ row }">{{ row.threshold }}{{ ['node_unavailable', 'terminal_offline'].includes(row.metric) ? ' 秒离线' : `% / ${row.holdSeconds} 秒` }}</template></el-table-column><el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag></template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" width="90"><template #default="{ row }"><el-button link type="primary" @click="edit(row)">编辑规则</el-button></template></el-table-column></el-table></template> <template v-else><div class="panel-heading"><p class="subtext">水位按持续时间触发;离线按秒数触发。修改规则后重新计时。</p><el-button v-if="auth.canAdmin" type="primary" @click="edit()">新增规则</el-button></div><el-table :data="rules" empty-text="暂无规则"><el-table-column prop="name" label="规则名称" min-width="190" /><el-table-column label="指标" min-width="190"><template #default="{ row }">{{ metricLabels[row.metric] }}</template></el-table-column><el-table-column label="阈值 / 持续" min-width="155"><template #default="{ row }">{{ row.threshold }}{{ ['node_unavailable', 'terminal_offline'].includes(row.metric) ? ' 秒离线' : `${unit(row.metric)} / ${row.holdSeconds} 秒` }}</template></el-table-column><el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag></template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" width="90"><template #default="{ row }"><el-button link type="primary" @click="edit(row)">编辑规则</el-button></template></el-table-column></el-table></template>
</section> </section>
<el-dialog v-model="dialog" :title="editId ? '编辑告警规则' : '新增告警规则'" width="540px" :close-on-click-modal="false" :close-on-press-escape="!saving" :show-close="!saving"><el-form label-position="top"><el-form-item label="规则名称"><el-input v-model="form.name" maxlength="80" aria-label="规则名称" :disabled="saving" /></el-form-item><el-form-item label="监控指标"><el-select v-model="form.metric" aria-label="规则监控指标" :disabled="saving" @change="metricChanged"><el-option v-for="(label, value) in metricLabels" :key="value" :value="value" :label="label" /></el-select></el-form-item><el-form-item :label="offline ? '离线阈值(秒)' : '使用率阈值(%)'"><el-input-number v-model="form.threshold" :min="offline ? 90 : 1" :max="offline ? 86400 : 100" :precision="offline ? 0 : 1" aria-label="告警阈值" :disabled="saving" /></el-form-item><el-form-item v-if="!offline" label="持续时间(秒)"><el-input-number v-model="form.holdSeconds" :min="0" :max="3600" :precision="0" aria-label="告警持续时间" :disabled="saving" /></el-form-item><el-form-item label="启用规则"><el-switch v-model="form.enabled" aria-label="启用告警规则" :disabled="saving" /></el-form-item></el-form><el-alert v-if="editId" title="保存会结束本规则当前告警,并按新规则重新计时。" type="info" :closable="false" /><template #footer><el-button :disabled="saving" @click="dialog = false">取消</el-button><el-button type="primary" :loading="saving" :disabled="!form.name.trim()" @click="save">保存规则</el-button></template></el-dialog> <el-dialog v-model="dialog" :title="editId ? '编辑告警规则' : '新增告警规则'" width="540px" :close-on-click-modal="false" :close-on-press-escape="!saving" :show-close="!saving"><el-form label-position="top"><el-form-item label="规则名称"><el-input v-model="form.name" maxlength="80" aria-label="规则名称" :disabled="saving" /></el-form-item><el-form-item label="监控指标"><el-select v-model="form.metric" aria-label="规则监控指标" :disabled="saving" @change="metricChanged"><el-option v-for="(label, value) in metricLabels" :key="value" :value="value" :label="label" /></el-select></el-form-item><el-form-item :label="offline ? '离线阈值(秒)' : temperature ? 'SoC 温度阈值(°C)' : '使用率阈值(%)'"><el-input-number v-model="form.threshold" :min="offline ? 90 : 1" :max="offline ? 86400 : temperature ? 150 : 100" :precision="offline ? 0 : 1" aria-label="告警阈值" :disabled="saving" /></el-form-item><el-form-item v-if="!offline" label="持续时间(秒)"><el-input-number v-model="form.holdSeconds" :min="0" :max="3600" :precision="0" aria-label="告警持续时间" :disabled="saving" /></el-form-item><el-form-item label="启用规则"><el-switch v-model="form.enabled" aria-label="启用告警规则" :disabled="saving" /></el-form-item></el-form><p v-if="terminalMetric" class="subtext terminal-alert-note">对已激活且启用的终端生效;SoC 温度只使用 soc-thermal 传感器。持续时间按新采样累计,重复读取不推进;采样超过 90 秒或缺失会重新计时,已有告警等待有效正常采样才恢复。此规则只在控制台告警,不发送外部通知。</p><el-alert v-if="editId" title="保存会结束本规则当前告警,并按新规则重新计时。" type="info" :closable="false" /><template #footer><el-button :disabled="saving" @click="dialog = false">取消</el-button><el-button type="primary" :loading="saving" :disabled="!form.name.trim()" @click="save">保存规则</el-button></template></el-dialog>
</template> </template>
<style scoped> <style scoped>
.monitor-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.monitor-summary .panel{display:flex;justify-content:space-between;align-items:center;gap:12px}.monitor-summary span{font-size:12px;color:var(--vdi-muted)}.monitor-summary strong{font-size:28px}.trend-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-top:20px}.chart-filters,.alert-filters{display:flex;gap:12px;flex-wrap:wrap}.chart-filters .el-select{width:160px}.alert-filters{margin:18px 0}.alert-filters .el-input{width:230px}.alert-filters .el-select{width:150px}.el-pagination{margin-top:20px;flex-wrap:wrap}.retention-note{margin-top:15px}.subtext{line-height:1.7} .monitor-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.monitor-summary .panel{display:flex;justify-content:space-between;align-items:center;gap:12px}.monitor-summary span{font-size:12px;color:var(--vdi-muted)}.monitor-summary strong{font-size:28px}.trend-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-top:20px}.chart-filters,.alert-filters{display:flex;gap:12px;flex-wrap:wrap}.chart-filters .el-select{width:160px}.alert-filters{margin:18px 0}.alert-filters .el-input{width:230px}.alert-filters .el-select{width:150px}.el-pagination{margin-top:20px;flex-wrap:wrap}.retention-note{margin-top:15px}.subtext{line-height:1.7}.data-unknown{color:var(--el-color-warning);font-size:12px;line-height:1.7;margin-top:6px}.terminal-alert-note{margin:14px 0;font-size:12px}
@media(max-width:1200px){.trend-grid{grid-template-columns:1fr}.panel-heading{flex-wrap:wrap;gap:16px}} @media(max-width:1200px){.trend-grid{grid-template-columns:1fr}.panel-heading{flex-wrap:wrap;gap:16px}}
@media(max-width:600px){.monitor-summary{gap:8px}.monitor-summary .panel{padding:14px;flex-direction:column;align-items:flex-start}.monitor-summary strong{font-size:24px}.chart-filters,.alert-filters{width:100%}.chart-filters .el-select,.alert-filters .el-select{flex:1;min-width:120px}.alert-filters .el-input{width:100%}} @media(max-width:600px){.monitor-summary{gap:8px}.monitor-summary .panel{padding:14px;flex-direction:column;align-items:flex-start}.monitor-summary strong{font-size:24px}.chart-filters,.alert-filters{width:100%}.chart-filters .el-select,.alert-filters .el-select{flex:1;min-width:120px}.alert-filters .el-input{width:100%}}
</style> </style>

View File

@ -11,7 +11,7 @@ const planned = ['公共池自动领取与退出还原', '新桌面自动初始
</script> </script>
<template> <template>
<div class="page-heading"><div><h1>系统设置</h1><p class="subtext">平台接入状态、访问权限与当前功能范围</p></div></div> <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><el-button v-if="auth.canAdmin" type="primary" style="margin-top:22px" @click="pveVisible = true">配置 Proxmox 接入</el-button><p class="settings-footnote">在控制台检查连接并保存;原密钥不回显,留空可保留。</p></section><section v-else-if="section === 'accounts'" class="panel settings-panel"><AdministratorAccounts /></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 ['登录限流与角色权限', '管理员账号维护与密码修改', '计算资源同步', 'GPU 清单与虚拟机配置引用', '已有桌面纳管与电源管理', '桌面批量开关机、重启与逐台结果', '镜像模板登记与 PVE 克隆', '桌面池容量与成员管理', '桌面连接与规格管理', '关机快照创建、删除与手动还原', '终端注册、分组、停用与重新激活', '用户资料、启停与批量桌面授权', '会话状态、历史与重置', 'CSV 批量导入', '节点趋势与告警', '终端 CPU、内存、SoC 温度与历史趋势', '诊断日志采集', '终端策略版本、下发与应用回执', '任务跟踪与操作审计']" :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> <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><el-button v-if="auth.canAdmin" type="primary" style="margin-top:22px" @click="pveVisible = true">配置 Proxmox 接入</el-button><p class="settings-footnote">在控制台检查连接并保存;原密钥不回显,留空可保留。</p></section><section v-else-if="section === 'accounts'" class="panel settings-panel"><AdministratorAccounts /></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 ['登录限流与角色权限', '管理员账号维护与密码修改', '计算资源同步', 'GPU 清单与虚拟机配置引用', '已有桌面纳管与电源管理', '桌面批量开关机、重启与逐台结果', '镜像模板登记与 PVE 克隆', '桌面池容量与成员管理', '桌面连接与规格管理', '关机快照创建、删除与手动还原', '终端注册、分组、停用与重新激活', '用户资料、启停与批量桌面授权', '会话状态、历史与重置', 'CSV 批量导入', '节点趋势与告警', '终端系统指标、历史与阈值告警', '诊断日志采集', '日志诊断关联与诊断包', '终端策略版本、下发与应用回执', '任务跟踪与操作审计']" :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>
<PveConnectionDialog v-model="pveVisible" /> <PveConnectionDialog v-model="pveVisible" />
</template> </template>
<style scoped> <style scoped>

View File

@ -1,35 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { ref } from 'vue'
import { usePlatform } from '../stores/platform' import AuditHistory from '../components/AuditHistory.vue'
import StatusTag from '../components/StatusTag.vue' import TaskHistory from '../components/TaskHistory.vue'
import { api, errorMessage } from '../api' const tab = ref('tasks')
import { useAuth } from '../stores/auth'
import { ElMessage } from 'element-plus'
import { useAction } from '../composables/useAction'
const { busy, run } = useAction()
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> = { 'batch-start': '批量开机提交', 'batch-shutdown': '批量关机提交', 'batch-reboot': '批量重启提交', reconcile_batch_power: '核对批量电源', 'snapshot-create': '创建快照', 'snapshot-delete': '删除快照', 'snapshot-rollback': '还原快照', reconcile_snapshot: '核对快照', 'configure-hardware': '调整 CPU/内存', 'expand-disk': '磁盘扩容', reconcile_hardware: '核对规格', save_pve_connection: '保存 PVE 接入', create_terminal_policy: '创建终端策略', deploy_terminal_policy: '下发终端策略', terminal_policy_applied: '策略应用成功', terminal_policy_failed: '策略应用失败', update_terminal: '更新终端资料', disable_terminal: '停用终端', reactivate_terminal: '重新激活终端', update_user: '更新用户资料', batch_grant_bindings: '批量分配桌面', batch_revoke_bindings: '批量撤销桌面', create_administrator: '新增管理员', update_administrator: '更新管理员', reset_administrator_password: '重置管理员密码', change_own_password: '修改本人密码', reconcile_clone: '核对克隆', clone: '克隆桌面', register_template: '登记模板', update_template: '更新模板', validate_template: '验证模板', save_pool: '保存桌面池', update_pool_members: '更新池成员', configure_desktop: '配置连接', 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> </script>
<template> <template>
<div class="page-heading"><div><h1>任务与审计</h1><p class="subtext">提交记录、执行回执和最终结果分开显示</p></div><el-button @click="data.refresh">刷新</el-button></div> <div class="page-heading"><div><h1>任务与审计</h1><p class="subtext">提交记录、执行回执和最终结果分开显示</p></div></div>
<section class="panel"> <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-tabs v-model="tab"><el-tab-pane label="执行任务" name="tasks" /><el-tab-pane label="操作审计" name="audit" /></el-tabs>
<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> <AuditHistory v-if="tab === 'audit'" />
<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> <TaskHistory v-else />
<el-pagination v-model:current-page="page" :total="tab === 'tasks' ? filtered.length : auditFiltered.length" :page-size="10" layout="total, prev, pager, next" />
</section> </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><el-button v-if="selected?.kind === 'provision' && selected.status === 'timeout' && selected.providerRef && auth.canAdmin" :loading="busy" style="margin-top:20px" @click="run(() => api.reconcileProvision(selected!.id), '已提交核对,只查询原 PVE 任务')">核对克隆结果</el-button><el-button v-if="selected?.kind === 'hardware' && selected.status === 'timeout' && auth.canAdmin" :loading="busy" style="margin-top:20px" @click="run(() => api.reconcileHardware(selected!.id), '已核对实际规格,未重发修改')">核对规格结果</el-button><el-button v-if="selected?.kind === 'snapshot' && selected.status === 'timeout' && auth.canAdmin" :loading="busy" style="margin-top:20px" @click="run(() => api.reconcileSnapshot(selected!.id), '已核对原快照任务,未重发修改')">核对快照结果</el-button><el-button v-if="selected?.kind === 'batch-desktop' && selected.status === 'timeout' && auth.canOperate" :loading="busy" style="margin-top:20px" @click="run(() => api.reconcileBatchPower(selected!.id), '已核对原电源任务,未重发操作')">核对电源结果</el-button><p class="subtext" style="margin-top:22px">结果未确认时先核对实际设备状态,再决定是否重新提交。系统不会自动重放操作。</p></el-drawer>
</template> </template>

View File

@ -40,7 +40,7 @@ async function reactivate(t: Terminal) {
<template> <template>
<div class="page-heading"><div><h1>终端管理</h1><p class="subtext">设备身份、在线心跳与远程命令</p></div><div v-if="auth.canAdmin" class="heading-actions"><el-button @click="csvVisible = true">批量导入</el-button><el-button type="primary" @click="createVisible = true">登记终端</el-button></div></div> <div class="page-heading"><div><h1>终端管理</h1><p class="subtext">设备身份、在线心跳与远程命令</p></div><div v-if="auth.canAdmin" class="heading-actions"><el-button @click="csvVisible = true">批量导入</el-button><el-button type="primary" @click="createVisible = true">登记终端</el-button></div></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-option label="停用" value="disabled" /></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="280"><template #default="{ row }"><div class="actions"><el-button link type="primary" @click="selectedId = row.id">详情</el-button><el-button v-if="auth.canOperate && !disabled(row)" link type="primary" :disabled="busy || pending(row.id)" @click="command(row, 'reboot')">重启盒子</el-button><el-button v-if="auth.canAdmin" link type="primary" :disabled="busy" @click="edit(row)">编辑资料</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> <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-option label="停用" value="disabled" /></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="280"><template #default="{ row }"><div class="actions"><el-button link type="primary" @click="selectedId = row.id">详情</el-button><el-button v-if="auth.canOperate && !disabled(row)" link type="primary" :disabled="busy || pending(row.id)" @click="command(row, 'reboot')">重启盒子</el-button><el-button v-if="auth.canAdmin" link type="primary" :disabled="busy" @click="edit(row)">编辑资料</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><el-button style="margin-top:18px" @click="metricsVisible = !metricsVisible">{{ metricsVisible ? '收起运行指标' : '查看运行指标' }}</el-button><TerminalMetrics v-if="metricsVisible" :terminal-id="selected.id" /><p class="subtext" style="margin:20px 0">重置会话只结束终端连接,不会还原或关机云桌面。网络 RTT 不等于整体输入延迟。</p><div class="actions" v-if="auth.canOperate && !disabled(selected)"><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><div v-if="auth.canAdmin" class="lifecycle-actions"><h3>设备管理</h3><p class="subtext">停用或重新激活会撤销旧凭据。请先结束活动会话并处理未完成命令;重新激活后需在设备上配置新密钥。</p><div class="actions"><el-button :disabled="busy" @click="edit(selected)">编辑资料</el-button><el-button v-if="!disabled(selected)" type="danger" plain :disabled="busy || pending(selected.id) || !!selected.sessionDesktopId" @click="disable(selected)">停用设备</el-button><el-button :disabled="busy || pending(selected.id) || !!selected.sessionDesktopId" @click="reactivate(selected)">重新激活</el-button></div></div></template></el-drawer> <el-drawer :model-value="!!selected" title="终端详情" size="560px" @close="selectedId = ''"><template v-if="selected"><h2>{{ selected.name }}</h2><router-link v-if="auth.canOperate" :to="{ path: '/diagnostics', query: { targetType: 'terminal', targetId: selected.id } }">查看此设备日志与诊断 →</router-link><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><el-button style="margin-top:18px" @click="metricsVisible = !metricsVisible">{{ metricsVisible ? '收起运行指标' : '查看运行指标' }}</el-button><TerminalMetrics v-if="metricsVisible" :terminal-id="selected.id" /><p class="subtext" style="margin:20px 0">重置会话只结束终端连接,不会还原或关机云桌面。网络 RTT 不等于整体输入延迟。</p><div class="actions" v-if="auth.canOperate && !disabled(selected)"><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><div v-if="auth.canAdmin" class="lifecycle-actions"><h3>设备管理</h3><p class="subtext">停用或重新激活会撤销旧凭据。请先结束活动会话并处理未完成命令;重新激活后需在设备上配置新密钥。</p><div class="actions"><el-button :disabled="busy" @click="edit(selected)">编辑资料</el-button><el-button v-if="!disabled(selected)" type="danger" plain :disabled="busy || pending(selected.id) || !!selected.sessionDesktopId" @click="disable(selected)">停用设备</el-button><el-button :disabled="busy || pending(selected.id) || !!selected.sessionDesktopId" @click="reactivate(selected)">重新激活</el-button></div></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="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="editVisible" title="编辑终端资料" width="480px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy" @submit.prevent="saveTerminal"><el-form-item label="终端名称"><el-input v-model="editForm.name" maxlength="80" aria-label="编辑终端名称" /></el-form-item><el-form-item label="分组"><el-input v-model="editForm.group" maxlength="80" aria-label="编辑终端分组" /></el-form-item></el-form><template #footer><el-button :disabled="busy" @click="editVisible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!editForm.name.trim()" @click="saveTerminal">保存终端资料</el-button></template></el-dialog> <el-dialog v-model="editVisible" title="编辑终端资料" width="480px" :close-on-click-modal="false" :show-close="!busy" :close-on-press-escape="!busy"><el-form label-position="top" :disabled="busy" @submit.prevent="saveTerminal"><el-form-item label="终端名称"><el-input v-model="editForm.name" maxlength="80" aria-label="编辑终端名称" /></el-form-item><el-form-item label="分组"><el-input v-model="editForm.group" maxlength="80" aria-label="编辑终端分组" /></el-form-item></el-form><template #footer><el-button :disabled="busy" @click="editVisible = false">取消</el-button><el-button type="primary" :loading="busy" :disabled="!editForm.name.trim()" @click="saveTerminal">保存终端资料</el-button></template></el-dialog>
<el-dialog v-model="activationVisible" title="终端激活信息" width="530px" @closed="secret = ''"><p class="subtext" style="margin-bottom:16px">激活密钥只在此处显示一次,交给设备管理员配置。设备注册后即失效。</p><p class="subtext" style="margin-bottom:12px">设备 SN:{{ activationSN }}</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> <el-dialog v-model="activationVisible" title="终端激活信息" width="530px" @closed="secret = ''"><p class="subtext" style="margin-bottom:16px">激活密钥只在此处显示一次,交给设备管理员配置。设备注册后即失效。</p><p class="subtext" style="margin-bottom:12px">设备 SN:{{ activationSN }}</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>

View File

@ -35,6 +35,8 @@ services:
PVE_TOKEN_ID: ${PVE_TOKEN_ID:?required} PVE_TOKEN_ID: ${PVE_TOKEN_ID:?required}
PVE_TOKEN_SECRET: ${PVE_TOKEN_SECRET:?required} PVE_TOKEN_SECRET: ${PVE_TOKEN_SECRET:?required}
PVE_CA_FILE: /run/secrets/pve-ca.pem PVE_CA_FILE: /run/secrets/pve-ca.pem
VDI_EXCLUSIVE_GPU_NODE: ${VDI_EXCLUSIVE_GPU_NODE:-}
VDI_EXCLUSIVE_GPU_VMIDS: ${VDI_EXCLUSIVE_GPU_VMIDS:-}
volumes: volumes:
- ../.local/live/pve-ca.pem:/run/secrets/pve-ca.pem:ro - ../.local/live/pve-ca.pem:/run/secrets/pve-ca.pem:ro
depends_on: depends_on:

View File

@ -14,6 +14,10 @@
另保留正在讨论的[云端服务与 Web 控制台功能设计](cloud-console-functional-design-20261109.md),用于产品/UI/后端任务拆分;它是讨论稿,不属于本次清理的历史实施记录。 另保留正在讨论的[云端服务与 Web 控制台功能设计](cloud-console-functional-design-20261109.md),用于产品/UI/后端任务拆分;它是讨论稿,不属于本次清理的历史实施记录。
[通用版与高级版首批实现](cloud-editions-20261111.md):两套真实界面、员工授权向导、终端诊断与高级 GPU 任务核对;[助手与后续流程设计](cloud-editions-and-assistant-design-20261111.md)保留后续方案,模型本轮暂不接入。
[高级版日志与诊断中心](cloud-diagnostics-20261111.md):按设备/桌面关联任务、会话、告警和审计,设备日志采集回执与完整诊断包导出;已完成真实盒子采集与页面验收。
[云管开发说明](cloud-platform-development.md)记录第一阶段代码、启动方式、接口与当前限制;本机控制台使用 Go API 和 PostgreSQL;已另行完成[真实 PVE 与盒子接入](cloud-live-integration.md),正式入口为 5182。 [云管开发说明](cloud-platform-development.md)记录第一阶段代码、启动方式、接口与当前限制;本机控制台使用 Go API 和 PostgreSQL;已另行完成[真实 PVE 与盒子接入](cloud-live-integration.md),正式入口为 5182。
[控制台 UI 接入记录](cloud-console-ui-20261109.md)记录按 `ui/` 设计稿接入的深浅主题、页面范围、验证方式与实际数据边界。 [控制台 UI 接入记录](cloud-console-ui-20261109.md)记录按 `ui/` 设计稿接入的深浅主题、页面范围、验证方式与实际数据边界。
@ -52,3 +56,11 @@
- [GPU 清单与配置引用](cloud-gpu-inventory-20261110.md):真实 PCI 清单、IOMMU 分组、当前/待应用直通引用及不完整检查提示;仅资源查看,绑定与健康采集尚未接入。 - [GPU 清单与配置引用](cloud-gpu-inventory-20261110.md):真实 PCI 清单、IOMMU 分组、当前/待应用直通引用及不完整检查提示;仅资源查看,绑定与健康采集尚未接入。
- [终端系统指标与历史](cloud-terminal-metrics-20261110.md):真实 CPU、内存、SoC 温度、运行时长和 30 天历史;串流 FPS/RTT 仍未采集。 - [终端系统指标与历史](cloud-terminal-metrics-20261110.md):真实 CPU、内存、SoC 温度、运行时长和 30 天历史;串流 FPS/RTT 仍未采集。
- [终端系统指标告警](cloud-terminal-alerts-20261111.md):CPU、内存和 SoC 温度阈值、按真实采样持续判断、缺失提示、确认与恢复。
11-11 已补齐[操作审计历史查询与 CSV 导出](cloud-audit-history-20261111.md)及[执行任务历史查询与 CSV 导出](cloud-task-history-20261111.md),均支持数据库分页、多条件查询和全部匹配页导出;外部归档和防篡改仍待后续。
11-11 已补齐[管理员在线登录管理](cloud-admin-sessions-20261111.md):查看登录来源及到期时间,单独撤销指定登录并记录审计,保留当前登录保护。
11-11 已部署[管理员双重验证](cloud-mfa-20261111.md):本人可选绑定身份验证器,动态码与一次性恢复码登录,支持更新恢复码和关闭。既有账号默认不启用。

View File

@ -0,0 +1,38 @@
# 管理员在线登录管理(2026-11-11)
已部署 `http://127.0.0.1:5182/settings` →「管理员账号」→ 对应账号的「在线登录」。仅超级管理员可查看与撤销。
## 行为
- 显示尚未退出、撤销或到期的登录凭据,支持分页和手动刷新。显示来源地址、登录时间、到期时间和浏览器/客户端,并标记当前登录。有效凭据不代表浏览器当前仍在使用。
- 「撤销登录」确认后只撤销所选登录,其他登录与密码保留;该凭据后续管理 API 请求返回 401。当前登录按钮禁用,服务端也返回 409,需使用正常退出入口。
- 新登录记录请求来源及最多 500 字符的 User-Agent;来源经过现有可信代理配置解析。代理/NAT 可能使地址显示为网关,User-Agent 可由客户端填写,均只作排查信息,不作为身份凭据。
- 升级前会话继续有效,来源与登录时间明确显示未知;不推算或伪造旧元数据。到期会话不列入有效列表。
- 查询或撤销失败显示错误并清除旧列表;取消确认不提交,切换账号不会被旧请求覆盖。
## 接口与数据
`GET /api/v1/administrators/{id}/sessions`:每页默认 20 条、最多 100 条,按到期时间及内部标识降序排列。
`DELETE /api/v1/administrators/{id}/sessions/{sessionKey}`:成功 204;不存在、已撤销、已到期或账号不匹配返回 404;本人当前登录返回 409;未登录 401,非超级管理员 403。并发撤销仅一次成功,成功撤销与 `revoke_administrator_session` 审计同事务提交。
公开的 key 为带独立用途前缀的 HMAC-SHA256 管理句柄。列表、路径和审计不暴露 JWT、内部会话标识或密码。句柄自身不能登录。事务内复查操作者当前会话及最新权限;等待期间被撤销的操作者不能继续撤销其他登录。原账号角色、启停和改密撤销全部登录的行为保持兼容。
新增 `admin_session_details` 附表,外键关联 `admin_sessions` 且删除级联。既有 `admin_sessions` 三列保持不变;旧 API 的插入仍兼容。登录与附表写入同一事务。附表无历史回填,现有管理员、密码和设备身份不重置。
## 验收
- 独立测试 PostgreSQL 的 Go 全量测试和 vet 通过,覆盖单独撤销、保留其他登录、三角色权限、当前登录保护、旧登录兼容、过期排除、来源清理与不可信代理、凭据不泄露、并发一次成功、等待锁期间操作者被撤销。
- 前端 lint/typecheck、3 项单元测试和生产构建通过;新增 4 项登录管理及原有 4 项管理员浏览器回归通过,包含确认/取消、错误处理、分页、旧响应保护、深浅主题和 390px。
- 真实临时账号建立两条登录,准确选取并撤销其中一条,旧凭据返回 401,另一条仍可读取本人资料;停用账号撤销剩余登录,审计记录仅含管理句柄。临时账号及会话已定向清理,审计保留。
- 真实深浅主题、1440/390px 界面通过,无脚本异常、失败 API 或浏览器业务写请求。既有账号密码指纹、用户授权、设备身份、告警规则及 VM 配置/电源与本轮部署前基线一致,盒子在线。未执行 VM、磁盘、GPU 或终端控制。
证据:[真实 API 核验](evidence/cloud-admin-sessions-20261111/live-verification.json)、[真实浏览器核验](evidence/cloud-admin-sessions-20261111/browser-verification.json)。首次验收脚本误把按时间倒序的第一条当作首次登录,已改为根据指定凭据的管理句柄精确选择后重验通过,应用实现无需变更。
## 部署与回退
备份 `.local/live/backups/before-admin-sessions-20261111-150936.dump`,原镜像标签 `before-admin-sessions-20261111-150936`。独立打包基线二进制与部署前 SHA-256 相同:`78f454b057fd2de9510fa966ada07654692c609181faa62dc72c96391f0d95ec`;保留已部署 GPU 代码和配置。
回退前核对后续是否另有部署,恢复原 API/console 镜像标签,并用 `.local/admin-sessions-review/compose.release.yml`、原环境文件及 `--project-directory deploy` 执行 `up -d --no-build --no-deps api console`。附表可保留,不删除业务卷或恢复整个数据库覆盖其他工作。旧 API 登录不写附表,再次升级时这些登录也会显示来源未知。
此项仅管理控制台登录,桌面串流会话仍由「远程会话」管理。专用 VM、Windows 副本、存储准备及真实快照还原继续暂缓。

View File

@ -0,0 +1,42 @@
# 操作审计历史查询与 CSV 导出(2026-11-11)
入口:控制台 `http://127.0.0.1:5182/tasks` →「操作审计」。本轮补齐真实 PostgreSQL 查询和导出,执行任务页维持原有行为。
## 使用方式
- 支持关键词、操作者包含、动作、结果与自定义时间范围;时间控件提供最近 24 小时、7/30/90 天快捷项。不选时间查询全部已有历史,包含超过最近 1000 条的旧记录。
- 动作、结果可以选择中文选项,也可输入原始代码;关键词查对象、操作者、动作、结果、请求标识和审计标识,`%`、`_`、反斜杠按普通文字匹配。
- 点击「查询审计」应用条件。每页 20 条,翻页和导出沿用已应用的条件及截止时间,避免新记录不断插入导致页内容移动;修改输入后需重新查询。查询失败清除旧结果,禁止把旧结果当成新条件的导出。
- 「导出查询结果 CSV」包含所有匹配页,最多 10000 条;超量时禁止导出并提示缩小范围,服务端也返回 413,绝不悄悄截取前几页。API 空结果返回表头。
- 页面时间按浏览器本地时区显示;CSV 时间为 UTC,使用 UTF-8 BOM、CRLF 和 CSV 转义,动作与结果保留原始代码。可能被表格软件当公式的单元格增加前置单引号。
## 接口与边界
`GET /api/v1/audit-logs`:数据库分页,默认 `page=1&pageSize=20`,最多每页 100 条。支持可选 `from`、`to`(含时区的 RFC3339)、`keyword`、`actor`、`action`、`result`。动作和结果精确匹配,操作者包含匹配。时间边界含两端;起止同时提供时要求结束晚于开始。没有隐含 1000 条上限,也没有新加历史清理策略。
`GET /api/v1/audit-logs/export`:使用相同筛选条件,忽略分页;成功返回 `text/csv`、附件名和 `X-Export-Count`。只输出时间、操作者、动作、对象、结果、请求标识、审计标识。不会读取密码、设备令牌、PVE 密钥或任务原始参数。导出文件准备成功会记一条 `export_audit_logs` 审计;它表示服务端生成文件,不证明客户端已保存。
查询与导出均要求有效管理员会话;三角色均可访问,与原有审计读取权限一致。不会新增设备控制权限。单次列表的总数和记录来自同一 PostgreSQL 快照,排序为 `created_at DESC,id DESC`。页面固定截止时间,记录正常追加时保持翻页稳定;审计导入时若回填历史时间,仍可能改变后续查询。
审计不再加入全局定时刷新,进入标签页时按需查询。执行任务查询仍为原有最近 1000 条,本轮未改任务历史分页。既有操作是否产生日志仍取决于对应接口,未新增所有鉴权失败或操作前后差异记录;外部归档、签名防篡改、多租户隔离和日志保留策略仍待后续产品化。
## 验证与部署
验证结果和现场部署记录见同目录下 `evidence/cloud-audit-history-20261111/`。测试使用独立一次性 PostgreSQL,不对现场库执行测试清空。
仓库另有并行的独占显卡切换开发。本轮部署使用独立源码目录,只包含已部署的终端告警基线和本轮审计改动;排除这部分并行修改,不修改工作区原文件。通过重建基线与现场二进制比较确认部署基础,保留数据库备份、原镜像标签和源码校验清单。
专用 VM、Windows 模板副本、额外磁盘及真实快照还原继续暂缓。本轮不需要对 VM 或终端发控制命令。
## 本轮验收结果
- 已部署到 5182 真实环境;Go 全量测试与 vet、前端 lint/typecheck/build、3 项单元测试、65 项安全 fixture 页面测试通过,其中新增审计页面 4 项。后端专用测试覆盖超过 1000 条历史记录、10000 条完整导出及 10001 条拒绝、三角色与未登录拒绝、筛选组合和 CSV 转义/公式防护。
- 现场将 root 的 187 条匹配记录按每页 2 条查询,共 94 页;CSV 的 187 个审计标识及顺序与所有匹配页完全一致。另用真实界面按操作者和保存告警规则筛选并下载 CSV。
- 深浅主题、1440 和 390px 真实界面通过;没有页面脚本异常、失败 API 或前端业务写请求。导出接口本身按设计新增审计,验收登录/退出也保留正常审计。
- 账号/密码指纹、用户授权、终端身份及配置版本、告警规则一致;PVE 三台 VM 配置摘要和电源状态保持验收前基线,任务仍为 8 条。验证时 VM100/102 为 running、VM101 为 stopped;这是本轮只读观测,不代表本轮执行过开机。
- 独立打包基线二进制 SHA-256 与部署前二进制一致:`8c87f4e2183bd6f96ff5d67544f0467dc832c6af524ba8f1426aa5ec54d82d75`。本次未部署仓库中并行的独占 GPU 逻辑和相关 Compose 配置。
部署前备份为 `.local/live/backups/before-audit-history-20261111-101812.dump`,原 API/console 镜像保留 `before-audit-history-20261111-101812` 标签。本轮没有新增数据库结构变更。需回退时先核对其他会话是否又有部署,再将两项镜像重新打回原名称,并使用 `.local/audit-review/compose.release.yml` 及 `--project-directory deploy`、原环境文件执行 `up -d --no-build --no-deps api console`。不要删除 PostgreSQL 业务卷,也不要把本轮只读验收的电源状态当成需要强制恢复的目标。
后续更新:执行任务也已补齐[完整历史分页与 CSV 导出](cloud-task-history-20261111.md),本文“最近 1000 条”描述仅为审计批次当时边界。

View File

@ -0,0 +1,41 @@
# 高级版日志与诊断中心
日期:2026-11-11。已部署到真实控制台 `http://127.0.0.1:5182`,入口为“高级版 → 日志与诊断”。
## 已实现
- 按终端设备或云桌面的唯一 ID 关联执行任务、桌面会话、监控告警、操作审计和已回传的终端日志片段。同名设备不会串记录。
- 支持时间范围、记录来源、数据库分页、来源数量及详情展开;最长查询 7 天,默认近 24 小时。修改筛选后清除旧结果,重新查询后才能导出。
- 设备、桌面及关联任务详情可直接进入指定对象诊断。设备选择显示 SN,桌面选择显示 VMID。
- 导出 ZIP 包含 `diagnostics.json` 和 `README.txt`,使用已查询的对象、时间及来源,包含全部匹配页。超过 2000 条时拒绝并提示缩小范围,不静默截断;导出记录审计。
- 在线设备可在确认后提交一次日志采集,显示真实任务回执;已有未完成或结果未知的设备命令时阻止再提交。失败重试复用幂等标识,提交成功与设备执行成功分开显示。
- 超级管理员、运维人员可使用;只读角色隐藏入口,直接页面访问被拦截,接口返回 403。界面模式不改变角色权限。
- 深浅主题和 390px 窄屏布局;日志以纯文本呈现。导出字段采用白名单,并遮盖常见凭据格式。
## 数据含义
对象卡片是查询时的当前快照,不代表所选历史时间点。任务提交条目展示查询时状态,另列最后一次更新事件,不伪造完整的任务状态变更历史。会话按预留、连接、结束分别列事件,因此查询范围之前开始但范围内结束的会话也能查到。
设备视角按终端 ID 及显卡准备任务的 `device:<ID>` 关联;桌面视角按桌面 ID 关联,并包含所属节点告警。只按名称保存、缺少稳定关联标识的旧审计不推测归属。不根据设备当前绑定推断历史日志属于哪台桌面,所以终端日志仅在设备视角出现。
日志来自设备已回传的片段,当前 Qt Agent 读取会话错误日志尾部及版本、启动标识、运行时长和 kiosk 状态。没有记录可能是尚未采集、未上报或超出留存;不代表没有故障。本轮没有实现持续实时日志推送、服务端全量日志归档或自动采集 VM 客体日志。常见凭据遮盖不保证消除所有自由文本中的自定义敏感信息。
## 测试与真实验收
- 后端 6 项诊断专项集成测试通过,覆盖同名隔离、旧会话结束时间、跨来源分页、权限/参数、ZIP 完整性/限额、白名单与凭据遮盖;使用一次性 PostgreSQL,未对业务库运行集成测试。
- Go 全量 `go test ./... -count=1`、`go vet ./...` 通过;全量测试之后仅增加能力标识及调整导出说明,最终 API 生产镜像构建通过。
- 前端 TypeScript、ESLint、6 项单元测试及生产 Docker 构建通过。9 项诊断页面测试、12 项两版界面测试、4 项 GPU 核对回归共 25 项通过;修正截图动画后另复验 1 项视觉用例。
- 真实 API 对 1 台设备和 3 台桌面核验近 24 小时查询、全部分页、5 类来源筛选与 ZIP 内容一致;临时运维账号可读/导出,临时只读账号 403,未登录 401。临时账号和会话已清理,保留审计。
- 真实浏览器核验:通用版 5 个入口、超级管理员高级版 14 个入口、设备/桌面深链、第二页、详情、筛选失效、实际下载、只读角色限制;1440/390 宽度及深浅主题通过,无脚本错误或失败 API 响应。
- 进一步通过实际页面确认并提交 **1 次 `collect-logs`**,`qt-platform/0.4.0` 真实盒子成功回执,页面展示日志并下载诊断包。导出中有 1 条对应日志,日志尾部 11981 个字符,内容保留在本机私有目录;公共证据仅保存元数据和收起日志后的截图。
- 采集完成后,原管理员密码/角色、桌面连接配置、用户/授权、设备身份/配置版本及告警规则仍与更新前一致。任务数 63→64,唯一新增任务为此次成功日志采集;快照任务数不变。未发送 VM 电源、GPU 切换、会话重置或终端重启。
证据:[真实接口核验](evidence/cloud-diagnostics-20261111/live-verification.json)、[真实页面核验](evidence/cloud-diagnostics-20261111/browser-verification.json)、[实际采集回执](evidence/cloud-diagnostics-20261111/collection-verification.json)、[采集后的状态核对](evidence/cloud-diagnostics-20261111/collection-state-verification.json)、[页面截图](evidence/cloud-diagnostics-20261111/live-diagnostics-light.png)。
## 发布与回退
从上一轮已部署源码 `.local/editions-review/release` 生成隔离发布目录,只叠加本轮 17 个文件;基线 API 重建二进制 SHA256 与更新前实际运行二进制一致。没有包含其他会话正在修改的终端/串流代码。
更新前备份:`.local/live/backups/before-diagnostics-20261111-174813.dump`,已校验归档目录。旧 API/控制台镜像均保留标签 `before-diagnostics-20261111-174813`。此次只更新 API、控制台,没有数据库结构变更,未重启 PostgreSQL,业务库 `fsync=on`。
需要回退时使用该标签的两套镜像和本轮保留的 compose 配置,仅重建 API/控制台;不要恢复整个业务库或使用 MFA 之前的 API。镜像身份与文件校验见[发布记录](evidence/cloud-diagnostics-20261111/deployment.json)和[文件清单](evidence/cloud-diagnostics-20261111/manifest.json)。

View File

@ -0,0 +1,48 @@
# 通用版与高级版:首批实现
日期:2026-11-11。真实控制台:`http://127.0.0.1:5182`。
## 使用入口
登录后,侧栏上方可切换“通用版 / 高级版”。新账号默认进入通用工作台;浏览器按管理员账号记住选择。直接打开旧的专业页面仍可访问,并切到高级导航。两版共用真实后台、数据、任务和审计,切换界面不会获得额外权限。本期实现的是两种操作界面,尚未实施商业许可、收费或组织功能包限制。
## 通用版
| 页面 | 已接入的真实功能 |
| --- | --- |
| 工作台 `/workbench` | 运行中的桌面、在线设备、活动连接;员工分配、设备检查和问题处理捷径;未分配员工、离线/待激活设备、未确认任务摘要 |
| 员工与桌面 `/people` | 查找、添加员工;先选桌面、查看方案,再追加或撤销指定授权;桌面名称、使用人、电源和连接状态;单台开机、正常关机、重启的确认提交 |
| 终端设备 `/devices` | 查找设备、在线状态、使用人、当前桌面;登记设备并显示一次性激活密钥;进入指定设备诊断 |
| 问题处理 `/help` | 只读检查设备启用、在线、员工关联、员工启用、有效桌面授权、电源、初始化及已知连接服务状态;当前触发中告警前 20 条与总数 |
| 操作记录 `/records` | 人员/配置审计和设备/桌面任务两类查询,服务端分页与关键词检索,展示实际结果和操作人 |
员工新增、授权、设备登记仍要求超级管理员;桌面电源允许运维人员;只读人员只能查看。本人密码、双重验证和主题切换在两版均保留,超级管理员也能从通用版顶部进入系统设置。
授权复用已有批量增减接口,只修改选定员工与桌面的关系,保留其他授权、员工状态和关联设备。查看方案不会写入;保存成功不代表桌面已经开机或完成串流。撤销会作废未消费的旧票据,已有串流不被强制断开。
电源操作提交后等待实际任务回执;界面不会将提交成功显示为设备已执行成功。存在排队、执行中或结果未知的关联任务时,通用版阻止再次操作。生产环境验收没有操作业务桌面的电源。
连接服务为“未检测”时不能认定桌面不可用;首页展示真实电源运行数量,详细诊断保留“尚未确认就绪”的说明。设备在线、服务状态通过也不能替代实际网络、画面与输入延迟验收。全局数据或告警读取失败时明确报错,不显示“没有告警”作为正常结论。
## 高级版
保留 13 个专业入口:总览、桌面、远程会话、终端、终端策略、计算资源、GPU、监控告警、模板、桌面池、用户授权、任务审计和系统设置。导入、批量电源预检、连接配置、规格、快照、模板/池、指标/规则、管理账号与登录会话等继续使用原有实现及验收范围。
本轮另补齐“任务与审计 → 执行任务 → 任务详情 → 核对显卡准备结果”,接入已有 `POST /tasks/{id}/gpu-reconcile`。仅超级管理员/运维人员可核对 `gpu-prepare` 类型且结果未知的任务。提交前说明:只读取原 PVE 任务与组内实际电源状态,不重发开关机;核对成功结束未确认流程并释放预留,原任务标记失败,需要重新准备时另行发起。缺少原 UPID、原任务未结束、实际电源未知时显示后台拒绝,保留待查状态。
## 测试与真实验收
- 前端 lint、TypeScript、生产 Docker 构建通过;6 项单元测试通过。
- 12 项版本/通用页面浏览器测试、4 项 GPU 核对浏览器测试,以及 18 项双重验证、任务历史、审计、在线会话回归通过,共 34 项。首页摘要调整后另复验默认入口、账号隔离及深浅窄屏 3 项。
- 4 项 GPU 服务端集成测试通过,包括未知提交不重放、原任务无法读取时拒绝、可核对后释放预留。使用一次性 PostgreSQL 与 PVE 测试服务,没有对真实业务库运行集成测试。
- 真实浏览器完成:登录默认通用、两版切换、按账号记忆、临时员工新增、查看方案无写入、两次追加授权、仅撤销其中一个、真实审计查询。临时员工与其授权已清理,保留操作审计。
- 真实 1440 / 390 宽度、深浅主题及五个通用页面检查通过;无脚本错误或失败 API 响应。
- 原账号、授权、桌面连接配置、设备身份及告警规则快照一致;API 镜像与数据库容器保持原部署,业务库 `fsync=on`。此次只发布经过隔离比对的控制台改动。
证据:[浏览器验收](evidence/cloud-editions-20261111/browser-verification.json)、[原状态核对](evidence/cloud-editions-20261111/state-verification.json)、[真实通用工作台](evidence/cloud-editions-20261111/live-common-workbench-dark.png)、[高级界面](evidence/cloud-editions-20261111/live-advanced-overview.png)。
## 后续边界
按用户要求,本轮不接模型,不展示假的“一句话 AI”。现有向导为明确选择对象的真实授权流程,跨步骤方案持久化、模型解释、受控脚本目录尚未实现。当前尚未把员工关联设备、员工停用、批量业务操作简化到通用版,这些仍在高级版完成。
高级版页面归类不改变原未完成功能的状态:通用 GPU 绑定与健康、扩展终端策略、外部通知、OTA、目录认证、公共池自动领取/还原、自动初始化、独立交付等继续按原计划逐项完成。GPU 核对以隔离集成与页面测试验收,没有为验收人为制造真实显卡切换超时。专用 VM、Windows 副本、额外存储准备和真实恢复验收仍按用户要求暂缓。

View File

@ -0,0 +1,126 @@
# 通用版、高级版与一句话助手设计
日期:2026-11-11。状态:两版界面及首批真实业务流程已按[实施记录](cloud-editions-20261111.md)落地;本文其余方案对象、模型与脚本内容仍为后续设计。用户已明确本轮不接模型,先完成通用/高级界面的区别。
## 产品定位
通用版面向不熟悉虚拟化的日常管理员,以“开通员工、分配桌面、查看设备、处理问题”组织流程。高级版面向 IT 运维,提供节点、模板、存储、规格、GPU、策略、监控和任务等详细管理。
建议使用同一套 Go 后台、数据库、任务与审计,两套导航和操作流程。现有控制台作为高级界面的基础,新增通用工作台。界面模式与角色权限分别管理:只读账号切换到高级界面仍只有只读权限;通用版管理员发放桌面仍需要后端授权。
第一期以“通用模式 / 高级模式”落地,设置默认入口并记住个人选择;需要商业版本销售时,再配置组织可用的功能包及服务端许可校验,避免维护两个代码分支。没有高级版授权的安装不能通过切换模式或直接调用接口开启受限功能。
## 两版功能划分
| 业务 | 通用版 | 高级版 |
| --- | --- | --- |
| 首页 | 健康摘要、常用操作、需要处理的事、助手 | 资源水位、节点、任务、详细告警及助手 |
| 员工与桌面 | 添加员工、分配/撤销桌面、关联盒子 | 用户批量导入、批量授权、分组筛选及详情 |
| 桌面管理 | 业务名称、使用人、是否运行、能否连接;开机/正常关机/重启 | VMID、节点、模板、池、规格、批量预检及维护详情 |
| 新增桌面 | 选择人数与用途,优先分配已有桌面;已就绪模板可进入发放向导 | 节点、存储、模板版本、规格及池成员配置 |
| 终端管理 | 设备在线状态、名称、使用人、激活向导 | 分组、策略版本、部署回执、系统指标、诊断日志 |
| 故障处理 | “桌面打不开 / 设备离线”等引导,说明原因和下一步 | 原始指标、关联事件、任务、诊断及专业核对入口 |
| 监控 | 简明健康状态、待处理事件 | 趋势、阈值规则、确认、历史和缺失采样详情 |
| GPU/存储/快照 | 常规业务页通过可用性和限制提示体现 | 提供专业页面;具体操作按已实现及已验收能力开放 |
| 记录 | 操作结果、谁操作、何时完成 | 全历史检索、CSV、任务关联与审计 |
| 账号安全 | 本人改密、双重验证;管理账号按角色开放 | 同样保留安全能力,并展示详细账号及会话管理 |
通用版减少术语与必填参数,仍明确区分“桌面正在开机”“连接服务未就绪”“设备离线”和“数据已过期”。“断开连接”“重启桌面”“还原数据”分别命名,不使用容易混淆的“一键修复”。
现有未完成或暂缓的能力继续保留边界:真实克隆与快照还原验收、公共池自动领取/还原、通用 GPU 绑定、OTA、外部通知等不会因为新增助手变成可用。当前已有指定 GPU 的桌面准备与切换能力,也不能推广为任意显卡都可自动分配。
## 通用版页面交给 UI 的内容
建议只保留五个一级入口:工作台、员工与桌面、终端设备、问题处理、操作记录。账号菜单保留安全设置,接入配置由有权限的管理员通过设置进入。
工作台结构:
```text
工作台 [账号] [设置]
今天的平台情况
[可用桌面] [在线设备] [正在使用] [需要处理的问题]
你想完成什么?
[例如:给张三分配一台办公桌面 ] [生成方案]
[开通新员工] [分配已有桌面] [批量开机] [桌面打不开]
常用操作 需要处理的事
添加员工 / 激活盒子 设备离线、授权缺失、失败任务
最近操作
对象、操作、当前进度、结果、查看详情
```
生成方案后以卡片显示:目标、推荐做法、影响对象、需要的资源、尚缺条件、执行步骤。主要按钮为“执行方案”“修改需求”,旁边提供“查看详情”。有未满足的条件时显示具体下一步,执行按钮不可用。
高级版助手可使用同样的卡片,额外展开对象标识、节点/存储、参数、检查结果和底层任务。通用版默认展示用户能理解的业务名称,详情仍可追溯。
## 一句话助手如何工作
```text
用户描述 → 识别意图和缺失信息 → 读取真实资源与权限
→ 生成方案 → 预检与展示 → 用户执行 → 逐步任务与结果
```
脚本或工作流负责检查和执行。理解自由描述、解释结果需要接入语言模型;固定场景也可通过同一套表单向导操作。两者共用执行能力,模型不可用时仍可使用明确的业务向导。
默认先生成方案。查状态、解释已有告警等读取请求直接返回结果;修改配置、授权或电源的请求先展示具体对象与影响,再由用户点击执行。涉及重启、断开或数据恢复时,明确展示会中断什么、会改变什么。
### 示例:给张三分配一台办公桌面
1. 匹配真实用户;同名时提供候选选择。用户不存在时,补充登录名等必要信息,生成“创建用户并分配”的方案。
2. 查看当前授权与已有可分配桌面。已有合适桌面时优先复用,避免每次都新建 VM。
3. 需要新建时检查已登记模板、存储、克隆权限及当前支持范围;条件不足就展示缺少什么。普通 Windows 业务镜像尚未完成模板验收时,不能直接承诺自动发放。
4. 方案卡片说明分配给谁、使用哪台桌面、是否新建、是否关联终端、是否需要开机。
5. 执行后分别展示“用户已创建 / 授权已保存 / 终端已关联 / 桌面可连接”等实际结果。
上述为交互示例,不代表当前已有名为张三的用户或空闲资源。人数、规格和模板名称均须由真实环境或管理员预设产生。
### 首批适合接入的六种请求
| 用户说的话 | 方案或返回结果 | 当前接口基础与实施范围 |
| --- | --- | --- |
| 给张三分配一台办公桌面 | 匹配人员、选桌面、授权;可选关联盒子 | 用户/桌面查询、现有绑定接口;新增跨步骤方案编排 |
| 把研发组的桌面都开起来 | 固定目标清单、逐台预检、可执行/跳过原因 | 已有批量电源预检与提交;“研发组桌面”的映射不明确时需选择目标 |
| 这个盒子为什么没有桌面 | 注册/启用、绑定用户、授权、心跳等只读检查 | 现有终端/用户/授权信息;新增诊断规则 |
| 看看今天有哪些异常 | 汇总真实告警、失败任务、离线设备 | 已有告警与任务历史接口;说明查询时区和时间范围 |
| 给这个员工换一台桌面 | 展示旧/新授权及当前使用情况,确认后改授权 | 现有绑定/批量授权;不将撤销授权误写成旧 VM 已删除或串流已断开 |
| 我想再增加几个人使用 | 现有资源与模板前提检查、复用/新增建议、实施步骤 | 可做规划;容量结论需明确估算依据,真实克隆仍按现场验收限制开放 |
预设“标准办公”“开发”等用途时,规格、允许模板、是否允许新建和资源预留由管理员配置。预设未配置时只询问影响方案的关键信息,不凭模型常识替客户决定资源或软件授权。
## 接口与脚本的实现方式
增加业务动作目录,由代码定义每种动作的参数、权限、预检和执行器。语言模型只能从目录中选择动作和填写参数,执行器再次验证。
优先复用当前平台接口:用户和桌面查询、用户绑定、CSV 预检、批量电源预检/任务、模板验证和克隆预检等。脚本适合已有接口未覆盖的受控运维步骤,以版本化模板登记;每个模板定义输入、执行位置、超时、返回结构及允许的任务类型。
脚本通过平台执行器获得限定的参数和凭据,凭据保留在服务端。模型生成的自由文本不能直接作为 Shell、PowerShell、SSH 命令执行;界面无需让普通用户复制命令。
建议新增的后台对象及接口(设计,当前尚未存在):
| 对象/接口 | 内容 |
| --- | --- |
| 业务动作目录 | 查询范围、参数定义、权限、执行器、版本、预检要求 |
| `POST /assistant/plans` | 输入需求,生成方案草稿;缺参数时返回简短问题或候选对象 |
| `POST /assistant/plans/{id}/check` | 对已确定的对象和动作预检,保存检查时间与资源版本 |
| `POST /assistant/plans/{id}/execute` | 对同一版本的方案进行幂等提交,重新核对权限和资源 |
| `GET /assistant/plans/{id}` | 方案步骤、执行状态、关联任务和结果 |
| 方案及步骤存储 | 意图、对象标识、参数、预检快照、执行人、每步结果及任务关联 |
执行权限取当前真实登录身份,同时检查版本可用功能;模型和脚本不能越过服务端 RBAC。只读账号可查和生成允许范围内的方案,执行写操作仍会被后端拒绝。日志、设备名称、用户描述和模型输出都按数据处理,不能改变动作权限。
方案必须保存确定的对象标识和版本。“所有设备”也先展开成可核对的清单,不在执行时重新解释为另一批对象。资源、授权或方案发生变化时重新预检。脚本版本在方案内固定,执行前变更不能悄悄套用。
每步拥有稳定操作标识并关联现有 task/command;HTTP 提交成功只代表已提交。对于尚无原生幂等支持的接口,编排层需保存调用状态并提供结果核对。结果未知的步骤先核对,不重发;部分成功时保留成功结果、停止依赖步骤并给出具体处理建议。跨系统工作流不承诺全局事务回滚,补偿动作单独展示和执行。
## 按项实施与验收
1. 通用工作台与导航:接真实数据、业务名称、常用操作,保留高级界面;测试权限、空态、失败、深浅主题和窄屏。
2. 业务向导与方案对象:先完成“已有桌面授权”“批量开机预检”“终端无桌面诊断”。测试预检无业务副作用、对象唯一性、重复点击和逐步结果。
3. 接语言模型:使用真实模型服务,验证输出动作参数,展示可执行方案;测试缺信息、同名对象、服务失败、越权请求及日志中的恶意指令。模型供应方式、外发数据范围、密钥和费用上限由部署配置决定。
4. 扩展脚本模板与流程:选择一个现场可验证的流程接入,测试模板版本、超时、离线、部分成功和结果核对,再开放下一项。
5. 商业版本授权:功能分包、服务端校验、到期与升级流程,单独验收。账号安全与审计保留为两版基础能力。
按用户要求,每项完成并通过对应测试后再进入下一项。原先暂缓的专用 VM、Windows 副本、存储准备和真实还原验收继续暂缓,不因本设计自动恢复。

View File

@ -16,9 +16,9 @@
| 终端与会话 | 设备登记、注册、心跳、名称/分组修改、停用/重新激活与旧令牌撤销;授权列表、票据、串流状态、重启/会话重置/诊断日志回执;运维策略模板、按设备/分组下发与应用回执;90 天会话历史与名称快照 | Wi-Fi、USB、应用与显示策略;重新激活需管理员更新设备身份配置 | | 终端与会话 | 设备登记、注册、心跳、名称/分组修改、停用/重新激活与旧令牌撤销;授权列表、票据、串流状态、重启/会话重置/诊断日志回执;运维策略模板、按设备/分组下发与应用回执;90 天会话历史与名称快照 | Wi-Fi、USB、应用与显示策略;重新激活需管理员更新设备身份配置 |
| 桌面使用者 | 新增、姓名编辑、启停、更换/解除关联终端、多桌面授权、批量追加/撤销、CSV 用户与授权导入、未消费票据作废 | 用户目录集成、用户自主登录模式 | | 桌面使用者 | 新增、姓名编辑、启停、更换/解除关联终端、多桌面授权、批量追加/撤销、CSV 用户与授权导入、未消费票据作废 | 用户目录集成、用户自主登录模式 |
| GPU | 已部署真实节点 PCI 显卡清单、IOMMU 分组、当前/待应用配置引用与纳管关系;直通配置由现场手工维护 | 实际驱动/运行占用、独占锁与绑定/解绑、健康指标和 GPU broker;配置引用不表示可分配 | | GPU | 已部署真实节点 PCI 显卡清单、IOMMU 分组、当前/待应用配置引用与纳管关系;直通配置由现场手工维护 | 实际驱动/运行占用、独占锁与绑定/解绑、健康指标和 GPU broker;配置引用不表示可分配 |
| 监控告警 | 节点历史曲线、CPU/内存/存储与节点异常/终端离线规则、控制台告警确认及自动恢复;终端 CPU/内存/SoC 温度/运行时长、历史曲线和 30 天留存;缺失值保持空 | VictoriaMetrics、外部通知、终端系统指标告警、完整串流 FPS/RTT 采集 | | 监控告警 | 节点历史曲线、CPU/内存/存储与节点异常/终端离线规则、控制台告警确认及自动恢复;终端 CPU/内存/SoC 温度/运行时长、历史曲线和 30 天留存,以及终端 CPU/内存/SoC 温度持续告警;缺失值保持空 | VictoriaMetrics、外部通知、完整串流 FPS/RTT 采集 |
| OTA | 尚无发布闭环 | 包签名、适配机型、灰度批次、暂停/恢复、Agent 安装、A/B 回滚 | | OTA | 尚无发布闭环 | 包签名、适配机型、灰度批次、暂停/恢复、Agent 安装、A/B 回滚 |
| 批量与交付 | 本机 Docker/真实 PostgreSQL、用户/终端/授权 CSV 预检及整批导入、操作审计与任务记录 | 独立服务器、MQTT/Rust Agent、七服务独立部署、备份恢复演练、离线交付 | | 批量与交付 | 本机 Docker/真实 PostgreSQL、用户/终端/授权 CSV 预检及整批导入、操作审计与任务记录、审计全历史分页/多条件查询与 CSV 导出(单次 10000 条) | 独立服务器、MQTT/Rust Agent、七服务独立部署、备份恢复演练、离线交付 |
画质/延迟、上网代理、软件白名单和品牌兜底画面继续按原有计划单独处理。 画质/延迟、上网代理、软件白名单和品牌兜底画面继续按原有计划单独处理。
@ -135,4 +135,23 @@ Go 全量测试与 `go vet` 通过,使用独立临时 PostgreSQL,覆盖账
## 终端系统指标与历史(2026-11-10) ## 终端系统指标与历史(2026-11-10)
5182 的「终端管理 → 详情 → 查看运行指标」已接通真实 CPU、内存、SoC 温度和运行时长,支持 1 小时/24 小时/7 天趋势、30 天留存、缺失断线和过期隐藏。盒子升级为 `qt-platform/0.4.0`,真实连续采样已入库,旧 Agent 兼容通过;没有将目标 FPS 或请求耗时当作串流实测指标。Go 全量及静态检查、前端检查/构建、57 项页面回归和盒子 6 项 CTest 通过,真实深浅主题及 390px 页面检查无异常。原设备配置、账号密码哈希指纹、桌面参数和授权一致,三台 VM 的配置 digest 与运行状态保持。详见[采集范围与验收](cloud-terminal-metrics-20261110.md)。串流 FPS/RTT、GPU 健康指标及系统指标告警仍待接入。 5182 的「终端管理 → 详情 → 查看运行指标」已接通真实 CPU、内存、SoC 温度和运行时长,支持 1 小时/24 小时/7 天趋势、30 天留存、缺失断线和过期隐藏。盒子升级为 `qt-platform/0.4.0`,真实连续采样已入库,旧 Agent 兼容通过;没有将目标 FPS 或请求耗时当作串流实测指标。Go 全量及静态检查、前端检查/构建、57 项页面回归和盒子 6 项 CTest 通过,真实深浅主题及 390px 页面检查无异常。原设备配置、账号密码哈希指纹、桌面参数和授权一致,三台 VM 的配置 digest 与运行状态保持。详见[采集范围与验收](cloud-terminal-metrics-20261110.md)。串流 FPS/RTT、GPU 健康指标仍待接入;系统指标告警已在 11-11 接入,见后续记录。
## 终端系统指标告警(2026-11-11)
5182「监控告警」新增终端 CPU、内存、SoC 温度规则,按真实新采样计算持续时长;计时不早于规则启用/修改,重复评估不推进,缺失/过期不冒充恢复。支持原有三角色权限、确认、规则启停和 90 天事件历史;摄氏度与百分比分开显示。Go 全量与静态检查、前端检查/构建及 61 项隔离页面回归通过,旧规则/事件/待触发状态的重复升级验证通过。无需更新终端,未操作 VM、磁盘或 GPU。详见[规则与验收边界](cloud-terminal-alerts-20261111.md)。
11-11 补充:[操作审计历史查询与 CSV 导出](cloud-audit-history-20261111.md)补齐超过最近 1000 条的历史查询,支持时间、操作人、动作、结果和请求标识检索。执行任务历史已在后续批次补齐,见[任务历史查询与 CSV 导出](cloud-task-history-20261111.md)。
## 管理员在线登录管理(2026-11-11)
已部署「系统设置 → 管理员账号 → 在线登录」,支持来源、登录/到期时间、分页与指定登录撤销。Go 全量/vet、前端检查/构建、8 项页面回归与真实临时账号双登录撤销验收通过。旧登录来源显示未知,当前登录保护,审计不暴露凭据。见[使用说明与测试证据](cloud-admin-sessions-20261111.md)。
## 管理员双重验证(2026-11-11)
已部署右上角「双重验证」:本人可选绑定身份验证器、动态码或恢复码登录、恢复码更新与关闭。Go 全量/vet、前端检查/构建、6 项新增页面和 12 项既有回归通过,并完成真实临时只读账号及实际页面完整验收。root 登录方式保持,见[功能、证据与回退限制](cloud-mfa-20261111.md)。
11-11 追加:[通用/高级两版界面](cloud-editions-20261111.md)已在 5182 部署,包含通用员工授权向导、设备登记/诊断、简明记录和高级显卡准备结果核对。两版共用真实 API/RBAC;首批 34 项页面测试、6 项单元测试和 GPU 专项集成通过,真实临时员工授权流程验收后已清理。模型暂不接入,剩余高级事项保持原验收边界。
11-11 追加:[高级版日志与诊断中心](cloud-diagnostics-20261111.md)已在 5182 部署:设备/桌面按唯一 ID 关联五类记录,数据库分页、来源/时间筛选、设备采集回执和完整 ZIP 导出。6 项后端专项、Go 全量/vet、25 项浏览器回归及真实接口/页面验收通过;真实盒子成功完成一次只读日志采集。现有身份、授权、连接配置及告警规则保持,唯一新增任务为该次采集。持续实时日志、全量归档和客体自动采集仍待后续。

View File

@ -0,0 +1,50 @@
# 管理员双重验证(2026-11-11)
已部署本机 `http://127.0.0.1:5182`。入口为右上角锁形按钮「双重验证」,三个角色均可维护本人设置。既有账号默认不启用;root 密码和原登录方式保持,未为用户账号自动绑定验证器。
## 使用
1. 点击「双重验证」,输入当前密码开始绑定。在身份验证器中手动添加账号,填入页面显示的密钥,选择基于时间、6 位、30 秒更新;本期提供手动密钥,不生成二维码。
2. 输入动态码并点击「验证并开启」。绑定准备有效 10 分钟,必须在发起绑定的同一登录中确认;确认前不启用。关闭准备窗口不会启用验证,重新开始会生成新密钥。
3. 保存只显示一次的 8 条恢复码,可下载文本。每条只能使用一次;关闭窗口后无法再次查看原码。开启后其他登录立即失效,当前登录保留。
4. 登录先验证用户名和密码,启用账号随后出现动态码/恢复码输入框。验证成功前不签发或保存登录令牌。动态码成功使用一次后需等待身份验证器刷新,服务端容许前后一个 30 秒时间步的时钟误差。
5. 更新恢复码或关闭验证,均须当前密码及有效动态码/恢复码,并二次确认。更新使全部旧恢复码作废,关闭恢复密码登录;两项操作均撤销其他登录,保留当前登录。
动态码遵循 [RFC 6238](https://www.rfc-editor.org/rfc/rfc6238),使用 SHA-1、6 位和 30 秒时间步。请保持验证器和服务器时间同步。丢失验证器和全部恢复码时需要受控运维恢复,当前网页不提供其他管理员代删 MFA、邮件找回或目录服务恢复;重置密码也不会绕过已有双重验证。
## 接口与保护
| 接口 | 用途 |
| --- | --- |
| `GET /api/v1/auth/mfa` | 本人启用状态、剩余恢复码数量,不回显密钥 |
| `POST /api/v1/auth/mfa/setup` | 当前密码验证后生成临时绑定密钥 |
| `POST /api/v1/auth/mfa/confirm` | 验证动态码、启用并返回一次性恢复码 |
| `POST /api/v1/auth/mfa/recovery` | 当前密码和验证代码校验后更新恢复码 |
| `DELETE /api/v1/auth/mfa` | 当前密码和验证代码校验后关闭 |
| `POST /api/v1/auth/login` | 新增可选 `code`;启用账号必须通过第二因素 |
- 临时和启用密钥使用 AES-GCM 加密,独立用途密钥由 `VDI_JWT_SECRET` 派生,附加认证数据绑定管理员标识。备份恢复须保留对应环境密钥,不能只恢复数据库或随意替换 JWT 密钥。
- 恢复码使用 128 位随机值,库中仅保存哈希;消费、会话创建、恢复码轮换、设置修改及相关审计使用事务。TOTP 时间步单调递增,同一动态码及同一恢复码的并发请求仅一次成功。
- 账号行锁将登录与权限/密码/MFA 修改串行,事务内复核当前登录及最新账号状态。开启、关闭及恢复码更新分别记录 `enable_mfa/disable_mfa/rotate_mfa_recovery`,审计不保存密钥、明文恢复码、密码或验证码。
- MFA 设置验证和登录各使用独立持久化尝试预算:每账号每 5 分钟 10 次、每来源地址 30 次。失败尝试和成功尝试均计数;返回 429 与 Retry-After。错误密码不会暴露账号是否已启用 MFA,也不会消费恢复码。
- 密钥、恢复码和密码只在当前页面流程中短暂保留,关闭窗口清理,不写入 localStorage/sessionStorage。恢复码仅通过明确下载操作保存到用户本地。
新增 `admin_mfa/admin_mfa_recovery` 附表,不改原账号及会话表字段。关闭 MFA 删除本人附表记录及恢复码;删除临时管理员时级联清理。验证设置取消或过期后不激活,过期密文保留至下一次绑定覆盖或账号清理。
## 验收
- 独立 PostgreSQL 的 Go 全量测试与 vet 通过;专项通过 RFC 六组 SHA-1 测试向量(含 2038 年之后)、时间窗口、动态码重复使用拒绝、恢复码重复使用拒绝、并发消费、密文账号绑定、三角色本人维护、准备过期/跨登录拒绝及尝试限制。另补充超级管理员重启后设置保留及改密仍需第二因素的验证,专项通过。
- 前端 lint/typecheck、3 项单元测试和生产构建通过;新增 6 项 MFA 页面测试通过,既有管理员/在线登录/界面 12 项回归通过。最初 3 条错误文案断言未包含请求编号,修正断言后全部通过,应用提示逻辑正常。
- 真实临时只读账号完成开启、原登录撤销、密码单独登录拒绝、真实当前时间动态码登录、动态码重复拒绝、恢复码登录及重复拒绝、恢复码更新及旧码失效、关闭后密码登录。设置变化审计不含秘密,临时账号及其 MFA/会话已清理。
- 真实浏览器操作完成绑定确认、恢复码下载、登录第二因素、恢复码登录及关闭;1440/390px 深浅主题通过,无脚本异常、非预期 API 错误或越界写请求。真实截图仅展示登录提示及启用状态,不保存有效密钥或恢复码。
- 原账号/密码指纹、用户授权、终端身份与配置、告警规则及三台 VM 配置摘要一致,盒子在线。验收期间盒子另行提交成功的 `gpu-prepare` 任务:业务任务 61→62,VM100 开机、VM101 关机,VM102 保持关机。该次设备任务和前后状态已完整保留,不把电源变化当作本轮 MFA 测试操作,也未强制恢复现场状态。
证据:[真实接口](evidence/cloud-mfa-20261111/live-verification.json)、[真实页面](evidence/cloud-mfa-20261111/browser-verification.json)。集成测试只使用一次性 `vdi-mfa-test-db`,未在业务库清空数据;隔离库为减少清理同步等待临时关闭 fsync,因此这些测试不证明数据库断电恢复能力,业务 PostgreSQL 配置未改动。
## 部署与回退
部署前备份 `.local/live/backups/before-mfa-20261111-154828.dump`,原镜像标签 `before-mfa-20261111-154828`。独立构建基线二进制与部署前 SHA-256 一致:`ac04d8f5423b07aa2afa442013a4ddeaee55bbf0343a0876c50a8e829c6a4779`,保留已部署 GPU 功能及 Compose 配置。
旧 API 不检查新增 MFA 表,直接回退会绕过第二因素。只要存在已启用 MFA 的账号,就必须保留强制验证的兼容 API,不能直接回退到旧镜像;部署脚本的失败恢复也加入了启用账号检查。只有确认没有启用账号且没有后续部署时,才可恢复原镜像并沿用 `.local/mfa-review/compose.release.yml` 和原环境配置;附表可保留,不删除业务卷或恢复整库覆盖其他工作。
此项为可选的本人 TOTP 与恢复码,尚未实现组织强制策略、目录集成、硬件安全密钥或 WebAuthn。专用 VM、Windows 副本、额外磁盘及真实快照还原仍按用户要求暂缓。

View File

@ -142,7 +142,7 @@ go test ./...
- 在线按最近 90 秒心跳计算;FPS/RTT 缺失为 null,不填 0。PVE 电源状态与 VM 内连接服务状态分开。 - 在线按最近 90 秒心跳计算;FPS/RTT 缺失为 null,不填 0。PVE 电源状态与 VM 内连接服务状态分开。
- 任务成功必须有计算适配结果或设备回执。提交后发生通信异常时保留“结果未确认”,由管理员核对实际状态,不自动重试。 - 任务成功必须有计算适配结果或设备回执。提交后发生通信异常时保留“结果未确认”,由管理员核对实际状态,不自动重试。
- 桌面“重启”只用 PVE reboot;未将硬重启 reset 冒充“还原镜像”。 - 桌面“重启”只用 PVE reboot;未将硬重启 reset 冒充“还原镜像”。
- 当前任务和审计列表展示最近 1000 条,完整记录仍存 PostgreSQL;长期检索和归档后续扩展。 - 任务和审计已支持完整历史的数据库分页、组合检索及 CSV 导出;单次导出最多 10000 条。全局轮询只读取近期任务和全部未结束任务;外部归档、防篡改待扩展。详见[任务历史](cloud-task-history-20261111.md)和[审计历史](cloud-audit-history-20261111.md)。
- 凭据不写入审计;设备上报的自由文本不直接放入公开任务结果。 - 凭据不写入审计;设备上报的自由文本不直接放入公开任务结果。
## 管理员维护(2026-11-10) ## 管理员维护(2026-11-10)
@ -184,3 +184,12 @@ go test ./...
## 终端系统指标与历史(2026-11-10) ## 终端系统指标与历史(2026-11-10)
5182 的「终端管理 → 详情 → 查看运行指标」已接通真实 CPU、内存、SoC 温度和运行时长,支持 1 小时/24 小时/7 天趋势、30 天留存、缺失断线和过期隐藏。盒子升级为 `qt-platform/0.4.0`,真实连续采样已入库,旧 Agent 兼容通过;没有将目标 FPS 或请求耗时当作串流实测指标。Go 全量及静态检查、前端检查/构建、57 项页面回归和盒子 6 项 CTest 通过,真实深浅主题及 390px 页面检查无异常。原设备配置、账号密码哈希指纹、桌面参数和授权一致,三台 VM 的配置 digest 与运行状态保持。详见[采集范围与验收](cloud-terminal-metrics-20261110.md)。串流 FPS/RTT、GPU 健康指标及系统指标告警仍待接入。 5182 的「终端管理 → 详情 → 查看运行指标」已接通真实 CPU、内存、SoC 温度和运行时长,支持 1 小时/24 小时/7 天趋势、30 天留存、缺失断线和过期隐藏。盒子升级为 `qt-platform/0.4.0`,真实连续采样已入库,旧 Agent 兼容通过;没有将目标 FPS 或请求耗时当作串流实测指标。Go 全量及静态检查、前端检查/构建、57 项页面回归和盒子 6 项 CTest 通过,真实深浅主题及 390px 页面检查无异常。原设备配置、账号密码哈希指纹、桌面参数和授权一致,三台 VM 的配置 digest 与运行状态保持。详见[采集范围与验收](cloud-terminal-metrics-20261110.md)。串流 FPS/RTT、GPU 健康指标及系统指标告警仍待接入。
## 终端系统指标告警(2026-11-11)
5182「监控告警」新增终端 CPU、内存、SoC 温度规则,按真实新采样计算持续时长;计时不早于规则启用/修改,重复评估不推进,缺失/过期不冒充恢复。支持原有三角色权限、确认、规则启停和 90 天事件历史;摄氏度与百分比分开显示。Go 全量与静态检查、前端检查/构建及 61 项隔离页面回归通过,旧规则/事件/待触发状态的重复升级验证通过。无需更新终端,未操作 VM、磁盘或 GPU。详见[规则与验收边界](cloud-terminal-alerts-20261111.md)。
2026-11-11:[管理员在线登录管理](cloud-admin-sessions-20261111.md)已上线,提供单独撤销与登录来源查看。新增兼容附表,密码与原会话表不重置。
2026-11-11:[可选管理员 TOTP 双重验证](cloud-mfa-20261111.md)已上线。既有账号默认不启用;启用账号必须验证动态码或恢复码。存在启用账号时禁止直接回退到不校验 MFA 的旧 API。

View File

@ -0,0 +1,28 @@
# 执行任务历史查询与 CSV 导出(2026-11-11)
已部署本机 `http://127.0.0.1:5182/tasks` →「执行任务」,使用真实 PostgreSQL 数据。
## 使用与接口
支持关键词、操作者包含、动作、类型、状态、提交时间范围;每页 20 条,详情可打开旧任务,并保留既有核对、诊断入口。不选起始时间可查全部历史,不再截取最近 1000 条。查询固定截止时间,任务状态随平台刷新,新增任务需重新查询;导出使用已应用条件,修改草稿后不会混用。查询失败清空旧结果并禁用导出。
- `GET /api/v1/tasks`:SQL 分页,默认每页 20、最多 100 条。可选 `keyword/actor/action/kind/status/targetId/from/to/active`;关键词覆盖任务、目标、操作者、动作和结果。`%/_/反斜杠` 按普通文字匹配,时间使用含时区 RFC3339,边界包含两端。
- `active=true` 查询全部 queued/running/timeout。全局刷新只取最近 100 条与分页拉全的未结束任务,旧任务继续阻止冲突操作,避免每 5 秒下载完整历史。
- `GET /api/v1/tasks/export`:共享筛选,忽略分页,UTF-8 BOM/CRLF,10 列,时间为 UTC。成功生成记 `export_tasks` 审计;不包含 provider_ref、幂等键或原始任务参数。公式单元格加单引号。最多 10000 条,超过返回 413 并提示缩小范围;不会静默截断。
三角色均可查询和导出,未登录返回 401。列表总数和记录使用同一数据库快照,稳定排序 `created_at DESC,id DESC`。固定截止时间可避免正常追加造成翻页移动;历史时间回填和状态筛选结果变化仍可能改变后续页。没有新增历史清理或归档。
## 验收结果
- 独立 PostgreSQL Go 全量及 vet 通过;专项覆盖 1052 条旧任务分页、旧活动任务、组合筛选、字面通配符、导出 10000/10001 条边界、权限和公式防护。
- 前端 lint、类型检查、3 项单元测试、生产构建通过。任务历史新增 4 项浏览器用例通过;原有快照、规格、批量电源和审计 26 项回归通过。
- 真实环境 51 条任务按每页 2 条查询,共 26 页;CSV 的 51 个任务标识及顺序与完整分页一致。组合筛选、active、401 和非法条件拒绝、导出审计通过。
- 真实深浅主题、1440/390px 页面、任务详情与 CSV 下载通过,无脚本错误、失败 API 或前端业务写请求。账号密码指纹、用户授权、终端身份及配置、告警规则、VM 配置和电源状态均与部署前一致,任务数仍为 51。
证据见 [真实接口核验](evidence/cloud-task-history-20261111/live-verification.json) 与 [真实浏览器核验](evidence/cloud-task-history-20261111/browser-verification.json)。独立打包以已部署 GPU 功能为基线,保留既有 GPU 配置;基线二进制 SHA-256 与部署前一致:`913056171c513393c24d15abfabcd7efd142a0d0bef3db6ae934f7ffa2cd8a8a`。
## 回退与范围
无数据库结构变更。部署前备份 `.local/live/backups/before-task-history-20261111-145521.dump`,原镜像标签 `before-task-history-20261111-145521`。回退前先核对后续部署,再将原 API/console 镜像打回服务名,使用 `.local/task-history-review/compose.release.yml`、原环境文件、`--project-directory deploy` 执行 `up -d --no-build --no-deps api console`,不删除业务卷。
专用测试 VM、Windows 副本、存储和真实还原仍按用户要求暂缓。此项不执行 VM、磁盘、GPU 或盒子控制命令。外部归档、防篡改仍待后续。

View File

@ -0,0 +1,50 @@
# 终端系统指标告警(2026-11-11)
本轮在已有真实 CPU、内存、SoC 温度采集之上,接通终端系统指标的规则、持续判断、事件、确认和恢复。入口为控制台「监控告警」,无需更新盒子客户端。
## 使用
超级管理员在「告警规则 → 新增规则」选择终端 CPU、终端内存或终端 SoC 温度,配置高水位与持续时间。使用率阈值为 1~100%,SoC 温度阈值为 1~150°C,持续时间为 0~3600 秒;值大于等于阈值视为超限。温度选项默认 80°C、持续 120 秒,只是表单初值,需按实际设备调整。
新增规则作用于已激活且启用的终端。旧客户端、不支持的传感器、空值和过期采样均不按正常值处理。SoC 温度只使用已有 `soc-thermal` 采集,不等同于 CPU 或 GPU 温度。系统升级不会自动创建或启用这三类终端规则。
运维人员可查看和确认告警,只读人员可查看。确认表示已知悉,不能代替指标恢复。修改或停用规则按现有流程结束该规则的当前事件,原因标为「规则变更」,并重新计时。已恢复事件保留 90 天。
本轮仅控制台告警,外部通知、终端 GPU 健康指标和串流 FPS/RTT 仍未接入。
## 数据与持续时间
- 评估循环沿用 15 秒周期,读取设备最近的真实采样。设备心跳和指标都须在 90 秒以内,设备须仍启用、已注册且支持系统指标。
- 持续时间按源采样的时间差计算,起点不早于规则最近启用或修改时间;规则启用前的旧样本不占用持续时长。重复评估同一条采样不会累加。持续时间为 0 时,首次有效超限样本即可触发。
- 无效或缺失采样清除待触发计时,保留已发生的告警。采样间隔或监测中断超过 90 秒,新的超限采样重新开始计时。
- 为避免漏过评估间隔中的短暂恢复,使用已有历史采样检查上次评估之后是否出现正常值或空值;发现后从当前样本重新计时。检查仅回看最近 90 秒,利用终端/时间索引。
- 这是采样级持续判断,不代表两次采样之间每一瞬间都被测量;服务长时间中断后会保守地重新计时。
- 已触发事件在数据缺失时保持未恢复,页面明确显示「数据已过期或缺失;显示最近告警值,等待有效采样」。恢复有效采样后,仍超限就继续原事件,正常才自动恢复,不重复创建同一未结束事件。
- 终端停用或重新激活导致目标不可用时结束事件,原因标为「目标移除或停用」,不冒充指标自然恢复。
## 接口和迁移
沿用 `/api/v1/monitor/rules`、`/monitor/rules/{id}`、`/monitor/alerts` 和 `/monitor/alerts/{id}/acknowledge`。
规则的 `metric` 增加 `terminal_cpu`、`terminal_memory`、`terminal_temperature`。事件增加可空的 `dataFresh`:终端系统指标返回当前来源是否有效,其他指标返回 `null`;事件值在数据缺失时仍为最近告警值,不能视作当前测量值。设置能力增加 `terminal-metric-alerts`,OpenAPI 与前端类型同步生成。
启动迁移扩展 `alert_rules_metric_check`,在 `alert_rules` 增加内部更新时间 `updated_at`,在 `alert_states` 增加可空 `last_sample_at`;不清空原规则、事件、待触发状态或业务数据。反复启动可重复执行。节点资源和离线规则继续沿用原有判断,不按终端源采样方式计算。
## 验证与回退
使用独立临时 PostgreSQL 验证三指标、持续源时间、重复评估、缺失/过期/旧 Agent、不连续采样、恢复、目标撤销、阈值/RBAC和旧库重复升级。控制台验证单位、温度上限、规则提交、错误保留、过期提示、确认语义、权限及深浅主题/窄屏。完整测试和现场结果见 [证据目录](evidence/cloud-terminal-alerts-20261111/)。
更新前备份为私有 `.local/live/backups/before-terminal-alerts-20261111-092529.dump`;旧 API 和控制台镜像标签为 `before-terminal-alerts-20261111-092529`。回退旧 API 前应先用新版本停用这三类终端指标规则,防止旧评估器误解未知指标。新列可以保留;不要通过删卷或恢复旧库覆盖新增业务数据。
本轮不修改设备策略或二进制,不操作 VM 电源、快照、磁盘、GPU 或模板。现场原有 VM 状态以更新前基线为准,而不是沿用前一天的运行状态。
## 最终现场结果
API 与控制台已更新到本机 5182。真实盒子保持 `qt-platform/0.4.0` 在线,无客户端更新。临时将温度阈值设为 1°C、持续 60 秒验证真实链路,没有给设备加压或制造过热;最终复验覆盖 4 条真实温度样本,事件从开始到触发约 102 秒(包含采样与评估周期),起点不早于规则启用时间。确认后事件仍在告警;随后将该规则设为 **80°C / 120 秒、停用**,事件按“规则变更”结束。验收期间的测试事件保留在历史中,未将这一流程冒充实际过热或自然降温恢复;正常值自动恢复、过期和缺失流程由隔离数据库及页面测试验证。
Go 全量测试、`go vet`、旧表结构两次升级、前端 lint / 类型 / 3 项单元测试 / 生产构建及 61 项页面回归通过。真实深浅主题、1440px 与 390px 页面检查无脚本异常、失败响应或写请求,截图展示验收结束后的停用规则和已确认历史事件。
原账号密码哈希指纹、终端身份、授权、桌面参数和既有规则一致;VM102 保持运行,VM100/101 保持关机,三台 VM 的配置 digest 均与本轮更新前一致。期间另有一次资源同步任务新增,已原样保留,本轮没有提交桌面或终端任务。没有操作终端、PVE 电源、快照、磁盘或 GPU。
证据:[后端与页面测试](evidence/cloud-terminal-alerts-20261111/validation.json)、[真实触发与确认](evidence/cloud-terminal-alerts-20261111/live-verification.json)、[真实页面](evidence/cloud-terminal-alerts-20261111/browser-verification.json)。

View File

@ -24,7 +24,7 @@ Agent 版本为 `qt-platform/0.4.0`。采集只读取系统文件,不启动额
- `GET /api/v1/terminals/{id}/metrics?from=...&to=...` 返回当前值、在线/新鲜度、最近采样时间和聚合历史。未知终端 404,无管理登录 401。单次查询最长 7 天,6 小时以内按 60 秒、24 小时以内按 300 秒、更长按 1800 秒聚合。 - `GET /api/v1/terminals/{id}/metrics?from=...&to=...` 返回当前值、在线/新鲜度、最近采样时间和聚合历史。未知终端 404,无管理登录 401。单次查询最长 7 天,6 小时以内按 60 秒、24 小时以内按 300 秒、更长按 1800 秒聚合。
- CPU/内存/温度历史为桶内均值,运行时长为桶内最大值。显式返回空桶,页面在缺失处断线;粗粒度聚合可能合并同一桶内的短暂中断,不表示整桶内持续在线。 - CPU/内存/温度历史为桶内均值,运行时长为桶内最大值。显式返回空桶,页面在缺失处断线;粗粒度聚合可能合并同一桶内的短暂中断,不表示整桶内持续在线。
- 设备停用、心跳或指标超过 90 秒,当前数值返回空值;历史仍可查。设备重新激活会清除旧身份的当前指标,保留历史。 - 设备停用、心跳或指标超过 90 秒,当前数值返回空值;历史仍可查。设备重新激活会清除旧身份的当前指标,保留历史。
- 现有观测循环定期清理 30 天以前的历史。当前使用 PostgreSQL;VictoriaMetrics 和指标告警规则尚未接入。 - 现有观测循环定期清理 30 天以前的历史。当前使用 PostgreSQL;VictoriaMetrics 尚未接入;终端系统指标告警已于 11-11 补齐,见[规则与采样判断](cloud-terminal-alerts-20261111.md)。
API 契约由 `tools/generate-platform-openapi.mjs` 生成,并同步到控制台类型。 API 契约由 `tools/generate-platform-openapi.mjs` 生成,并同步到控制台类型。

View File

@ -2,6 +2,8 @@
截至 2026-11-10,依据仓库中的实施记录整理。“已验证”以对应日期和范围为准,并非持续实时健康报告;11-10 启动认证修复已重新连接盒子核验。 截至 2026-11-10,依据仓库中的实施记录整理。“已验证”以对应日期和范围为准,并非持续实时健康报告;11-10 启动认证修复已重新连接盒子核验。
2026-11-11 更新:Ubuntu、麒麟已接入 GTX 965M 渲染与 NvFBC/NVENC,三桌面按用户授权轮流直通同一块显卡;平台和盒子已部署“退出当前串流、选择下一台时正常关机再启动”的切换流程。下文原 Linux 软件编码资源表保留为历史基线,本轮配置、测量与尚未通过的稳定性条件以 [GPU 优化实施记录](evidence/linux-gpu-20261111/README.md) 为准,不能将其描述为三桌面并发或稳定 2K60。
## 产品与架构 ## 产品与架构
当前主线是 RK3528 盒子上的 Qt kiosk,通过 Moonlight/Sunshine 接入 Windows、Ubuntu、麒麟。盒子开机进入“选择云桌面”,选择后启动串流,结束后回到列表。11-09 已在独立 production 数据库接通 PVE VM100/101/102 和真实盒子:设备注册/心跳、授权列表、一次性票据、三系统串流事件与远程会话重置。服务目前在开发机 Docker,详见[真实接入说明](cloud-live-integration.md)。 当前主线是 RK3528 盒子上的 Qt kiosk,通过 Moonlight/Sunshine 接入 Windows、Ubuntu、麒麟。盒子开机进入“选择云桌面”,选择后启动串流,结束后回到列表。11-09 已在独立 production 数据库接通 PVE VM100/101/102 和真实盒子:设备注册/心跳、授权列表、一次性票据、三系统串流事件与远程会话重置。服务目前在开发机 Docker,详见[真实接入说明](cloud-live-integration.md)。
@ -32,6 +34,7 @@
| 事项 | 记录与验证边界 | | 事项 | 记录与验证边界 |
| --- | --- | | --- | --- |
| 终端系统指标告警 | 11-11 新增 CPU/内存/SoC 温度持续规则、采样有效性与恢复判断;61 项页面回归通过,无需更新终端。见[规则与验收](cloud-terminal-alerts-20261111.md) |
| 终端系统指标 | 11-10 已部署真实 CPU、内存、SoC 温度、运行时长与历史趋势;盒子 0.4.0 连续入库,57 项页面回归和 6 项 Qt 测试通过。串流 FPS/RTT 仍未采集。见[采集与验收](cloud-terminal-metrics-20261110.md) | | 终端系统指标 | 11-10 已部署真实 CPU、内存、SoC 温度、运行时长与历史趋势;盒子 0.4.0 连续入库,57 项页面回归和 6 项 Qt 测试通过。串流 FPS/RTT 仍未采集。见[采集与验收](cloud-terminal-metrics-20261110.md) |
| GPU 资源查看 | 11-10 已部署真实显卡清单、PCI/IOMMU、当前/待应用配置引用和纳管关系;现场识别 Intel 核显、GTX 965M 及 VM100 引用,51 项页面回归通过。未修改直通配置,独占分配和健康指标尚未接入。见[功能与验收](cloud-gpu-inventory-20261110.md) | | GPU 资源查看 | 11-10 已部署真实显卡清单、PCI/IOMMU、当前/待应用配置引用和纳管关系;现场识别 Intel 核显、GTX 965M 及 VM100 引用,51 项页面回归通过。未修改直通配置,独占分配和健康指标尚未接入。见[功能与验收](cloud-gpu-inventory-20261110.md) |
| 桌面批量电源 | 11-10 已部署最多 50 台的开机/正常关机/重启、逐台预检、批次历史、幂等任务和原结果核对;45 项页面回归通过,现场仅做预检及取消,三台 VM 保持运行。专用 VM/Windows 副本和存储准备按用户要求暂缓。见[功能与边界](cloud-batch-operations-20261110.md) | | 桌面批量电源 | 11-10 已部署最多 50 台的开机/正常关机/重启、逐台预检、批次历史、幂等任务和原结果核对;45 项页面回归通过,现场仅做预检及取消,三台 VM 保持运行。专用 VM/Windows 副本和存储准备按用户要求暂缓。见[功能与边界](cloud-batch-operations-20261110.md) |
@ -74,3 +77,13 @@
## 11-10 关机快照与手动还原 ## 11-10 关机快照与手动还原
本机 5182 已部署真实快照列表、创建/删除/手动还原任务及只读核对,维护期间和未知结果阻止新连接与桌面修改。还原撤销旧票据并要求重新确认初始化。Go 全量、37 项浏览器回归和窄屏复验通过;现场只读检查保持原业务配置和三台 VM 运行,盒子心跳正常。当前 Token 无快照权限,PVE 报告 VM100 不支持快照,真实写入尚未验收;公共池退出自动还原未实现。见[快照与还原说明](cloud-snapshots-20261110.md)。 本机 5182 已部署真实快照列表、创建/删除/手动还原任务及只读核对,维护期间和未知结果阻止新连接与桌面修改。还原撤销旧票据并要求重新确认初始化。Go 全量、37 项浏览器回归和窄屏复验通过;现场只读检查保持原业务配置和三台 VM 运行,盒子心跳正常。当前 Token 无快照权限,PVE 报告 VM100 不支持快照,真实写入尚未验收;公共池退出自动还原未实现。见[快照与还原说明](cloud-snapshots-20261110.md)。
11-11 已补齐[操作审计历史查询与 CSV 导出](cloud-audit-history-20261111.md)及[执行任务历史查询与 CSV 导出](cloud-task-history-20261111.md),均支持数据库分页、多条件查询和全部匹配页导出;外部归档和防篡改仍待后续。
11-11 已补齐[管理员在线登录管理](cloud-admin-sessions-20261111.md):查看登录来源及到期时间,单独撤销指定登录并记录审计,保留当前登录保护。
11-11 已部署[管理员双重验证](cloud-mfa-20261111.md):本人可选绑定身份验证器,动态码与一次性恢复码登录,支持更新恢复码和关闭。既有账号默认不启用。
11-11 已部署[通用版与高级版](cloud-editions-20261111.md):通用版五个日常入口,真实员工授权、设备登记、只读问题诊断和简明操作记录;高级版保留专业管理并补齐显卡准备超时任务核对入口。模型暂不接入,界面模式不改变账号权限或原功能验收范围。
11-11 已部署[高级版日志与诊断中心](cloud-diagnostics-20261111.md):按唯一设备/桌面关联任务、会话、告警、审计和设备日志,支持 7 天内筛选、分页与完整 ZIP 导出;真实盒子通过页面采集成功并展示/导出。6 项后端专项、Go 全量/vet、25 项页面回归及真实权限/深浅/窄屏检查通过。持续实时日志、全量归档和 VM 客体日志自动采集仍未实现。

View File

@ -0,0 +1,16 @@
{
"time": "2026-11-11T07:17:20.960Z",
"realAPI": true,
"themes": [
"dark",
"light"
],
"viewportWidths": [
1440,
390
],
"scriptErrors": [],
"failedResponses": [],
"blockedWrites": [],
"screenshots": 3
}

View File

@ -0,0 +1,19 @@
{
"time": "2026-11-11T07:09:39.915702+00:00",
"backup": ".local/live/backups/before-admin-sessions-20261111-150936.dump",
"backupBytes": 256037,
"oldImageTag": "before-admin-sessions-20261111-150936",
"oldImages": {
"api": "sha256:92138b1111fab09270b192ce465ba66fa98393158bace1a359a7553f92dda046",
"console": "sha256:828b045abf8c6eca17592a1bfbd2b62f8e50b723f4dbd6439d7f3ebfbccc3fd7"
},
"deployedAt": "2026-11-11T07:14:11.930124+00:00",
"baselineBinarySHA256": "78f454b057fd2de9510fa966ada07654692c609181faa62dc72c96391f0d95ec",
"baselineMatchesLiveBinary": true,
"existingGPUConfigurationPreserved": true,
"activeJobsBeforeDeployment": 0,
"newImages": {
"api": "sha256:4bc0609343b3c9382c859444579c0246bc12bb6ff787c2d0fb784ec7942db3b7",
"console": "sha256:52c38fa17705374202f557edd959271e0fae1c88d9f104a356b266dbd54b5e83"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -0,0 +1,16 @@
{
"time": "2026-11-11T07:17:17.750800+00:00",
"mode": "production",
"realDatabase": true,
"twoSessionsCreated": true,
"selectedSessionRevoked": true,
"otherSessionValid": true,
"sessionsHideCredentials": true,
"auditRecorded": true,
"accountDisableRevokedSessions": true,
"authenticationEnforced": true,
"administratorsUsersBindingsDeviceIdentityUnchanged": true,
"alertRulesUnchanged": true,
"vmConfigAndPowerUnchanged": true,
"businessTaskAndSnapshotCountsUnchanged": true
}

View File

@ -0,0 +1,17 @@
{
"time": "2026-11-11T02:22:58.470Z",
"realAPI": true,
"themes": [
"dark",
"light"
],
"viewportWidths": [
1440,
390
],
"downloaded": true,
"scriptErrors": [],
"failedResponses": [],
"blockedWrites": [],
"screenshots": 4
}

View File

@ -0,0 +1,20 @@
{
"time": "2026-11-11T02:18:16.487141+00:00",
"backup": ".local/live/backups/before-audit-history-20261111-101812.dump",
"backupBytes": 206044,
"oldImageTag": "before-audit-history-20261111-101812",
"oldImages": {
"api": "sha256:040ca70d5ecf42a0db2c43d089bc3e1da85d98730a2eb22cfb043babe94fc2d4",
"console": "sha256:919d26c7ee5e0292fb22d1fcbb7b6144a6eb110cce6271351cc5bdc2a80d1034"
},
"deployedAt": "2026-11-11T02:22:33.443673+00:00",
"baselineBinarySHA256": "8c87f4e2183bd6f96ff5d67544f0467dc832c6af524ba8f1426aa5ec54d82d75",
"baselineMatchesLiveBinary": true,
"concurrentGPUCodeExcluded": true,
"composeGPUConfigurationExcluded": true,
"activeJobsBeforeDeployment": 0,
"newImages": {
"api": "sha256:4b01050834124b526eba7b5ee32839bfffccfa43a674f86c53c9e3f29199bfb3",
"console": "sha256:b4d15d6fa16c3576ca2beba1132369917554dcc3978e8609547a46483cf0324d"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -0,0 +1,23 @@
{
"time": "2026-11-11T02:22:54.883217+00:00",
"mode": "production",
"realDatabase": true,
"matchingRecords": 187,
"pageSize": 2,
"pages": 94,
"csvRows": 187,
"csvMatchesAllPages": true,
"exportAudited": true,
"authenticationEnforced": true,
"invalidRangeRejected": true,
"administratorsUsersBindingsDeviceIdentityUnchanged": true,
"alertRulesUnchanged": true,
"vmConfigAndPowerUnchanged": true,
"vmPowerStates": [
"running",
"stopped",
"running"
],
"businessTaskAndSnapshotCountsUnchanged": true,
"taskCount": 8
}

View File

@ -0,0 +1,140 @@
{
".dockerignore": "1ec5c1cbf7804fe50681775ff7204bd0a753ace315c071e089fb35b6cc38e24a",
"console/.env.example": "ab72c957a6f5895d608a924ddd6bcc4c92044ed854465ccd38490bf2896d7128",
"console/Dockerfile": "0ec09a6f15331a85b24d5ef278719827ea2a9bb6eb0759cfa4ddf6377f4c88a7",
"console/eslint.config.js": "d55522173395ade075e0ae7bac6567ad25ed218e5862184e9edffc011e23a72a",
"console/index.html": "04a80010b9d643f1e922b53b76242632d1bb23a55151f87e370644e2868e624f",
"console/package-lock.json": "bdf4236cdb3ffd48a8a8d2394a5324c3b3beabc6c82f382c5ecd82ca1f402a1b",
"console/package.json": "cd2cb1bf4e74cfd35547ae47cae6b77296ce62a6a6aaa986ed6d0c0695530851",
"console/playwright.config.ts": "0d3eab93c9da1dcb9c4ae3106af46784edafe9ef45c9104ee2fa07c1ab281aa1",
"console/playwright.live.config.ts": "6d965bfed9446c0b398083d70ac12b4cddbd702ce3bbbcf6dcb70ce10527eb06",
"console/README.md": "b8fb84bb09b8b634d2dfb53aeb6f9a984cc6aacb71b39820f91e5cc573515cec",
"console/tsconfig.json": "becebae4321654e60c434896219fbada01bd8a8ddf3325e39e70a460a7667cea",
"console/vite.config.ts": "22fb15d5fd246e1511318444e59126a03a152b7385e3fd19518c85e9d4a5e5a9",
"console/vitest.config.ts": "74ef7d84fb000f03870f49b271d17f329d1c8c4b043db3f31dede2b12a88e069",
"deploy/compose.dev.yml": "60e2545268c994f4efa410abc7a0a4878d80c1e93ae3094cf37548f4615595da",
"deploy/compose.live.yml": "70b60e5dfc89369e7f26665b1310a73638a259798a77d07fcc5a9f0d464c6c9d",
"deploy/nginx.conf": "c81448d1329fd13bc50fe8e5969b8fe049477bc3c7ff43eefa74d6c0c769d803",
"deploy/nginx.live.conf": "3c5bd01b9e9d5e50cae6fd36568fdcb2b96a7480d2690f30928e8b458befb493",
"platform/.env.example": "4d979ea700686c54830ca039ef4649ff0f653545720db29c37cf4227e4758572",
"platform/Dockerfile": "61ae69a5b8ed1b19d31b5f47e628ac978d95a54ca15936aeb688504c9a5961ca",
"platform/go.mod": "559117ddf04023e338455ab192790b813a4a153f1ea5bd936ea0907bd2b572a8",
"platform/go.sum": "25fa1de5f07dd2cce19db1534dd066262c32e93dcbb6118cac87ff8996854097",
"platform/README.md": "52ce4e99d72ac4333fad8b2342a22f88dc7bf4c49c08a6f13fab7f8e28fa2d71",
"platform/pkg/proxmox/client.go": "8e8c256adb8ca37c85f453bc592087b80742ffccb76f640da264e28367e2995b",
"platform/pkg/proxmox/client_test.go": "5a2ce7031fd0b402608a4d10d5f5d2cf4c41938f6fedc8ecfa0723de837b13ef",
"platform/pkg/proxmox/gpu.go": "946fa364aabba8b96679c8ee184e407f7e09c1a5667930fdbedd1480ccc615d4",
"platform/pkg/proxmox/gpu_test.go": "b72fe8a8f06fcead0b73dd71bea3803b4ff51e1513abff95fa71ed578dab119e",
"platform/pkg/proxmox/hardware.go": "d35be1f147872101f88ccec84e60050da46579d2563b7b0fd013a77cb4bc3a2b",
"platform/pkg/proxmox/hardware_test.go": "60ebff73c6ae9d9356638f84b5e1b41464529ac71988bc1f98a8262551923c5a",
"platform/pkg/proxmox/provision.go": "eea12dd036f325adf8ca6d3add8a075a873f837f191cc24d5d61c49bcb496f66",
"platform/pkg/proxmox/provision_test.go": "dc100d228fa1cda0fe02bf5052660b6bd348932e717bd8caf4bfe096cac05163",
"platform/pkg/proxmox/snapshots.go": "5620699da3345bea052d2b31dc5005496ed610918d7a63668b1986ee4d71d0ea",
"platform/pkg/proxmox/snapshots_test.go": "5c2aaab24c87894316a473ad97b1488b84e0108c1db56b07fc242f03b39e3969",
"platform/internal/config/config.go": "ee4bec0d50f50d2fab8b87f2b1bff23392a8913a090396c0995f65195f49966f",
"platform/internal/model/model.go": "f5eb9f9f68e8ea655fa7061ee9dc994a88eeb73df0fa3256cf627479baf50989",
"platform/internal/server/admin.go": "1771c904df180e846794a81f447a9889bd03301e9e8d783e798ad137b4fee2b3",
"platform/internal/server/administrators.go": "400989e699329c4643bb37fdbdb5a89acaba4959fc7d1587524bc84c73b12fc4",
"platform/internal/server/administrators_test.go": "750b4f915d290d181503d836c6585aca48a8f011f4600b9ab50fbde4dfa1d97b",
"platform/internal/server/audits.go": "6706dd7fdbfaae6bd9dab9e43549eb498f9921862a10ece84c0c3a67c265f43f",
"platform/internal/server/audits_test.go": "5ce668bb6e4d4d49d85e637168ddb76d85ac02ef7b4d5b039d7a96796686b119",
"platform/internal/server/catalog.go": "5970c170a02204f92b46db4e2657aa184dc6b7489061c40e4a5b16afb86ad890",
"platform/internal/server/catalog_test.go": "5fbdf7c1bad6ffe445d027270a04072ae273c3b3b64206a5dc61f58953e1cadb",
"platform/internal/server/compute_connection.go": "87ee2de5cad7b2222d185048442f173dbfb349fa721ada90c095f38e259b2975",
"platform/internal/server/configuration_test.go": "7d66f7f672f7b6978235d3178a219943fd05f5a9a79c939f0470342a894c5729",
"platform/internal/server/gpu.go": "f1a46be6bd8995fb1388a5bd57e1fc6f91494bc662368faa900183ed5e65e212",
"platform/internal/server/gpu_test.go": "3f06e25b64ca6edebb382493751b52d846b36d2e31c449116fac7a565bda467b",
"platform/internal/server/hardware.go": "63675368f7bdde9173d8cdcb82a2633b16c8757c05d77860e936dd40d29b9040",
"platform/internal/server/imports.go": "413854f1565f01c91a3bdb58ce5a9517925dd7d841b0d08a5302f68094283db7",
"platform/internal/server/lifecycle.go": "32ba57fbb74655f2eb10c4896adc09e8e6115d75ec1e7a54fa4e119bde157cde",
"platform/internal/server/lifecycle_test.go": "c2a905d7415ac9da0522f6793a540dcfb4f743ccf008b98e6d7d5baf0fac4a58",
"platform/internal/server/login_limits.go": "4c18db415396140522a6a7d7bd99f5475e527bab9ac62291af6b5cfb4ebb7f96",
"platform/internal/server/maintenance.go": "1e3fd3e712a2e40175b30e9aa4f1c4a884534ecdf22c22be8d860fd7bee6851c",
"platform/internal/server/observability.go": "90b3667ab5a12304714a6e0dba52ee910ec8f536c4b28a8106f0468e0e0d28eb",
"platform/internal/server/operations_test.go": "9dd8eb57118f55cc19949edb2725c18b2912a42eab7eb011d0f68237a9224077",
"platform/internal/server/policies.go": "f1b2fbe06c7467a14b923b5deda4ca5405431a1c246d20e589a1f8d70396da9b",
"platform/internal/server/policies_test.go": "146f79f9271e222c5bda54fe171a838fafe3cf18c9bccd9dc52bfc213b98f8ec",
"platform/internal/server/power_batches.go": "69220167981a712e415a1ff5d2e5dbd857f186cf4063bfdd1be6f7baef7c8ffe",
"platform/internal/server/power_batches_test.go": "f3be4c6398885ff5579b9dc45dddb6c9a4a8b219be2fdafa92254419917d2e6b",
"platform/internal/server/power_batch_worker.go": "ecab67a9aff19efac3e674881d947c80395a6e88776dd923a2ec9d0dfdd0b454",
"platform/internal/server/provision_worker.go": "9d3d6419f47a32d15bae2c8fac3a02fbf8e5858488a05015beab58f73d7d0010",
"platform/internal/server/server.go": "990095f61c16169312a74fc3a7ac70df9ac2762be29bdc17477579bd2f442ff9",
"platform/internal/server/server_test.go": "25d6323570f7a454ca5ca9cb1683e28be66f75de4521b0908153bcef138fec1d",
"platform/internal/server/session_history.go": "4098d126d1a7e841a1c4b5b1be4fe101dd884b08a71c78b8ca98e73309008c78",
"platform/internal/server/snapshots.go": "97dd570e5b7e0352dd6c0037dca529f4ff764a6718e36d6c9e2b682ff75aceb6",
"platform/internal/server/snapshots_test.go": "90523abace16f3cca1a0b538b6896072294a05163b8a54ee760fba360eddb0de",
"platform/internal/server/terminal.go": "0143bf616243a9d0292aaec1ea134ced43f0ed4900765894d61e592f23f83dff",
"platform/internal/server/terminal_alerts.go": "eb44d842b9f7430ac71f7d361f0dcf27f3d4490b7e51685629323f9ee2dfc2fb",
"platform/internal/server/terminal_alerts_test.go": "cec76be6fea3bc37c3ad01c72c476e059140696af3fa4f735bc5b23329699c50",
"platform/internal/server/terminal_metrics.go": "8c1c02d8085772b078cd91243a75099ddb592d8b655db8d96af8059b2075d03b",
"platform/internal/server/terminal_metrics_test.go": "1d0ac4d73694104c9d9f3da9274b6e8c6e1f7f29609d51f1daa5d703e1eafbd0",
"platform/internal/store/schema.sql": "a8ccab01ae90d9070cb793ab02be7509ce174a1660581cb25d7aa2c9e876d349",
"platform/internal/store/store.go": "333bc9798f1a671c8376ae55ce80ca83a7305fc12116ef3ddc248d824742658f",
"platform/cmd/api-gateway/main.go": "271660f003afff20b968938ba084b11f314126d90409def2d213f9587fdfb827",
"console/e2e/administrators.spec.ts": "225b8e96781d2abd648b798b70d9443c80e2831473ca37a9b00066e4bf5cdd9b",
"console/e2e/audits.spec.ts": "2fcc63e8534416e5f2a918cf7a5d4d1da78dae552ee631bb81de691587737485",
"console/e2e/batch-operations.spec.ts": "76f6a04870aa335bdcd5e44cc65ba43ab961f776cb0e28daaeffcf3831415669",
"console/e2e/catalog.spec.ts": "af2fa3783ac2b6de920af227c925650d2416c66194ff0de98e5c0fd1789224a4",
"console/e2e/configuration.spec.ts": "0cd3e4fa5a3020fa19d251cdc1bd1a953f94e9b4a7ab406ed790532196d0d2f9",
"console/e2e/console.spec.ts": "705de4025cab9525bafee455ed27cecb96550c887464f69a179795240d21dbc3",
"console/e2e/gpus.spec.ts": "d7953d1d14fe22bb550ad754a4412cf172186fd770d7083438f55e73d953c91c",
"console/e2e/lifecycle.spec.ts": "6374ab61a67fd0b51a132a1e4079f3e2dd351ce747dba8a6180f38fb611638ed",
"console/e2e/live.spec.ts": "3af8b236e0ca7ebb3fb3a6a434372c71f53d4577f4b7cf30332e684b39d5a961",
"console/e2e/operations.spec.ts": "eea905df578b1ea7f26a13c112748ed80816606487db450c8627a26a36ca73aa",
"console/e2e/policies.spec.ts": "156b74f615e0817f368cb12607cbc56821956300eb6aac94ca404b8518bd0abc",
"console/e2e/snapshots.spec.ts": "d16dae0fb17634105f4aeca103bb19af20c1bb5fb19f4719f625edf93663ac97",
"console/e2e/terminal-alerts.spec.ts": "e5419fcff8c97917d7f013fe366a62b24379b3d1822bd16a99742af66982809e",
"console/e2e/terminal-metrics.spec.ts": "02a24fa7aa0fadbdc712027d7ee06505d7a0f4ff570710b1da3cef012d0667cb",
"console/e2e/ui.spec.ts": "dcc8ed295dce67f54036b6ae4843cc5cb28b9d3c00fd40e2daa377d971b7ecee",
"console/public/mockServiceWorker.js": "26171599d94d5445297303f3a1f1cedec26835ea3d0057dcfa92ec520cb35cca",
"console/src/App.vue": "51aa88af1efacc8189f3cb8e5d1491af4f40997466c8036a8933246dd443380d",
"console/src/main.ts": "1954e1e7c6e278b800b1d687547039a888392402934311c0c0f3a83fccba41e5",
"console/src/api/audit-labels.ts": "3a01ba5a9f105bbdba132a360ecc6ff6bd6e533dd87901fd6f671dcd962d6a75",
"console/src/api/index.ts": "e9bfa7618c47d28e03a2baaa580db974a2294f276c1add55df8f10107a894610",
"console/src/api/schema.d.ts": "5e745e0505eaaebbed4b3fb088b0bc52d43d96316c437ffe08f359c6b975b549",
"console/src/assets/brand-mark.svg": "30ccb90d95e4fa305fad1a6045251f91d9a75efe97908de562c104d547792956",
"console/src/assets/kylinsoft.png": "98397497b49338607a7a4e1c1db562ce6fd1cdc1a6f35cb1f5f3a0b524876086",
"console/src/assets/ubuntu.svg": "b0aca674c881a07bb60b26afbcee07093ae419e29ee38e376f938817fa66ae03",
"console/src/components/AdministratorAccounts.vue": "fa42b4a3877175385efdc7dad32c6500f93f4c50c70bc30d3f9999386349dfa1",
"console/src/components/AuditHistory.vue": "36d4ceb12d5616e10f6672d28178eeb3397d1cc3a3f5515b0415f59284ce1273",
"console/src/components/ChangePasswordDialog.vue": "af9708676717dbeee3284a0ed8d7a57d4c5b43ffde17889ef8509078f528b2d7",
"console/src/components/CloneDesktopDialog.vue": "a098994d5285cbb9e9a718eb8182a7d4e1065e5c8029ed856d2923d5cef1a0af",
"console/src/components/CsvImportDialog.vue": "91f2ced2ee35c5808122421acb4c1c0588a6ac37fa2777fd44e4830c4f98fc86",
"console/src/components/DesktopBatchDialog.vue": "aaf9729b1d3c71b040689ce38486d084a37e64a64c672ca7d992cdb03900721b",
"console/src/components/DesktopHardwareDialog.vue": "ce7a1689c8bc6b0959a3322969c7669a9af1e0a18a7b0af00c5bfc0bd6dd237d",
"console/src/components/DesktopSnapshotsDialog.vue": "c005c73e6861ad6a68c3e91d8dc608cadc5d8459b8859c21c437264e572d48b6",
"console/src/components/PveConnectionDialog.vue": "0a1df39a83dc88e4bb1816f23765aa40dc11b36be9d005c5ed9b1fdc15f4df7c",
"console/src/components/ResourceTrend.vue": "6b9150b93658fc2508d25e434757a3e96d8e7ac21920c77b61e65bb077d30fd7",
"console/src/components/SessionHistory.vue": "d67ae011dd6236e15646760952b8d53d9402ae4cb806217d79ecdb6fb644311d",
"console/src/components/StatusTag.vue": "c1119aabc24c740ed45d7e8c6c1450f6ab9ceed67a4076667e1e8d1fbfe7712b",
"console/src/components/SystemIcon.vue": "c2c1c81950305cafa40c8618ef2b8a0d00b629ad43f4113adc86e0ce486510e6",
"console/src/components/TerminalMetrics.vue": "3cffab8fc3027579892fee34604e84f58691086a5d622c4043ad9004989a10da",
"console/src/components/TerminalMetricTrend.vue": "643ed690201b3a2596fce822cc42dc2669c930af8ad6e5bab7a078ee199040a4",
"console/src/components/ThemeToggle.vue": "20321bee998e83377e969ff3cb2004efc629b3fa6b72080282bfc9e98fc8bb25",
"console/src/composables/useAction.ts": "2567143620249766e9940f1e63e263cca180332b0fcdd15fdd31ca9fa09815b8",
"console/src/composables/useTheme.ts": "2de0fde65c75a8cf9ade9b2d93d162fa245e027507ea36d0046e5161292dd61e",
"console/src/layouts/ConsoleLayout.vue": "4622c695cfac6298cd7aedc3d3f8b32a5c5deed58b48256e0572be57873a8db3",
"console/src/mocks/browser.ts": "1b751209d7fbfcf39bb47376cd893ca42c71281e4ca39188aece1d768464d543",
"console/src/mocks/handlers.ts": "46ebab4dbad545cd5799facb2647e10e3560e4054dbab11e2788d5cecc77aa18",
"console/src/router/index.ts": "b9f806c59aa317c204c404a5a5f9385746daf4dbaef6991aa84ff9f01fc898d4",
"console/src/stores/auth.test.ts": "26adb951ebfa12196fdebd586083f88b18c67fc648225d64233d49eac7f98633",
"console/src/stores/auth.ts": "32d5ad0394ec136b167447b8a3d8be8cda7ba574e78f8f7b7f21d0f069ff0675",
"console/src/stores/platform.ts": "cde4cd50b1c7a39a525651a601d07f8cd09740bc7dc0bbfe118e899fa41a90c2",
"console/src/styles/tokens.css": "64c09a6c5b54c3fc8121a80cd29d79542a3926ee638235755f41f7599f3c8bbc",
"console/src/views/Desktops.vue": "291b4acde5d476812bfac78c19f773c5707e6d3601a4c47ab68d6722f64c5c7a",
"console/src/views/Forbidden.vue": "6391073f7b441e547f6918d27c8e628341e7be04b850644b9e0089280455b6be",
"console/src/views/GPUs.vue": "e08261a1f7aa5ccf9422acff112f239c44f477b255847c03ca884ddcf462c716",
"console/src/views/Login.vue": "e58aaa254cc0d9b9d578d8a7348aabb454ffb6646538b7f4073eafec500c06c0",
"console/src/views/Monitor.vue": "f3cb6760f85c88a4faccac4ae35540ac2ad2be669cf50d4229223ffabc4dd63f",
"console/src/views/NotFound.vue": "924455b640a9adb9f3c921b8bc5d4004f4d5915cecc503f0e0829db3d4497224",
"console/src/views/Overview.vue": "bff2ea6496b70a44265b182cdfee95a0ceed3245613fe875685f2131673a0183",
"console/src/views/Pools.vue": "04f0d8dc390c34a94da4dda5c25ce2b80e15966a96f8dec0b03de75327e07908",
"console/src/views/Resources.vue": "e98f825f0c7f7f411cd97a0455faa4ca2dd05d98b793256f6eb71d3d7f7b837e",
"console/src/views/Sessions.vue": "367555f4bb152907cac7b7f71231c81a95e887a1b6fb60cdf842c74a6282edef",
"console/src/views/Settings.vue": "8841e5d9a667403bbb5a956cb1bd7b77ccaea388b879367e7432133efa96fa00",
"console/src/views/Tasks.vue": "2ebae81b54544fe3f43edf6eefbf6a8b7099f508f576f1246dcf0cc22be86d51",
"console/src/views/Templates.vue": "db39841af85c3a73d288c374065d38f0c3f1b1a6e2f3d46c927c53076ae29e59",
"console/src/views/TerminalPolicies.vue": "f0b3b77b73749f02e265b0cbab4e0e4d28be1b18f139dc77e2950bb934a1acfb",
"console/src/views/Terminals.vue": "8bcf6aad71ce73c3b83d9ee913ea6bd287c528676a8b00c1e6904d05d6296c4a",
"console/src/views/Users.vue": "a2a9fda424660ce7e5865dc3bcbd902b5b86e7da07edc2b4ace22dd30a7959c6"
}

View File

@ -0,0 +1,28 @@
{
"time": "2026-11-11T02:22:38.197918+00:00",
"goFullSuite": "passed on isolated database; both workspace and GPU-excluded release",
"goVet": "passed for release",
"frontendLint": "passed",
"frontendBuild": "passed",
"frontendUnitTests": 3,
"safeFixtureE2E": 65,
"newAuditE2E": 4,
"goAuditTests": 3,
"dedicatedTestDatabase": "vdi_audit_test on temporary container vdi-audit-test-db",
"liveDatabaseNeverTruncated": true,
"limitsTested": {
"queryBeyond1000": true,
"export10000": true,
"rejectExport10001": true,
"csvFormulaInjection": true,
"literalWildcards": true,
"anonymous401": true,
"threeRoles": true
},
"expectedBuildWarnings": [
"Existing Vite chunk size warning"
],
"deployed": true,
"realAPIAcceptance": "passed",
"realBrowserAcceptance": "passed"
}

View File

@ -0,0 +1,19 @@
# 日志与诊断验收证据
日期:2026-11-11。真实服务:本机 5182。
| 文件 | 范围 |
| --- | --- |
| `live-verification.json` | 采集前真实设备/桌面查询、全分页与导出一致、三角色和未登录权限 |
| `browser-verification.json` | 真实两版入口、查询/详情/筛选/下载、角色守卫及深浅窄屏 |
| `state-verification.json` | 第一阶段只读验收及临时账号清理后,配置一致、任务仍为 63 |
| `collection-verification.json` | 随后通过真实页面提交一次日志采集,设备成功回执、展示及下载 |
| `collection-state-verification.json` | 采集后唯一新增任务与原配置一致性、ZIP 含实际日志 |
| `deployment.json` / `manifest.json` | 备份、镜像、基线二进制校验及隔离发布文件 |
| `live-*.png` | 真实页面,日志原文收起;较早的总览图中日志数为采集前的 0 |
各核验 JSON 的时间戳标明先后阶段:只读阶段没有提交业务命令,随后采集阶段新增一条 `collect-logs`,不是相互矛盾的任务计数。原始诊断 ZIP 仅保存在被忽略的 `.local/diagnostics-review/`,未将设备原始日志纳入公共证据。
隔离测试结果:后端 6 项专项、Go 全量/vet;前端检查、6 项单元测试、生产构建;诊断 9 项/版本 12 项/GPU 4 项共 25 项浏览器测试通过。浏览器原始结果位于 `.local/diagnostics-review/e2e-final/`,视觉复验位于 `e2e-visual/`;用例源代码见 `console/e2e/diagnostics.spec.ts`,后端见 `platform/internal/server/diagnostics_test.go`。
测试数据库使用 `127.0.0.1:55441` 一次性容器;不使用真实业务库。现场验收没有发电源、GPU、重启或重置会话命令。功能范围与未实现项见[实施说明](../../cloud-diagnostics-20261111.md)。

View File

@ -0,0 +1,26 @@
{
"time": "2026-11-11T09:52:22.239Z",
"realAPI": true,
"defaultCommonNavigationEntries": 5,
"advancedOperatorNavigationEntries": 14,
"terminalAndDesktopDeepLinks": true,
"actualPageTwoRead": true,
"detailsExpanded": true,
"actualZIPDownload": true,
"filterChangesInvalidateOldResults": true,
"viewerNavigationHiddenAndDeepLinkForbidden": true,
"themes": [
"dark",
"light"
],
"viewportWidths": [
1440,
390
],
"scriptErrors": [],
"failedAPIResponses": [],
"blockedBusinessWrites": [],
"terminalCommandsSubmitted": 0,
"vmPowerActionsSubmitted": 0,
"gpuSwitchesSubmitted": 0
}

View File

@ -0,0 +1,8 @@
{
"time": "2026-11-11T10:00:00.705662+00:00",
"temporaryDatabaseAndAnonymousVolumeRemoved": true,
"temporaryVite5183Stopped": true,
"temporaryAdministratorsAndSessionsRemoved": true,
"privateAccountCredentialFilesRemoved": true,
"sourceFilesMatchReleaseManifest": true
}

View File

@ -0,0 +1,19 @@
{
"time": "2026-11-11T09:57:30.567493+00:00",
"collectedZIPContainsDeviceReceipt": true,
"logsInArchive": 1,
"logTailCharacters": 11981,
"knownConfiguredSecretsAbsent": true,
"businessIdentityConfigurationAndBindingsStillUnchanged": true,
"alertRulesStillUnchanged": true,
"onlyAddedTaskIsSuccessfulLogCollection": true,
"newTasks": [
{
"id": "task_f4e4f72e2303be996e6c8bf75303c01c",
"kind": "terminal",
"action": "collect-logs",
"status": "success"
}
],
"rawZIPKeptPrivate": true
}

View File

@ -0,0 +1,30 @@
{
"time": "2026-11-11T09:56:38.177Z",
"realTerminal": true,
"submittedViaBrowser": true,
"confirmedBeforeSubmission": true,
"actualDeviceReceipt": "success",
"taskId": "task_f4e4f72e2303be996e6c8bf75303c01c",
"terminalAgentVersion": "qt-platform/0.4.0",
"logSourceVisible": true,
"deviceMetadataExpanded": true,
"actualCollectedZIPDownload": true,
"submittedCommands": [
"collect-logs"
],
"commandCount": 1,
"vmPowerActionsSubmitted": 0,
"gpuSwitchesSubmitted": 0,
"sessionResetCommandsSubmitted": 0,
"scriptErrors": [],
"failedAPIResponses": [],
"blockedBusinessWrites": [],
"beforeCounts": {
"tasks": 63,
"snapshots": 0
},
"afterCounts": {
"tasks": 64,
"snapshots": 0
}
}

View File

@ -0,0 +1,20 @@
{
"deployedAt": "2026-11-11T09:48:30.313383+00:00",
"backup": ".local\\live\\backups\\before-diagnostics-20261111-174813.dump",
"backupBytes": 285675,
"oldImageTag": "before-diagnostics-20261111-174813",
"oldImages": {
"api": "sha256:106f7df40c654b281650a0011e39c365e7b7e8c6a1fae56eea8b81a4e5d164ca",
"console": "sha256:2859db73b4eb6531549ee67e288d1d84ffd9e68d7b8beaefc9000d1ec1911069"
},
"newImages": {
"api": "sha256:00b12a4ba144776b53a33bcc39f8eddae9639f3cd711b38faf0c4327b93d40fb",
"console": "sha256:7e54468e502d50b945fc8d9abb787bf10efb1233bdbb092366f70ccfebe818ec"
},
"baselineBinarySHA256": "d195f248d7393f35321f1b0d4d92cda4a14abcf73ea92a6bb0223ed6ff37a034",
"databaseRestarted": false,
"databaseSchemaChanged": false,
"terminalFilesIncluded": false,
"sourceBaseline": ".local/editions-review/release",
"overlayManifest": ".local/diagnostics-review/manifest.json"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

@ -0,0 +1,74 @@
{
"time": "2026-11-11T09:49:33.002253+00:00",
"realDatabase": true,
"realAPI": true,
"targets": [
{
"kind": "terminal",
"total": 352,
"counts": {
"alerts": 5,
"audits": 55,
"logs": 0,
"sessions": 182,
"tasks": 110
},
"archiveBytes": 15237,
"allPagesEqualExport": true
},
{
"kind": "desktop",
"total": 182,
"counts": {
"alerts": 2,
"audits": 30,
"logs": 0,
"sessions": 90,
"tasks": 60
},
"archiveBytes": 8705,
"allPagesEqualExport": true
},
{
"kind": "desktop",
"total": 65,
"counts": {
"alerts": 2,
"audits": 10,
"logs": 0,
"sessions": 33,
"tasks": 20
},
"archiveBytes": 4495,
"allPagesEqualExport": true
},
{
"kind": "desktop",
"total": 106,
"counts": {
"alerts": 2,
"audits": 15,
"logs": 0,
"sessions": 59,
"tasks": 30
},
"archiveBytes": 5943,
"allPagesEqualExport": true
}
],
"roleChecks": {
"super_admin": "read/export allowed",
"operator": "read/export allowed",
"viewer": "both 403",
"anonymous": "both 401"
},
"databasePaginationAndSourceFilters": true,
"zipCompleteTwoFilesAndAuditRecorded": true,
"subjectAndDetailsWhitelisted": true,
"knownConfiguredSecretsAbsent": true,
"businessIdentityAndAuthorizationUnchangedBeforeTests": true,
"vmPowerActionsSubmitted": 0,
"gpuSwitchesSubmitted": 0,
"terminalCommandsSubmitted": 0,
"temporaryAccountsRemovedAfterBrowserCheck": true
}

View File

@ -0,0 +1,70 @@
[
{
"path": "tools/generate-platform-openapi.mjs",
"sha256": "91f1ae26bf9ee4c87f884e61b53569dcbe31d72e087566b8da4731ffdae47190"
},
{
"path": "platform/internal/server/admin.go",
"sha256": "7028dca5236de2238ada6c27fca34f66228e5588d215acee0253186b0c5767e2"
},
{
"path": "platform/internal/server/server.go",
"sha256": "9e4c9c487f154c018f31ed1f266703de017767d071ac9de93bad9ff1cef40d42"
},
{
"path": "docs/openapi/platform.v1.json",
"sha256": "4da7ace4959419d05a8cf60f821da64af5f60b31e53c81eb5a8f2c84dd2b7d12"
},
{
"path": "console/src/api/audit-labels.ts",
"sha256": "632f6895d6fb4f1b93e8bd2f0cce88ae0565a98cca0e860fdd192aa8f7fc9f4b"
},
{
"path": "console/src/api/index.ts",
"sha256": "59e8195ab5f00ef1cc2dc932527766f482063e11800ab49b010ea987ddd01b4a"
},
{
"path": "console/src/api/schema.d.ts",
"sha256": "c532951545d9259e738e198deb237c67a225e3201ce0124951829e1166be1f9e"
},
{
"path": "console/src/components/TaskHistory.vue",
"sha256": "fd3ed85e0c319816731609afad4b8d79aea9e08658ffb57b0b58de37402b7348"
},
{
"path": "console/src/layouts/ConsoleLayout.vue",
"sha256": "98dab479e76de2fd288cc70da642dd1f7916e061e09270084ef05e9859b2f14e"
},
{
"path": "console/src/router/index.ts",
"sha256": "25b0229cc0ebbf5d86fa61014c2b6f3afb510edd16384b4e0cb283e654f04ba2"
},
{
"path": "console/src/views/Desktops.vue",
"sha256": "dc20e3589a2bcb31d910c0c794758ddb172ba10a21d1579a69564ce18fca0c4e"
},
{
"path": "console/src/views/Settings.vue",
"sha256": "935456d69378c23ffb7ccbda06d231bec819ea6e4a7f330bade57682c9cb3019"
},
{
"path": "console/src/views/Terminals.vue",
"sha256": "596c2ae31d57a34d41e273dcfb4efa6d84b4c1564d761315656a63bac9bfe499"
},
{
"path": "platform/internal/server/diagnostics.go",
"sha256": "c87778e8901670d096fda36ecae629fbe75ce0f4e3456004a880d2cd66aaeae7"
},
{
"path": "platform/internal/server/diagnostics_test.go",
"sha256": "c477fc4b8efe5a003b75a6cfc7576f3ccc8162407750c43fefa96e104b2ba11a"
},
{
"path": "console/src/views/Diagnostics.vue",
"sha256": "272481cd97c5fe4378937a40f21eb06ff473bf882aea92db78dd345bcc36785a"
},
{
"path": "console/e2e/diagnostics.spec.ts",
"sha256": "1f603315ae0440f52976af34e8b5e183b165a57e98262855917fc54c42945f9c"
}
]

View File

@ -0,0 +1,19 @@
{
"time": "2026-11-11T09:54:41.570631+00:00",
"existingAdministratorPasswordsRolesAndStatesUnchanged": true,
"existingUsersBindingsDesktopConnectionAndDeviceIdentityUnchanged": true,
"alertRulesUnchanged": true,
"temporaryAdministratorsAndSessionsRemoved": true,
"databaseFsync": "on",
"beforeCounts": {
"tasks": 63,
"snapshots": 0
},
"afterCounts": {
"tasks": 63,
"snapshots": 0
},
"noBusinessTasksSubmitted": true,
"deployedImagesStillMatchRelease": true,
"databaseNotRestarted": true
}

View File

@ -0,0 +1,28 @@
{
"time": "2026-11-11T09:13:10.454Z",
"realAPI": true,
"defaultCommon": true,
"commonNavigationEntries": 5,
"advancedNavigationEntries": 13,
"perAccountModeRemembered": true,
"actualUICreateEmployee": true,
"actualUIPreviewHasNoWrite": true,
"actualUIAdditiveGrant": true,
"actualUISelectiveRevoke": true,
"authorizationAuditVerified": true,
"temporaryUserRemoved": true,
"themes": [
"dark",
"light"
],
"viewportWidths": [
1440,
390
],
"scriptErrors": [],
"failedAPIResponses": [],
"blockedBusinessWrites": [],
"vmPowerActionsSubmitted": 0,
"gpuSwitchesSubmitted": 0,
"terminalCommandsSubmitted": 0
}

View File

@ -0,0 +1,18 @@
{
"time": "2026-11-11T09:18:05.152Z",
"realAPI": true,
"summaryUsesActualRunningState": true,
"commonPagesReadOnlyVerified": 5,
"professionalTasksReadOnlyVerified": true,
"viewportWidths": [
1440,
390
],
"themes": [
"dark",
"light"
],
"scriptErrors": [],
"failedAPIResponses": [],
"businessWrites": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -0,0 +1,7 @@
{
"businessIdentityAndConfigurationUnchanged": true,
"alertRulesUnchanged": true,
"liveDatabaseFsync": "on",
"apiImageUnchanged": true,
"temporaryUsersRemaining": 0
}

View File

@ -0,0 +1,22 @@
{
"time": "2026-11-11T07:55:38.379Z",
"realAPI": true,
"temporaryViewerAccount": true,
"actualUIEnrollmentAndConfirmation": true,
"recoveryDownloaded": true,
"actualUILoginChallengeAndRecoveryLogin": true,
"actualUIDisable": true,
"themes": [
"dark",
"light"
],
"viewportWidths": [
1440,
390
],
"scriptErrors": [],
"failedResponses": [],
"blockedWrites": [],
"screenshots": 3,
"screenshotsContainNoActiveSecrets": true
}

View File

@ -0,0 +1,19 @@
{
"time": "2026-11-11T07:48:31.875276+00:00",
"backup": ".local/live/backups/before-mfa-20261111-154828.dump",
"backupBytes": 265458,
"oldImageTag": "before-mfa-20261111-154828",
"oldImages": {
"api": "sha256:4bc0609343b3c9382c859444579c0246bc12bb6ff787c2d0fb784ec7942db3b7",
"console": "sha256:52c38fa17705374202f557edd959271e0fae1c88d9f104a356b266dbd54b5e83"
},
"deployedAt": "2026-11-11T07:52:43.214467+00:00",
"baselineBinarySHA256": "ac04d8f5423b07aa2afa442013a4ddeaee55bbf0343a0876c50a8e829c6a4779",
"baselineMatchesLiveBinary": true,
"existingGPUConfigurationPreserved": true,
"activeJobsBeforeDeployment": 0,
"newImages": {
"api": "sha256:106f7df40c654b281650a0011e39c365e7b7e8c6a1fae56eea8b81a4e5d164ca",
"console": "sha256:458f32152b048eb53d9841119a9fc99854c7394a9e35b988add3eb5c8915aba4"
}
}

View File

@ -0,0 +1,10 @@
{
"time": "2026-11-11T08:00:18.903056+00:00",
"deployedImagesMatchTestedRelease": true,
"originalBusinessIdentityAndAuthorizationSnapshotUnchanged": true,
"temporaryAccountsRemaining": 0,
"MFARowsRemainingAfterTemporaryTests": 0,
"rootMFAEnabled": false,
"terminalOnline": true,
"livePostgreSQLFsync": "on"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

View File

@ -0,0 +1,78 @@
{
"time": "2026-11-11T07:55:02.694317+00:00",
"realDatabase": true,
"temporaryViewerFullMFALifecycle": true,
"passwordAloneRejected": true,
"authenticatorLoginPassed": true,
"TOTPReplayRejected": true,
"recoveryLoginPassed": true,
"recoveryReplayRejected": true,
"rotationInvalidatedOldCodesAndOtherSessions": true,
"disableRestoredPasswordLogin": true,
"auditsHideSecrets": true,
"temporaryAccountCleaned": true,
"existingAccountsDeviceIdentityBindingsUnchanged": true,
"alertRulesUnchanged": true,
"vmConfigUnchanged": true,
"vmPowerStatesUnchanged": false,
"beforeVMs": [
{
"id": "desk_29cf3c79d9fc1b4a25e1bfe55c5c943f",
"vmid": 102,
"digest": "2fbb5c2e41737a6a640b0733c1ac5d20551c771a",
"powerState": "stopped"
},
{
"id": "desk_38010c040399376117ea116029767c25",
"vmid": 100,
"digest": "e83a4e40c5c927dc016e5f6d76b9081a420baef4",
"powerState": "stopped"
},
{
"id": "desk_bbec6e55af36b91f042f66df80902007",
"vmid": 101,
"digest": "5a71f3c510bf41641380dc9697bd1190f8847c31",
"powerState": "running"
}
],
"afterVMs": [
{
"id": "desk_29cf3c79d9fc1b4a25e1bfe55c5c943f",
"vmid": 102,
"digest": "2fbb5c2e41737a6a640b0733c1ac5d20551c771a",
"powerState": "stopped"
},
{
"id": "desk_38010c040399376117ea116029767c25",
"vmid": 100,
"digest": "e83a4e40c5c927dc016e5f6d76b9081a420baef4",
"powerState": "running"
},
{
"id": "desk_bbec6e55af36b91f042f66df80902007",
"vmid": 101,
"digest": "5a71f3c510bf41641380dc9697bd1190f8847c31",
"powerState": "stopped"
}
],
"beforeTaskCounts": {
"tasks": 61,
"snapshots": 0
},
"afterTaskCounts": {
"tasks": 62,
"snapshots": 0
},
"onlyConcurrentDevicePreparationTasksAdded": true,
"concurrentDeviceTasks": [
{
"id": "task_56e8d83030b6231eb805636061fc887e",
"kind": "gpu-prepare",
"action": "prepare-desktop",
"actor": "device:term_8f55853c7c3b1454018c4b9e20f3fa49",
"status": "success",
"created_at": "2026-11-11T07:51:04.237141+00:00"
}
],
"existingAccountsMFADefaultUnchanged": true
}

View File

@ -0,0 +1,18 @@
{
"goFullSuite": "passed",
"goVet": "passed",
"mfaAndLoginLimitTargeted": "passed",
"superAdminRestartAndPasswordChange": "passed",
"frontendLint": "passed",
"frontendTypecheck": "passed",
"frontendUnitTests": 3,
"productionDockerBuilds": "passed",
"newMFABrowserTests": 6,
"existingAccountSessionAndUIBrowserTests": 12,
"liveAPILifecycle": "passed",
"realBrowserLifecycle": "passed",
"testDatabase": "isolated disposable PostgreSQL, fsync disabled during testing",
"liveVMWritesFromThisWork": 0,
"liveTerminalCommandsFromThisWork": 0,
"concurrentDeviceVMPreparationObserved": true
}

View File

@ -0,0 +1,18 @@
{
"time": "2026-11-11T07:02:20.102Z",
"realAPI": true,
"themes": [
"dark",
"light"
],
"viewportWidths": [
1440,
390
],
"downloaded": true,
"details": true,
"scriptErrors": [],
"failedResponses": [],
"blockedWrites": [],
"screenshots": 4
}

View File

@ -0,0 +1,19 @@
{
"time": "2026-11-11T06:55:25.820094+00:00",
"backup": ".local/live/backups/before-task-history-20261111-145521.dump",
"backupBytes": 253717,
"oldImageTag": "before-task-history-20261111-145521",
"oldImages": {
"api": "sha256:d91b71e4c202737a1866bfbd1e9dec646b4e385b243fa99888951165637f66c1",
"console": "sha256:b4d15d6fa16c3576ca2beba1132369917554dcc3978e8609547a46483cf0324d"
},
"deployedAt": "2026-11-11T07:01:41.250632+00:00",
"baselineBinarySHA256": "913056171c513393c24d15abfabcd7efd142a0d0bef3db6ae934f7ffa2cd8a8a",
"baselineMatchesLiveBinary": true,
"existingGPUConfigurationPreserved": true,
"activeJobsBeforeDeployment": 0,
"newImages": {
"api": "sha256:92138b1111fab09270b192ce465ba66fa98393158bace1a359a7553f92dda046",
"console": "sha256:828b045abf8c6eca17592a1bfbd2b62f8e50b723f4dbd6439d7f3ebfbccc3fd7"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

View File

@ -0,0 +1,24 @@
{
"time": "2026-11-11T07:02:14.355093+00:00",
"mode": "production",
"realDatabase": true,
"matchingRecords": 51,
"pageSize": 2,
"pages": 26,
"csvRows": 51,
"csvMatchesAllPages": true,
"combinedFiltersAndActiveQueryPassed": true,
"exportAudited": true,
"authenticationEnforced": true,
"invalidFiltersRejected": true,
"administratorsUsersBindingsDeviceIdentityUnchanged": true,
"alertRulesUnchanged": true,
"vmConfigAndPowerUnchanged": true,
"vmPowerStates": [
"stopped",
"running",
"stopped"
],
"businessTaskAndSnapshotCountsUnchanged": true,
"taskCount": 51
}

Some files were not shown because too many files have changed in this diff Show More