功能:支持按桌面配置连接参数与多种地址

变更详情:桌面连接可配置化——每卡设置、地址支持 IP/域名/VPN 隧道

- 新增 DesktopStore:~/.config/vdi-terminal/desktops.json 持久化连接参数,
  首次启动生成三张默认卡(待分配),老盒子自动从 client.conf 迁移 server 地址
- 新增 DesktopSettingsDialog:按桌面编辑协议/地址/应用名/分辨率/帧率,
  置顶显示避免被全屏 kiosk 遮挡
- 桌面列表每张卡增加“设置”按钮;管理菜单 Ctrl+Alt+Shift+M 增加入口
- DesktopService 改为从 store 读取;地址为空的桌面显示待分配且不可连接
- 修复:默认卡未按 endpoint 重算状态导致迁移后仍显示待分配
- 实测真机通过:Windows 自动迁移可连接、对话框显示与保存正常
This commit is contained in:
宋一鸣 2026-10-29 14:05:35 +08:00
parent ee6f449bf1
commit ec1566a80e
10 changed files with 460 additions and 44 deletions

View File

@ -24,7 +24,9 @@ add_executable(kiosk-shell
src/main_window.cpp
src/login_page.cpp
src/desktop_list_page.cpp
src/desktop_settings_dialog.cpp
src/desktop_service.cpp
src/desktop_store.cpp
src/moonlight_session.cpp
src/state_machine.cpp
src/config.cpp

View File

@ -132,6 +132,16 @@ void DesktopListPage::rebuildCards() {
state->setStyleSheet(QStringLiteral("color: %1; font-weight: bold;").arg(stateColor));
row->addWidget(state);
// “设置”:每张卡都可编辑连接参数(协议/地址/应用名/分辨率/帧率)
auto* editBtn = new QPushButton(QStringLiteral("设置"), card);
editBtn->setMinimumSize(88, 42);
connect(editBtn, &QPushButton::clicked, this, [this, id = d.id]() {
if (!busy_) {
emit editRequested(id);
}
});
row->addWidget(editBtn);
if (d.state == DesktopState::kAvailable) {
auto* connectBtn = new QPushButton(QStringLiteral("连接"), card);
connectBtn->setMinimumSize(110, 42);

View File

@ -36,6 +36,8 @@ public:
signals:
// 用户点击一张可用桌面卡片
void desktopSelected(const QString& desktopId);
// 用户点击某张卡片的“设置”按钮
void editRequested(const QString& desktopId);
// 用户点击退出登录
void logoutRequested();

View File

@ -1,5 +1,7 @@
#include "desktop_service.h"
#include "desktop_store.h"
namespace vdi {
void DesktopService::login(const QString& username, const QString& password, AuthCallback callback) {
@ -20,59 +22,34 @@ void DesktopService::login(const QString& username, const QString& password, Aut
}
void DesktopService::listDesktops(DesktopListCallback callback) {
// 本地演示数据:Windows 可用(Moonlight),Ubuntu/麒麟尚未分配连接信息。
// 桌面与连接参数来自本地连接配置(现场可配);
// 正式版本替换为 /api/v1/terminal/session/desktops(按用户绑定返回)。
std::vector<DesktopInfo> desktops;
DesktopInfo windows;
windows.id = QStringLiteral("desk-win10-01");
windows.name = QStringLiteral("Windows 10");
windows.os = QStringLiteral("Windows 10 22H2 专业版");
windows.protocol = Protocol::kMoonlight;
windows.state = DesktopState::kAvailable;
windows.endpoint = QStringLiteral("192.168.1.70");
windows.app = QStringLiteral("Desktop");
desktops.push_back(windows);
DesktopInfo ubuntu;
ubuntu.id = QStringLiteral("desk-ubuntu-01");
ubuntu.name = QStringLiteral("Ubuntu");
ubuntu.os = QStringLiteral("Ubuntu 桌面");
ubuntu.protocol = Protocol::kRdp;
ubuntu.state = DesktopState::kUnassigned;
desktops.push_back(ubuntu);
DesktopInfo kylin;
kylin.id = QStringLiteral("desk-kylin-01");
kylin.name = QStringLiteral("麒麟");
kylin.os = QStringLiteral("银河麒麟桌面");
kylin.protocol = Protocol::kRdp;
kylin.state = DesktopState::kUnassigned;
desktops.push_back(kylin);
std::vector<DesktopInfo> desktops = DesktopStore::load();
if (callback) {
callback(std::move(desktops), QString());
}
}
void DesktopService::createTicket(const QString& desktopId, TicketCallback callback) {
// 本地演示票据:直接由桌面列表数据构造。
// 本地演示票据:由连接配置构造。
// 正式版本替换为 /api/v1/terminal/session/connect-ticket(短期一次性票据)。
ConnectionTicket ticket;
ticket.desktopId = desktopId;
std::vector<DesktopInfo> desktops;
QString error;
listDesktops([&desktops, &error](std::vector<DesktopInfo> list, const QString& err) {
desktops = std::move(list);
error = err;
});
for (const DesktopInfo& d : desktops) {
if (d.id == desktopId) {
listDesktops([&ticket, &error, desktopId](std::vector<DesktopInfo> list, const QString& err) {
if (!err.isEmpty()) {
error = err;
return;
}
for (const DesktopInfo& d : list) {
if (d.id != desktopId) {
continue;
}
if (d.state != DesktopState::kAvailable) {
error = QStringLiteral("桌面 %1 尚未分配连接信息").arg(d.name);
break;
error = QStringLiteral("桌面 %1 尚未配置连接地址,请先在“设置”中完成配置")
.arg(d.name);
return;
}
ticket.protocol = d.protocol;
ticket.endpoint = d.endpoint;
@ -80,10 +57,12 @@ void DesktopService::createTicket(const QString& desktopId, TicketCallback callb
ticket.width = d.width;
ticket.height = d.height;
ticket.fps = d.fps;
error.clear();
break;
return;
}
}
if (error.isEmpty() && ticket.endpoint.isEmpty()) {
error = QStringLiteral("桌面不存在");
}
});
if (callback) {
callback(ticket, error);

View File

@ -0,0 +1,143 @@
#include "desktop_settings_dialog.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QVBoxLayout>
#include <cstdio>
#include "desktop_store.h"
namespace vdi {
DesktopSettingsDialog::DesktopSettingsDialog(std::vector<DesktopInfo> desktops,
const QString& selectId, QWidget* parent)
: QDialog(parent), desktops_(std::move(desktops)) {
setWindowTitle(QStringLiteral("桌面连接设置"));
setMinimumWidth(560);
// Kiosk 主窗口是全屏窗口,必须置顶否则对话框会被完全遮挡
setWindowFlags(windowFlags() | Qt::WindowStaysOnTopHint);
auto* root = new QVBoxLayout(this);
root->setSpacing(14);
auto* hint = new QLabel(
QStringLiteral("为每张桌面配置连接参数。地址支持 IP、域名或 VPN 隧道地址;"
"地址留空表示待分配,卡片将不可连接。"),
this);
hint->setWordWrap(true);
hint->setStyleSheet(QStringLiteral("color: #8a94a6;"));
root->addWidget(hint);
auto* form = new QFormLayout();
form->setHorizontalSpacing(14);
form->setVerticalSpacing(12);
comboDesktop_ = new QComboBox(this);
for (const DesktopInfo& d : desktops_) {
comboDesktop_->addItem(d.name, d.id);
}
form->addRow(QStringLiteral("桌面"), comboDesktop_);
comboProtocol_ = new QComboBox(this);
comboProtocol_->addItem(QStringLiteral("Moonlight"), QStringLiteral("moonlight"));
comboProtocol_->addItem(QStringLiteral("RDP"), QStringLiteral("rdp"));
form->addRow(QStringLiteral("协议"), comboProtocol_);
editEndpoint_ = new QLineEdit(this);
editEndpoint_->setPlaceholderText(QStringLiteral("例如 192.168.1.70 或 VPN 隧道地址"));
form->addRow(QStringLiteral("服务器地址"), editEndpoint_);
editApp_ = new QLineEdit(this);
editApp_->setPlaceholderText(QStringLiteral("Sunshine 应用名,通常为 Desktop"));
form->addRow(QStringLiteral("应用名(Moonlight)"), editApp_);
comboResolution_ = new QComboBox(this);
const char* resolutions[] = {"1920x1080", "2560x1440", "1920x1440", "3840x2160", "1280x720"};
for (const char* r : resolutions) {
comboResolution_->addItem(QString::fromLatin1(r), QString::fromLatin1(r));
}
form->addRow(QStringLiteral("分辨率"), comboResolution_);
comboFps_ = new QComboBox(this);
comboFps_->addItem(QStringLiteral("60"), 60);
comboFps_->addItem(QStringLiteral("30"), 30);
form->addRow(QStringLiteral("帧率"), comboFps_);
root->addLayout(form);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
buttons->button(QDialogButtonBox::Ok)->setText(QStringLiteral("保存"));
buttons->button(QDialogButtonBox::Cancel)->setText(QStringLiteral("取消"));
root->addWidget(buttons);
connect(comboDesktop_, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
&DesktopSettingsDialog::onDesktopChanged);
connect(buttons, &QDialogButtonBox::accepted, this, &DesktopSettingsDialog::onAccepted);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
// 预选指定桌面
int index = 0;
if (!selectId.isEmpty()) {
for (int i = 0; i < comboDesktop_->count(); ++i) {
if (comboDesktop_->itemData(i).toString() == selectId) {
index = i;
break;
}
}
}
comboDesktop_->setCurrentIndex(index);
loadCurrent();
}
void DesktopSettingsDialog::onDesktopChanged(int index) {
Q_UNUSED(index);
loadCurrent();
}
void DesktopSettingsDialog::loadCurrent() {
const QString id = comboDesktop_->currentData().toString();
for (const DesktopInfo& d : desktops_) {
if (d.id != id) {
continue;
}
loading_ = true;
comboProtocol_->setCurrentIndex(d.protocol == Protocol::kRdp ? 1 : 0);
editEndpoint_->setText(d.endpoint);
editApp_->setText(d.app);
int resIndex = comboResolution_->findData(
QStringLiteral("%1x%2").arg(d.width).arg(d.height));
comboResolution_->setCurrentIndex(resIndex >= 0 ? resIndex : 0);
int fpsIndex = comboFps_->findData(d.fps);
comboFps_->setCurrentIndex(fpsIndex >= 0 ? fpsIndex : 0);
loading_ = false;
break;
}
}
void DesktopSettingsDialog::onAccepted() {
const QString id = comboDesktop_->currentData().toString();
for (DesktopInfo& d : desktops_) {
if (d.id != id) {
continue;
}
d.protocol = comboProtocol_->currentData().toString() == QStringLiteral("rdp")
? Protocol::kRdp
: Protocol::kMoonlight;
d.endpoint = editEndpoint_->text().trimmed();
d.app = editApp_->text().trimmed();
const QString res = comboResolution_->currentData().toString();
sscanf(res.toLatin1().constData(), "%dx%d", &d.width, &d.height);
d.fps = comboFps_->currentData().toInt();
d.state = d.endpoint.isEmpty() ? DesktopState::kUnassigned : DesktopState::kAvailable;
DesktopStore::update(d);
break;
}
accept();
}
} // namespace vdi

View File

@ -0,0 +1,52 @@
// 桌面连接设置对话框
//
// 现场环境每套部署的桌面地址不同:选择一张桌面卡,
// 编辑协议(Moonlight/RDP)、地址(IP / 域名 / VPN 隧道地址)、
// 应用名、分辨率和帧率,保存后立即生效。
// 地址为空 = 待分配,卡片不可连接。
#ifndef KIOSK_SHELL_DESKTOP_SETTINGS_DIALOG_H
#define KIOSK_SHELL_DESKTOP_SETTINGS_DIALOG_H
#include <QDialog>
#include <vector>
#include "desktop_session.h"
class QComboBox;
class QLineEdit;
namespace vdi {
class DesktopSettingsDialog : public QDialog {
Q_OBJECT
public:
explicit DesktopSettingsDialog(std::vector<DesktopInfo> desktops,
const QString& selectId = QString(),
QWidget* parent = nullptr);
// 保存后的桌面列表(供调用方刷新)
std::vector<DesktopInfo> desktops() const { return desktops_; }
private slots:
void onDesktopChanged(int index);
void onAccepted();
private:
void loadCurrent();
std::vector<DesktopInfo> desktops_;
QComboBox* comboDesktop_ = nullptr;
QComboBox* comboProtocol_ = nullptr;
QLineEdit* editEndpoint_ = nullptr;
QLineEdit* editApp_ = nullptr;
QComboBox* comboResolution_ = nullptr;
QComboBox* comboFps_ = nullptr;
bool loading_ = false;
};
} // namespace vdi
#endif // KIOSK_SHELL_DESKTOP_SETTINGS_DIALOG_H

View File

@ -0,0 +1,160 @@
#include "desktop_store.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include "config.h"
namespace vdi {
namespace {
// 三张默认桌面卡:id/name/os 固定,连接参数由现场配置。
// endpoint 为空 = 待分配,配置地址后即变为可连接。
std::vector<DesktopInfo> defaultDesktops(const QString& migratedServer) {
std::vector<DesktopInfo> list;
DesktopInfo win;
win.id = QStringLiteral("desk-win10-01");
win.name = QStringLiteral("Windows 10");
win.os = QStringLiteral("Windows 桌面");
win.protocol = Protocol::kMoonlight;
win.endpoint = migratedServer; // 兼容老盒子:迁移 client.conf 的 server
win.app = QStringLiteral("Desktop");
win.width = 1920;
win.height = 1080;
win.fps = 60;
list.push_back(win);
DesktopInfo ubuntu;
ubuntu.id = QStringLiteral("desk-ubuntu-01");
ubuntu.name = QStringLiteral("Ubuntu");
ubuntu.os = QStringLiteral("Ubuntu 桌面");
ubuntu.protocol = Protocol::kRdp;
list.push_back(ubuntu);
DesktopInfo kylin;
kylin.id = QStringLiteral("desk-kylin-01");
kylin.name = QStringLiteral("麒麟");
kylin.os = QStringLiteral("银河麒麟桌面");
kylin.protocol = Protocol::kRdp;
list.push_back(kylin);
return list;
}
DesktopInfo fromJson(const QJsonObject& obj) {
DesktopInfo d;
d.id = obj.value(QStringLiteral("id")).toString();
d.name = obj.value(QStringLiteral("name")).toString();
d.os = obj.value(QStringLiteral("os")).toString();
d.protocol = obj.value(QStringLiteral("protocol")).toString() == QStringLiteral("rdp")
? Protocol::kRdp
: Protocol::kMoonlight;
d.endpoint = obj.value(QStringLiteral("endpoint")).toString();
d.app = obj.value(QStringLiteral("app")).toString();
d.width = obj.value(QStringLiteral("width")).toInt(1920);
d.height = obj.value(QStringLiteral("height")).toInt(1080);
d.fps = obj.value(QStringLiteral("fps")).toInt(60);
d.state = d.endpoint.isEmpty() ? DesktopState::kUnassigned : DesktopState::kAvailable;
return d;
}
QJsonObject toJson(const DesktopInfo& d) {
QJsonObject obj;
obj.insert(QStringLiteral("id"), d.id);
obj.insert(QStringLiteral("name"), d.name);
obj.insert(QStringLiteral("os"), d.os);
obj.insert(QStringLiteral("protocol"),
d.protocol == Protocol::kRdp ? QStringLiteral("rdp") : QStringLiteral("moonlight"));
obj.insert(QStringLiteral("endpoint"), d.endpoint);
obj.insert(QStringLiteral("app"), d.app);
obj.insert(QStringLiteral("width"), d.width);
obj.insert(QStringLiteral("height"), d.height);
obj.insert(QStringLiteral("fps"), d.fps);
return obj;
}
} // namespace
QString DesktopStore::storePath() {
return QString::fromStdString(configDir()) + QStringLiteral("/desktops.json");
}
std::vector<DesktopInfo> DesktopStore::load() {
std::vector<DesktopInfo> result;
QFile file(storePath());
if (file.exists() && file.open(QIODevice::ReadOnly)) {
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
const QJsonArray arr = doc.object().value(QStringLiteral("desktops")).toArray();
for (const QJsonValue& v : arr) {
DesktopInfo d = fromJson(v.toObject());
if (!d.id.isEmpty()) {
result.push_back(d);
}
}
}
// 确保三张默认卡始终存在(新环境骨架 / 防止手工编辑删卡)。
// 兼容老盒子:client.conf 里只要配过 server,就迁移为 Windows 桌面地址。
const TerminalConfig cfg = loadConfig();
const QString migrated = result.empty() && !cfg.server.empty()
? QString::fromStdString(cfg.server)
: QString();
const std::vector<DesktopInfo> defaults = defaultDesktops(migrated);
for (const DesktopInfo& def : defaults) {
bool found = false;
for (const DesktopInfo& d : result) {
if (d.id == def.id) {
found = true;
break;
}
}
if (!found) {
result.push_back(def);
}
}
// 统一按 endpoint 推导状态:有地址=可连接,无地址=待分配
for (DesktopInfo& d : result) {
d.state = d.endpoint.isEmpty() ? DesktopState::kUnassigned : DesktopState::kAvailable;
}
return result;
}
bool DesktopStore::save(const std::vector<DesktopInfo>& desktops) {
QJsonArray arr;
for (const DesktopInfo& d : desktops) {
arr.append(toJson(d));
}
QJsonObject root;
root.insert(QStringLiteral("version"), 1);
root.insert(QStringLiteral("desktops"), arr);
const QString path = storePath();
QDir().mkpath(QFileInfo(path).absolutePath());
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return false;
}
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
return true;
}
bool DesktopStore::update(const DesktopInfo& info) {
std::vector<DesktopInfo> list = load();
for (DesktopInfo& d : list) {
if (d.id == info.id) {
d = info;
d.state = d.endpoint.isEmpty() ? DesktopState::kUnassigned : DesktopState::kAvailable;
return save(list);
}
}
return false;
}
} // namespace vdi

View File

@ -0,0 +1,37 @@
// 桌面连接配置持久化(JSON)
//
// 每个客户部署环境的桌面地址不同,连接参数不能写死在代码里。
// 当前阶段由本地文件维护(现场通过"桌面连接设置"界面配置);
// 后续接入管理平台后,本类改为平台下发配置的本地缓存,接口不变。
//
// 文件:~/.config/vdi-terminal/desktops.json
// 兼容:文件不存在时生成默认骨架;老盒子从 client.conf 的 server 迁移 Windows 地址。
#ifndef KIOSK_SHELL_DESKTOP_STORE_H
#define KIOSK_SHELL_DESKTOP_STORE_H
#include <QString>
#include <vector>
#include "desktop_session.h"
namespace vdi {
class DesktopStore {
public:
static QString storePath();
// 读取桌面列表;确保三张默认卡(Windows/Ubuntu/麒麟)始终存在。
// state 由 endpoint 是否为空推导:有地址=可连接,无地址=待分配。
static std::vector<DesktopInfo> load();
// 保存完整桌面列表
static bool save(const std::vector<DesktopInfo>& desktops);
// 更新单张桌面(按 id 匹配),保存成功返回 true
static bool update(const DesktopInfo& info);
};
} // namespace vdi
#endif // KIOSK_SHELL_DESKTOP_STORE_H

View File

@ -24,6 +24,7 @@
#include <freerdp/scancode.h>
#include "desktop_list_page.h"
#include "desktop_settings_dialog.h"
#include "login_page.h"
namespace vdi {
@ -132,6 +133,7 @@ void MainWindow::setupUi() {
stack_->addWidget(list);
connect(list, &DesktopListPage::desktopSelected, this, &MainWindow::onDesktopSelected);
connect(list, &DesktopListPage::editRequested, this, &MainWindow::onEditDesktopRequested);
connect(list, &DesktopListPage::logoutRequested, this, &MainWindow::onLogoutRequested);
// 会话视图
@ -330,6 +332,28 @@ void MainWindow::onDesktopSelected(const QString& desktopId) {
});
}
void MainWindow::onEditDesktopRequested(const QString& desktopId) {
// 桌面连接设置:编辑协议/地址/应用名/分辨率/帧率,保存后立即刷新列表。
// 现场环境地址不同或走 VPN 隧道时,在这里改地址即可,无需改代码。
DesktopSettingsDialog dialog(desktops_, desktopId, this);
dialog.setStyleSheet(
QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }"
"QLineEdit { background: #1a2233; border: 1px solid #2b3654; "
"border-radius: 4px; padding: 8px; color: #e6e9ef; }"
"QComboBox { background: #1a2233; border: 1px solid #2b3654; "
"border-radius: 4px; padding: 8px; color: #e6e9ef; }"
"QPushButton { background: #232d44; border: 1px solid #2b3654; "
"border-radius: 6px; color: #e6e9ef; }"
"QLabel { color: #e6e9ef; }"));
if (dialog.exec() == QDialog::Accepted) {
desktops_ = dialog.desktops();
if (desktopListPage_) {
desktopListPage_->setDesktops(desktops_);
desktopListPage_->setStatus(QStringLiteral("连接配置已保存"));
}
}
}
void MainWindow::onLogoutRequested() {
stopSessions();
desktopService_.logout();
@ -546,12 +570,18 @@ void MainWindow::onMenuAction(int action) {
break;
default: {
QMenu menu(this);
QAction* settings = menu.addAction(QStringLiteral("桌面连接设置"));
menu.addSeparator();
QAction* reconnect = menu.addAction(QStringLiteral("重新连接当前桌面"));
QAction* disconnect = menu.addAction(QStringLiteral("断开并返回桌面列表"));
menu.addSeparator();
QAction* quit = menu.addAction(QStringLiteral("退出终端"));
QAction* chosen = menu.exec(QCursor::pos());
if (chosen == reconnect) {
if (chosen == settings) {
if (fsm_.current() == State::kDesktopList || fsm_.current() == State::kConfig) {
onEditDesktopRequested(QString());
}
} else if (chosen == reconnect) {
onMenuAction(1);
} else if (chosen == disconnect) {
onMenuAction(2);

View File

@ -52,6 +52,7 @@ protected:
private slots:
void onLoginRequested(const QString& username, const QString& password);
void onDesktopSelected(const QString& desktopId);
void onEditDesktopRequested(const QString& desktopId);
void onLogoutRequested();
void onQuitRequested();
void onFrameImage(const QImage& image);