refactor: 代码解耦合,完善WebUI功能,添加网页VNC

This commit is contained in:
foxhui
2025-12-20 04:40:05 +08:00
Unverified
parent 41e3e022a3
commit c8c7aec0e1
74 changed files with 4984 additions and 644 deletions
+444
View File
@@ -0,0 +1,444 @@
<script setup>
import { h, ref, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { useSystemStore } from '@/stores/system';
import { useSettingsStore } from '@/stores/settings';
import {
PoweroffOutlined,
CheckCircleOutlined,
LoadingOutlined,
ClockCircleOutlined,
DeleteOutlined,
FolderOutlined,
StopOutlined,
LoginOutlined,
DownOutlined
} from '@ant-design/icons-vue';
const systemStore = useSystemStore();
const settingsStore = useSettingsStore();
// 重启步骤当前状态
const currentStep = ref(0);
const restarting = ref(false);
// 重启步骤定义
const restartSteps = ref([
{
title: '准备重启',
status: 'wait',
icon: h(ClockCircleOutlined),
},
{
title: '发送指令',
status: 'wait',
icon: h(PoweroffOutlined),
},
{
title: '等待重启',
status: 'wait',
icon: h(LoadingOutlined),
},
{
title: '重启完成',
status: 'wait',
icon: h(CheckCircleOutlined),
},
]);
// 实例文件夹抽屉
const instanceDrawerOpen = ref(false);
const selectedFolders = ref([]);
// 实例文件夹列表
const instanceFolders = ref([]);
// 重启弹窗状态
const restartModalVisible = ref(false);
// Workers 列表(用于登录模式选择)
const workers = ref([]);
// 确认重启弹窗
const restartConfirmVisible = ref(false);
const pendingRestartOptions = ref({});
// 获取 workers 列表
const fetchWorkers = async () => {
try {
const res = await fetch('/admin/config/instances', {
headers: settingsStore.getHeaders()
});
if (res.ok) {
const instances = await res.json();
// 从 instances 中提取所有 workers
const allWorkers = [];
for (const inst of instances) {
for (const w of (inst.workers || [])) {
allWorkers.push({ name: w.name, instance: inst.name });
}
}
workers.value = allWorkers;
}
} catch (e) {
console.error('获取 Workers 列表失败', e);
}
};
// 显示重启确认
const showRestartConfirm = (options = {}) => {
pendingRestartOptions.value = options;
restartConfirmVisible.value = true;
};
// 确认重启
const confirmRestart = () => {
restartConfirmVisible.value = false;
handleRestart(pendingRestartOptions.value);
};
onMounted(() => {
fetchWorkers();
});
// 辅助函数:延迟
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 执行重启
const handleRestart = async (options = {}) => {
restartModalVisible.value = true;
restarting.value = true;
currentStep.value = 0;
// 步骤1: 准备
restartSteps.value[0].status = 'process';
await sleep(500);
restartSteps.value[0].status = 'finish';
currentStep.value = 1;
// 步骤2: 发送指令 (调用API)
restartSteps.value[1].status = 'process';
try {
await systemStore.restartService(options);
restartSteps.value[1].status = 'finish';
currentStep.value = 2;
} catch (e) {
restartSteps.value[1].status = 'error';
message.error('无法连接到服务器');
return;
}
// 步骤3: 等待服务恢复 (轮询检查)
restartSteps.value[2].status = 'process';
// 先等待一小段时间让服务重启
await sleep(3000);
let retries = 20;
while (retries > 0) {
try {
await systemStore.fetchStatus();
if (systemStore.status) {
break;
}
} catch (e) {
// ignore
}
await sleep(2000);
retries--;
}
restartSteps.value[2].status = 'finish';
currentStep.value = 3;
// 步骤4: 完成
restartSteps.value[3].status = 'finish';
message.success('服务重启成功');
// 延迟关闭弹窗并重置状态
setTimeout(() => {
restartModalVisible.value = false;
restarting.value = false;
restartSteps.value.forEach(step => step.status = 'wait');
currentStep.value = 0;
}, 1500);
};
// 停止服务
const handleStop = async () => {
try {
const success = await systemStore.stopService();
if (success) {
message.success('服务已停止');
}
} catch (e) {
message.error('停止服务失败: ' + e.message);
}
};
// 清理缓存
const handleClearCache = async () => {
try {
const res = await fetch('/admin/cache/clear', {
method: 'POST',
headers: settingsStore.getHeaders()
});
if (res.ok) {
message.success('缓存文件夹已清理');
} else {
message.error('清理失败');
}
} catch (e) {
message.error('请求失败: ' + e.message);
}
};
// 打开实例文件夹管理抽屉
const handleOpenInstanceDrawer = async () => {
selectedFolders.value = [];
instanceDrawerOpen.value = true;
try {
const res = await fetch('/admin/data-folders', {
headers: settingsStore.getHeaders()
});
if (res.ok) {
instanceFolders.value = await res.json();
}
} catch (e) {
message.error('获取文件夹列表失败');
}
};
// 选中/取消选中文件夹
const handleFolderSelect = (name, checked) => {
if (checked) {
if (!selectedFolders.value.includes(name)) {
selectedFolders.value.push(name);
}
} else {
selectedFolders.value = selectedFolders.value.filter(n => n !== name);
}
};
// 删除选中的实例数据
const handleDeleteSelectedFolders = async () => {
if (selectedFolders.value.length === 0) {
message.warning('请先选择要删除的文件夹');
return;
}
try {
const res = await fetch('/admin/data-folders/delete', {
method: 'POST',
headers: settingsStore.getHeaders(),
body: JSON.stringify({ folders: selectedFolders.value })
});
if (res.ok) {
message.success(`已删除 ${selectedFolders.value.length} 个实例数据文件夹`);
// 刷新列表
await handleOpenInstanceDrawer();
} else {
message.error('删除失败');
}
} catch (e) {
message.error('删除请求失败');
}
};
</script>
<template>
<a-layout style="background: transparent;">
<!-- 项目管理板块 -->
<a-card title="项目管理" :bordered="false" style="width: 100%; margin-bottom: 10px;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center;">
<div style="margin-right: 16px;">
<div style="font-weight: 600; margin-bottom: 4px;">系统服务控制</div>
<div style="font-size: 12px; color: #8c8c8c;">
控制后端服务的运行状态 (重启或停止)
</div>
</div>
</div>
<div>
<a-space>
<!-- 下拉式重启按钮 -->
<a-dropdown-button type="primary" size="large" @click="showRestartConfirm()">
<PoweroffOutlined />
重启
<template #overlay>
<a-menu>
<a-menu-item key="normal" @click="showRestartConfirm()">
<PoweroffOutlined />
普通重启
</a-menu-item>
<a-menu-divider />
<a-menu-item key="login" @click="showRestartConfirm({ loginMode: true })">
<LoginOutlined />
登录模式重启
</a-menu-item>
<a-sub-menu v-if="workers.length > 1" key="login-worker" title="指定 Worker 登录">
<template #icon>
<LoginOutlined />
</template>
<a-menu-item v-for="worker in workers" :key="worker.name"
@click="showRestartConfirm({ loginMode: true, workerName: worker.name })">
{{ worker.name }}
</a-menu-item>
</a-sub-menu>
</a-menu>
</template>
</a-dropdown-button>
<a-popconfirm ok-text="确定" cancel-text="取消" @confirm="handleStop" placement="topRight">
<template #title>
<div style="width: 240px;">
<div style="font-weight: 500; margin-bottom: 4px;">确定要停止服务吗</div>
<div style="font-size: 12px; color: #f5222d;">停止后服务将完全终止需要手动重新启动</div>
</div>
</template>
<a-button type="primary" danger size="large">
<template #icon>
<StopOutlined />
</template>
停止
</a-button>
</a-popconfirm>
</a-space>
</div>
</div>
</a-card>
<!-- 重启确认模态框 -->
<a-modal v-model:open="restartConfirmVisible" title="确认重启" @ok="confirmRestart" ok-text="确定" cancel-text="取消"
:width="400">
<div style="padding: 12px 0;">
<p v-if="!pendingRestartOptions.loginMode">确定要重启服务吗</p>
<p v-else-if="pendingRestartOptions.workerName">
确定要以<b>登录模式</b>重启服务吗<br />
<span style="color: #1890ff;">仅初始化 Worker: {{ pendingRestartOptions.workerName }}</span>
</p>
<p v-else>确定要以<b>登录模式</b>重启服务吗</p>
</div>
</a-modal>
<!-- 重启进度模态框 -->
<a-modal v-model:open="restartModalVisible" title="系统服务重启中" :footer="null" :closable="false"
:maskClosable="false" width="500px">
<div style="padding: 24px 0;">
<a-steps :current="currentStep" :items="restartSteps" />
<div style="text-align: center; margin-top: 24px; color: #8c8c8c;">
请稍候系统正在执行重启操作...
</div>
</div>
</a-modal>
<!-- 缓存管理板块 -->
<a-card title="缓存管理" :bordered="false" style="width: 100%;">
<a-row :gutter="[16, 16]">
<!-- 清理缓存 -->
<a-col :xs="24" :md="12">
<a-card style="height: 100%;"
:body-style="{ display: 'flex', flexDirection: 'column', height: '100%' }">
<div style="flex: 1;">
<div style="display: flex; align-items: center; margin-bottom: 12px;">
<DeleteOutlined style="font-size: 24px; color: #1890ff; margin-right: 8px;" />
<div style="font-weight: 600; font-size: 16px;">清理缓存文件夹</div>
</div>
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 16px;">
清理项目运行过程中可能会遗留的临时缓存文件如遇到错误时遗留的图片<br>
不会影响用户数据和配置<strong style="color: #ff4d4f;">有任务运行时请勿执行</strong>
</div>
</div>
<a-popconfirm title="确定要清理缓存文件夹吗?" ok-text="确定" cancel-text="取消" @confirm="handleClearCache">
<a-button type="primary" block>
<template #icon>
<DeleteOutlined />
</template>
清理缓存
</a-button>
</a-popconfirm>
</a-card>
</a-col>
<!-- 删除实例数据 -->
<a-col :xs="24" :md="12">
<a-card style="height: 100%;"
:body-style="{ display: 'flex', flexDirection: 'column', height: '100%' }">
<div style="flex: 1;">
<div style="display: flex; align-items: center; margin-bottom: 12px;">
<FolderOutlined style="font-size: 24px; color: #ff4d4f; margin-right: 8px;" />
<div style="font-weight: 600; font-size: 16px;">删除实例数据文件夹</div>
</div>
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 16px;">
删除所有浏览器实例的用户数据文件夹<br>
包括 Cookie本地存储等<strong style="color: #ff4d4f;">请谨慎操作</strong>
</div>
</div>
<a-button danger block @click="handleOpenInstanceDrawer">
<template #icon>
<FolderOutlined />
</template>
管理实例数据
</a-button>
</a-card>
</a-col>
</a-row>
</a-card>
<!-- 实例数据文件夹管理抽屉 -->
<a-drawer v-model:open="instanceDrawerOpen" title="管理实例数据文件夹" placement="right" width="500">
<div style="margin-bottom: 16px;">
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 12px;">
选择要删除的实例数据文件夹删除后无法恢复请谨慎操作
</div>
<!-- 文件夹列表 -->
<a-list :data-source="instanceFolders" bordered>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>
<a-checkbox :checked="selectedFolders.includes(item.name)"
@change="e => handleFolderSelect(item.name, e.target.checked)">
{{ item.name }}
</a-checkbox>
</template>
<template #description>
<div style="font-size: 12px; margin-top: 4px;">
<div>路径: {{ item.path }}</div>
<div>关联实例: {{ item.instance }}</div>
<div>大小: {{ item.size }}</div>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</div>
<!-- 抽屉底部操作按钮 -->
<template #footer>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="font-size: 12px; color: #8c8c8c;">
已选择 {{ selectedFolders.length }} 个文件夹
</div>
<div>
<a-button style="margin-right: 8px;" @click="instanceDrawerOpen = false">
取消
</a-button>
<a-popconfirm placement="topRight" ok-text="确定删除" cancel-text="取消"
@confirm="handleDeleteSelectedFolders">
<template #title>
<div style="white-space: nowrap;">
确定要删除选中的 {{ selectedFolders.length }} 个文件夹吗
</div>
</template>
<a-button type="primary" danger :disabled="selectedFolders.length === 0">
删除选中项
</a-button>
</a-popconfirm>
</div>
</div>
</template>
</a-drawer>
</a-layout>
</template>
+220
View File
@@ -0,0 +1,220 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useSettingsStore } from '@/stores/settings';
import {
DesktopOutlined,
DisconnectOutlined,
ExpandOutlined,
CompressOutlined,
ReloadOutlined
} from '@ant-design/icons-vue';
const settingsStore = useSettingsStore();
// 状态
const loading = ref(true);
const vncStatus = ref(null);
const connectionState = ref('disconnected'); // disconnected, connecting, connected, error
const errorMessage = ref('');
const isFullscreen = ref(false);
// DOM 引用
const vncContainer = ref(null);
// noVNC 实例
let rfb = null;
let RFB = null;
// 获取 VNC 状态
async function fetchVncStatus() {
try {
const res = await fetch('/admin/vnc/status', {
headers: settingsStore.getHeaders()
});
if (res.ok) {
vncStatus.value = await res.json();
}
} catch (e) {
console.error('获取 VNC 状态失败', e);
} finally {
loading.value = false;
}
}
// 连接 VNC
async function connectVnc() {
if (!vncStatus.value?.enabled) return;
connectionState.value = 'connecting';
errorMessage.value = '';
try {
// 动态导入 noVNC
if (!RFB) {
const module = await import('@novnc/novnc/core/rfb.js');
RFB = module.default;
}
// 构建 WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/admin/vnc?token=${settingsStore.token}`;
// 创建 RFB 实例
rfb = new RFB(vncContainer.value, wsUrl, {
wsProtocols: ['binary']
});
// 配置
rfb.scaleViewport = true; // 缩放远程画面以适应容器
rfb.clipViewport = false; // 不裁剪视口
rfb.resizeSession = false; // 允许调整远程会话分辨率
// 事件监听
rfb.addEventListener('connect', () => {
connectionState.value = 'connected';
});
rfb.addEventListener('disconnect', (e) => {
connectionState.value = 'disconnected';
if (e.detail.clean === false) {
errorMessage.value = '连接意外断开';
}
rfb = null;
});
rfb.addEventListener('credentialsrequired', () => {
rfb.sendCredentials({ password: '' });
});
} catch (e) {
connectionState.value = 'error';
errorMessage.value = e.message || '连接失败';
}
}
// 断开连接
function disconnectVnc() {
if (rfb) {
rfb.disconnect();
rfb = null;
}
connectionState.value = 'disconnected';
}
// 切换全屏
function toggleFullscreen() {
if (!vncContainer.value) return;
if (!document.fullscreenElement) {
vncContainer.value.requestFullscreen();
isFullscreen.value = true;
} else {
document.exitFullscreen();
isFullscreen.value = false;
}
}
// 监听全屏变化
function handleFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement;
}
onMounted(async () => {
await fetchVncStatus();
document.addEventListener('fullscreenchange', handleFullscreenChange);
});
onUnmounted(() => {
disconnectVnc();
document.removeEventListener('fullscreenchange', handleFullscreenChange);
});
</script>
<template>
<a-layout style="background: transparent;">
<a-card title="虚拟显示器" :bordered="false" style="height: 100%">
<!-- 加载中 -->
<div v-if="loading" style="text-align: center; padding: 48px;">
<a-spin size="large" />
<div style="margin-top: 16px; color: #8c8c8c;">正在检查 VNC 状态...</div>
</div>
<!-- xvfbMode -->
<div v-else-if="!vncStatus?.xvfbMode" style="text-align: center; padding: 48px;">
<DisconnectOutlined style="font-size: 64px; color: #bfbfbf;" />
<div style="margin-top: 16px; font-size: 16px; color: #595959;">程序未使用虚拟显示器运行</div>
<div style="margin-top: 8px; color: #8c8c8c;">
VNC 远程显示功能仅在 Linux 环境下使用 <code>-xvfb -vnc</code> 参数启动时可用
</div>
</div>
<!-- xvfbMode VNC 未启用 -->
<div v-else-if="!vncStatus?.enabled" style="text-align: center; padding: 48px;">
<DesktopOutlined style="font-size: 64px; color: #bfbfbf;" />
<div style="margin-top: 16px; font-size: 16px; color: #595959;">VNC 服务未启动</div>
<div style="margin-top: 8px; color: #8c8c8c;">
请确保启动时包含 <code>-vnc</code> 参数并已安装 x11vnc
</div>
</div>
<!-- VNC 可用 -->
<div v-else>
<!-- 控制栏 -->
<div style="margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center;">
<div>
<a-tag v-if="connectionState === 'connected'" color="success">已连接</a-tag>
<a-tag v-else-if="connectionState === 'connecting'" color="processing">连接中...</a-tag>
<a-tag v-else-if="connectionState === 'error'" color="error">连接错误</a-tag>
<a-tag v-else color="default">未连接</a-tag>
<span v-if="errorMessage" style="margin-left: 8px; color: #ff4d4f; font-size: 12px;">
{{ errorMessage }}
</span>
</div>
<a-space>
<a-button v-if="connectionState !== 'connected'" type="primary" @click="connectVnc"
:loading="connectionState === 'connecting'">
<template #icon>
<DesktopOutlined />
</template>
连接
</a-button>
<a-button v-else danger @click="disconnectVnc">
<template #icon>
<DisconnectOutlined />
</template>
断开
</a-button>
<a-button @click="toggleFullscreen" :disabled="connectionState !== 'connected'">
<template #icon>
<CompressOutlined v-if="isFullscreen" />
<ExpandOutlined v-else />
</template>
</a-button>
<a-button @click="fetchVncStatus">
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-space>
</div>
<!-- VNC 显示区域 -->
<div ref="vncContainer"
style="width: 100%; aspect-ratio: 16/9; min-height: 400px; max-height: 70vh; background: #000; border-radius: 8px; overflow: hidden;">
<div v-if="connectionState === 'disconnected'"
style="height: 100%; display: flex; align-items: center; justify-content: center; color: #595959;">
<div style="text-align: center;">
<DesktopOutlined style="font-size: 48px; color: #434343;" />
<div style="margin-top: 16px;">点击"连接"按钮查看远程显示器</div>
</div>
</div>
</div>
<!-- 信息 -->
<div style="margin-top: 12px; font-size: 12px; color: #8c8c8c;">
显示器: {{ vncStatus.display }} | VNC 端口: {{ vncStatus.port }}
</div>
</div>
</a-card>
</a-layout>
</template>