vdi-cloud/ui/terminal/workspace.js

184 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
'use strict';
const root = document.getElementById('workspace');
const login = document.getElementById('login');
const paths = {
monitor: '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>',
settings: '<path d="m9 3 1-1h4l1 3 3 1 3-1 2 4-2 2v3l2 2-2 4-3-1-3 1-1 3h-4l-1-3-3-1-3 1-2-4 2-2v-3L1 9l2-4 3 1 3-1Z" transform="translate(1 0) scale(.92)"/><circle cx="12" cy="12" r="3"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.5 1.5m11 11L19 19M5 19l1.5-1.5m11-11L19 5"/>',
moon: '<path d="M20.5 13A8.5 8.5 0 0 1 11 3.5 8.5 8.5 0 1 0 20.5 13Z"/>',
user: '<circle cx="12" cy="8" r="3.5"/><path d="M5 21v-2a7 7 0 0 1 14 0v2"/>',
star: '<path d="m12 2.5 2.9 5.9 6.6 1-4.8 4.6 1.1 6.5-5.8-3.1-5.8 3.1 1.1-6.5-4.8-4.6 6.6-1Z"/>',
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
refresh: '<path d="M20 8a8 8 0 1 0 0 8M20 3v5h-5"/>',
arrow: '<path d="M4 12h16m-5-5 5 5-5 5"/>',
back: '<path d="M20 12H4m5-5-5 5 5 5"/>',
network: '<path d="M12 12v7M5 19v-4h14v4"/><rect x="8" y="3" width="8" height="6" rx="1"/><path d="M2 19h6v3H2Zm14 0h6v3h-6Z"/>',
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6m0-10v.2"/>',
logout: '<path d="M9 3H4v18h5m6-5 4-4-4-4M8 12h11"/>',
check: '<path d="m5 12 4 4L19 6"/>',
close: '<path d="m6 6 12 12M6 18 18 6"/>'
};
const icon = name => `<svg class="ws-icon" viewBox="0 0 24 24" aria-hidden="true">${paths[name] || paths.monitor}</svg>`;
const escape = value => String(value).replace(/[&<>"']/g, char => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[char]));
const defaults = [
{ id:'windows', os:'Windows 10', version:'64 位 · Windows', name:'设计工作站', resolution:'2560x1440', fps:60, endpoint:'192.168.1.50', app:'Desktop', note:'图形设计与日常办公', favorite:true },
{ id:'ubuntu', os:'Ubuntu', version:'24.04 LTS · Linux', name:'研发环境', resolution:'2560x1440', fps:30, endpoint:'192.168.1.51', app:'Desktop', note:'开发工具与研发工作环境', favorite:false },
{ id:'kylin', os:'银河麒麟', version:'桌面操作系统 · Linux', name:'办公桌面', resolution:'1920x1080', fps:30, endpoint:'', app:'Desktop', note:'暂未配置服务器,请联系管理员', favorite:false }
];
const read = () => { try { return JSON.parse(localStorage.getItem('yuexin-terminal-ui-v3') || 'null'); } catch { return null; } };
const saved = read();
let desktops = defaults.map(d => {
const local = Array.isArray(saved) && saved.find(v => v.id === d.id);
return local ? { ...d, endpoint:String(local.endpoint ?? d.endpoint), app:String(local.app || d.app), resolution:['1920x1080','2560x1440','3840x2160','1280x720'].includes(local.resolution) ? local.resolution : d.resolution, fps:[24,30,60].includes(local.fps) ? local.fps : d.fps, favorite:!!local.favorite } : { ...d };
});
const persist = () => { try { localStorage.setItem('yuexin-terminal-ui-v3', JSON.stringify(desktops)); return true; } catch { return false; } };
let tab = 'all', search = '', view = 'desktops', settingsTab = 'desktop', selectedId = 'windows', sessionState = 'connecting';
let timer, toastTimer;
let identity = {name:'终端用户',mode:'preview',service:'cloud',organization:'越欣科技'};
const selected = () => desktops.find(d => d.id === selectedId) || desktops[0];
const mark = d => {
const art = d.id === 'windows'
? '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M1 3.5 10.5 2.2v9.3H1Zm11-1.6L23 .4v11.1H12ZM1 13h9.5v9.3L1 21Zm11 0h11v11l-11-1.5Z"/></svg>'
: `<img src="assets/systems/${d.id === 'ubuntu' ? 'ubuntu.svg' : 'kylinsoft.png'}" alt="${d.id === 'ubuntu' ? 'Ubuntu' : '麒麟软件'} 标识">`;
return `<div class="ws-os-mark ${d.id}">${art}</div>`;
};
const prettyResolution = d => d.resolution.replace('x', ' × ');
const themeLabel = () => document.body.dataset.theme === 'dark' ? '切换浅色' : '切换深色';
const header = () => `<header class="ws-header"><div class="ws-brand"><strong>越欣科技</strong><span>云桌面终端</span></div><div class="ws-header-actions"><button class="ws-btn" data-action="settings" aria-label="终端设置">${icon('settings')}<span class="ws-settings-label">终端设置</span></button><button class="ws-btn ws-icon-btn" data-action="theme" aria-label="${themeLabel()}" title="${themeLabel()}">${icon(document.body.dataset.theme === 'dark' ? 'sun' : 'moon')}</button><div class="ws-account">${icon('user')}<span>${escape(identity.name)}</span></div></div></header>`;
const footer = () => `<footer class="ws-footer"><div class="ws-footer-left">${identity.mode === 'offline' ? '<span>离线体验</span>' : '<span class="ws-online">网络已连接</span>'}<span>设备 SN:VDI-8F42A1</span><span>RK3528 · ARM64</span></div><span class="ws-prototype">交互原型 · 设备与桌面状态为示例</span></footer>`;
function card(d) {
const available = !!d.endpoint;
return `<article class="ws-card" aria-label="${escape(d.os+' '+d.name)}"><div class="ws-card-top"><div class="ws-system">${mark(d)}<div><div class="ws-system-name">${escape(d.os)}</div><div class="ws-system-version">${escape(d.version)}</div></div></div><button class="ws-favorite" data-action="favorite" data-id="${d.id}" aria-pressed="${d.favorite}" aria-label="${d.favorite ? '取消收藏' : '收藏'}${escape(d.name)}" title="${d.favorite ? '取消收藏' : '收藏'}">${icon('star')}</button></div><h2>${escape(d.name)}</h2><span class="ws-status ${available ? '' : 'pending'}">${available ? '可连接' : '待配置'}</span><dl class="ws-specs"><div><dt>分辨率</dt><dd>${prettyResolution(d)}</dd></div><div><dt>帧率</dt><dd>${d.fps} FPS</dd></div></dl><p class="ws-card-note">${available ? escape(d.id === 'kylin' ? '国产办公工作环境' : d.note) : '暂未配置服务器,请联系管理员'}</p><div class="ws-card-actions"><button class="ws-btn" data-action="edit" data-id="${d.id}">${icon('settings')}设置</button><button class="ws-btn ws-primary" data-action="connect" data-id="${d.id}" ${available ? '' : 'disabled'}>${available ? '连接桌面' : '暂不可连接'}${available ? icon('arrow') : ''}</button></div></article>`;
}
function list() {
return `<div class="ws-heading"><div><h1>我的云桌面</h1><p>选择工作环境,连接后即可开始使用。</p></div><div class="ws-count">${desktops.filter(d => d.endpoint).length} 台可连接 / 共 ${desktops.length} 台</div></div><div class="ws-toolbar"><div class="ws-tabs" role="tablist" aria-label="桌面筛选"><button class="ws-tab" role="tab" aria-selected="${tab === 'all'}" data-action="tab" data-tab="all">全部桌面<span>${desktops.length}</span></button><button class="ws-tab" role="tab" aria-selected="${tab === 'favorites'}" data-action="tab" data-tab="favorites">已收藏<span>${desktops.filter(d => d.favorite).length}</span></button></div><div class="ws-action-group"><label class="ws-search">${icon('search')}<input id="ws-search" type="search" aria-label="搜索云桌面" placeholder="搜索桌面或系统" value="${escape(search)}"></label><button class="ws-btn ws-icon-btn" data-action="refresh" aria-label="刷新桌面列表" title="刷新桌面列表">${icon('refresh')}</button></div></div><div class="ws-grid" id="ws-grid"></div>`;
}
function renderGrid() {
const rows = desktops.filter(d => (tab !== 'favorites' || d.favorite) && (d.os + d.name).toLowerCase().includes(search.trim().toLowerCase()));
root.querySelector('#ws-grid').innerHTML = rows.length ? rows.map(card).join('') : `<div class="ws-empty">${icon('search')}<h2>${search ? '没有找到匹配的桌面' : '还没有收藏的桌面'}</h2><p>${search ? '试试系统名称,或清除搜索条件。' : '点击桌面卡片右上角的星标即可收藏。'}</p><button class="ws-btn" data-action="clear-search">查看全部桌面</button></div>`;
}
const option = (value, label, current) => `<option value="${escape(value)}" ${value === current ? 'selected' : ''}>${escape(label)}</option>`;
function desktopSettings() {
const d = selected();
return `<h2>桌面连接设置</h2><p>配置当前桌面的连接地址和显示参数。</p><form id="ws-settings-form" novalidate><div class="ws-form-grid"><label class="ws-field full">目标桌面<select class="ws-input" id="ws-target">${desktops.map(v => option(v.id, v.os+' · '+v.name, d.id)).join('')}</select></label><label class="ws-field">服务器地址<input class="ws-input" name="endpoint" value="${escape(d.endpoint)}" placeholder="IP 地址或域名" autocomplete="off"><small>留空表示待配置,桌面将不可连接。</small></label><label class="ws-field">应用名<input class="ws-input" name="app" value="${escape(d.app)}" placeholder="Desktop" autocomplete="off"><small>与服务端发布的应用名保持一致。</small></label><label class="ws-field">分辨率<select class="ws-input" name="resolution">${['1280x720','1920x1080','2560x1440','3840x2160'].map(v => option(v,v.replace('x',' × '),d.resolution)).join('')}</select></label><label class="ws-field">帧率<select class="ws-input" name="fps">${[24,30,60].map(v => option(String(v),v+' FPS',String(d.fps))).join('')}</select></label></div><p class="ws-error-text" id="ws-form-error" role="alert"></p><div class="ws-form-actions"><button class="ws-btn" type="button" data-action="home">取消</button><button class="ws-btn ws-primary" type="submit">保存设置</button></div></form>`;
}
function settings() {
let panel;
if (settingsTab === 'desktop') panel = desktopSettings();
if (settingsTab === 'network') panel = `<h2>网络状态</h2><p>连接远程桌面前,请确认终端已接入工作网络。</p><dl class="ws-info-list"><div><dt>连接方式</dt><dd>有线网络</dd></div><div><dt>网络状态</dt><dd><span class="ws-online">已连接</span></dd></div><div><dt>地址获取</dt><dd>自动获取(DHCP)</dd></div></dl><p class="ws-session-note" style="margin-top:24px">此处为设备状态示例。实际网络配置由终端系统管理。</p>`;
if (settingsTab === 'device') panel = `<h2>设备信息</h2><p>联系管理员时,可提供设备序列号协助定位。</p><dl class="ws-info-list"><div><dt>设备序列号</dt><dd>VDI-8F42A1</dd></div><div><dt>设备型号</dt><dd>RK3528 云终端</dd></div><div><dt>处理器架构</dt><dd>ARM64</dd></div><div><dt>管理快捷键</dt><dd>Ctrl + Alt + Shift + M</dd></div></dl><div class="ws-form-actions"><button class="ws-btn" data-action="logout">${icon('logout')}退出登录</button></div>`;
return `<div class="ws-view-title"><button class="ws-btn ws-icon-btn" data-action="home" aria-label="返回云桌面">${icon('back')}</button><h1>终端设置</h1></div><div class="ws-settings"><nav class="ws-settings-nav" aria-label="设置分类">${[['desktop','monitor','桌面连接'],['network','network','网络状态'],['device','info','设备信息']].map(([key,i,label]) => `<button data-action="settings-tab" data-tab="${key}" ${key === settingsTab ? 'aria-current="page"' : ''}>${icon(i)}${label}</button>`).join('')}</nav><section class="ws-settings-panel">${panel}</section></div>`;
}
function session() {
const d = selected(), failure = sessionState === 'error', recovery = sessionState === 'reconnect';
const title = failure ? '暂时无法连接' : recovery ? '正在恢复连接' : '正在连接桌面';
const steps = recovery ? ['检测网络连接','恢复桌面会话','恢复桌面画面'] : ['检查连接配置','建立远程会话','准备桌面画面'];
return `<div class="ws-session">${mark(d)}<h1>${title}</h1><p class="ws-session-sub">${escape(d.os)} · ${escape(d.name)}</p>${failure ? '<div style="height:26px"></div>' : '<div class="ws-session-progress"><span style="width:38%"></span></div>'}${failure ? `<div class="ws-session-note"><strong>尚未接入远程桌面服务</strong><br>当前为交互原型,未向服务器发送连接请求。实际终端需由桌面服务返回连接结果。</div>` : `<ol class="ws-steps">${steps.map((step,i) => `<li class="${i === 0 ? 'done' : i === 1 ? 'active' : ''}"><span class="ws-step-mark">${i === 0 ? '✓' : i+1}</span><span>${step}</span>${i === 1 ? '<span class="ws-spinner" aria-hidden="true"></span>' : ''}</li>`).join('')}</ol><div class="ws-session-note">${recovery ? '连接中断后将尝试恢复原会话,请保持设备联网。' : '请保持设备联网。连接完成后,桌面将全屏打开。'}<br>当前预览不发起真实远程连接。</div>`}<div class="ws-session-actions"><button class="ws-btn" data-action="home">${failure ? '返回桌面列表' : '取消连接'}</button>${failure ? `<button class="ws-btn ws-primary" data-action="edit" data-id="${d.id}">检查连接设置</button>` : ''}</div></div>`;
}
function render() {
root.innerHTML = header() + `<main class="ws-main" id="ws-content">${view === 'desktops' ? list() : view === 'settings' ? settings() : session()}</main>` + footer();
if (view === 'desktops') renderGrid();
}
function showWorkspace(next = 'desktops') {
clearTimeout(timer);
document.dispatchEvent(new CustomEvent('terminal:open-workspace'));
view = next;
login.classList.remove('active');
root.hidden = false;
document.body.classList.add('in-workspace');
render();
window.scrollTo(0,0);
}
function home() { showWorkspace(); }
function leave() {
clearTimeout(timer);
root.hidden = true;
document.body.classList.remove('in-workspace');
login.classList.add('active');
identity = {name:'终端用户',mode:'preview',service:'cloud',organization:'越欣科技'};
tab='all';search='';
document.dispatchEvent(new CustomEvent('terminal:logout'));
window.scrollTo(0,0);
}
function notify(message) {
clearTimeout(toastTimer);
root.querySelector('.ws-toast')?.remove();
const toast = document.createElement('div');
toast.className = 'ws-toast'; toast.setAttribute('role','status'); toast.textContent = message; root.append(toast);
toastTimer = setTimeout(() => toast.remove(), 3000);
}
function start(id, state = 'connecting') {
selectedId = id;
if (!selected().endpoint) return;
sessionState = state;
showWorkspace('session');
if (state !== 'error') timer = setTimeout(() => { if (view === 'session') { sessionState = 'error'; render(); } }, 12000);
}
function toggleTheme() {
const dark = document.body.dataset.theme === 'dark';
document.body.dataset.theme = dark ? 'light' : 'dark';
const oldTheme = document.getElementById('theme');
oldTheme.textContent = dark ? '◉ 浅色外观' : '▣ 深色外观';
if (!root.hidden) {
const b = root.querySelector('[data-action="theme"]');
b.innerHTML = icon(dark ? 'moon' : 'sun');b.setAttribute('aria-label',themeLabel());b.title = themeLabel();
}
}
// Authentication interactions own login entry; direct preview URLs remain available for design review.
document.addEventListener('terminal:login', event => {
identity = {...identity,...event.detail};tab='all';search='';home();
const title = root.querySelector('h1');
if (title) { title.tabIndex = -1; title.focus({preventScroll:true}); }
});
document.getElementById('theme').addEventListener('click', toggleTheme);
root.addEventListener('input', event => {
if (event.target.id === 'ws-search') { search = event.target.value; renderGrid(); }
});
root.addEventListener('change', event => {
if (event.target.id === 'ws-target') { selectedId = event.target.value; render(); }
});
root.addEventListener('click', event => {
const b = event.target.closest('[data-action]');
if (!b || b.disabled) return;
switch (b.dataset.action) {
case 'home': home(); break;
case 'logout': leave(); break;
case 'theme': toggleTheme(); break;
case 'settings': settingsTab = 'desktop'; showWorkspace('settings'); break;
case 'edit': selectedId = b.dataset.id; settingsTab = 'desktop'; showWorkspace('settings'); break;
case 'settings-tab': settingsTab = b.dataset.tab; render(); break;
case 'connect': start(b.dataset.id); break;
case 'favorite': { const d = desktops.find(v => v.id === b.dataset.id); d.favorite = !d.favorite; persist(); render(); break; }
case 'tab': tab = b.dataset.tab; render(); break;
case 'clear-search': search = ''; tab = 'all'; render(); break;
case 'refresh': render(); notify('示例桌面列表已刷新'); break;
}
});
root.addEventListener('submit', event => {
if (event.target.id !== 'ws-settings-form') return;
event.preventDefault();
const form = event.target, values = new FormData(form), endpoint = String(values.get('endpoint')).trim(), app = String(values.get('app')).trim();
const field = form.querySelector('[name="endpoint"]');
if (endpoint && !/^(?:[a-z\d](?:[a-z\d.-]*[a-z\d])?)(?::\d{1,5})?$/i.test(endpoint)) {
form.querySelector('#ws-form-error').textContent = '请输入 IP 地址或域名,可附加端口号。';field.setAttribute('aria-invalid','true');field.focus();return;
}
if (!app) { form.querySelector('#ws-form-error').textContent = '请填写应用名,例如 Desktop。';form.querySelector('[name="app"]').focus();return; }
const d = selected();
Object.assign(d,{endpoint,app,resolution:String(values.get('resolution')),fps:Number(values.get('fps'))});
const stored = persist();home();notify(stored ? '设置已保存到本机预览' : '设置已应用,本次会话内有效');
});
document.addEventListener('keydown', event => {
if (event.ctrlKey && event.altKey && event.shiftKey && event.key.toLowerCase() === 'm') { event.preventDefault();settingsTab='desktop';showWorkspace('settings'); }
if (event.key === 'Escape' && !root.hidden && view !== 'desktops') home();
});
const params = new URLSearchParams(location.search);
const preview = params.get('preview');
if (preview) {
if (['dark','light'].includes(params.get('theme'))) document.body.dataset.theme = params.get('theme');
if (['connecting','reconnect','error'].includes(preview)) start('windows',preview);
else if (preview === 'settings') showWorkspace('settings');
else showWorkspace();
}
})();