mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
refactor: 代码解耦合,完善WebUI功能,添加网页VNC
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { LockOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success']);
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const token = ref(settingsStore.token);
|
||||
const loading = ref(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!token.value) {
|
||||
message.warning('请输入 Token');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const originalToken = settingsStore.token;
|
||||
settingsStore.setToken(token.value);
|
||||
|
||||
const success = await settingsStore.checkAuth();
|
||||
if (success) {
|
||||
message.success('验证成功');
|
||||
emit('success');
|
||||
emit('update:visible', false);
|
||||
} else {
|
||||
message.error('Token 验证失败,请检查是否正确');
|
||||
settingsStore.setToken(originalToken);
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('验证过程发生错误');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="visible" title="需要身份验证" :closable="false" :maskClosable="false" :footer="null" width="400px"
|
||||
centered>
|
||||
<div style="padding: 20px 0;">
|
||||
<div style="text-align: center; margin-bottom: 24px;">
|
||||
<a-avatar :size="64" style="background-color: #1890ff">
|
||||
<template #icon>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<div style="margin-top: 16px; font-size: 16px; font-weight: 500;">
|
||||
WebAI2API 管理面板
|
||||
</div>
|
||||
<div style="color: #8c8c8c; margin-top: 8px;">
|
||||
请输入访问 Token 以继续
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="Access Token">
|
||||
<a-input-password v-model:value="token" placeholder="请输入 Token" size="large"
|
||||
@pressEnter="handleLogin">
|
||||
<template #prefix>
|
||||
<LockOutlined style="color: rgba(0,0,0,.25)" />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-button type="primary" block size="large" :loading="loading" @click="handleLogin">
|
||||
验证并登录
|
||||
</a-button>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,237 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useSystemStore } from '@/stores/system';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import {
|
||||
DesktopOutlined,
|
||||
PieChartOutlined,
|
||||
ChromeOutlined,
|
||||
FieldTimeOutlined,
|
||||
LineChartOutlined,
|
||||
SyncOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
CheckCircleOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const systemStore = useSystemStore();
|
||||
const queueData = ref([]);
|
||||
const timer = ref(null);
|
||||
const queueStats = ref({ processing: 0, waiting: 0, total: 0 });
|
||||
|
||||
// 获取队列数据
|
||||
const fetchQueue = async () => {
|
||||
const settingsStore = useSettingsStore(); // 获取store
|
||||
try {
|
||||
const res = await fetch('/admin/queue', { headers: settingsStore.getHeaders() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
|
||||
// 更新统计信息
|
||||
queueStats.value = {
|
||||
processing: data.processing || 0,
|
||||
waiting: data.waiting || 0,
|
||||
total: data.total || 0
|
||||
};
|
||||
|
||||
const processing = (data.processingTasks || []).map(t => ({ ...t, status: 'processing' }));
|
||||
const waiting = (data.waitingTasks || []).map(t => ({ ...t, status: 'waiting' }));
|
||||
queueData.value = [...processing, ...waiting];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fetch queue failed', e);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
await Promise.all([
|
||||
systemStore.fetchStatus(),
|
||||
systemStore.fetchStats(),
|
||||
fetchQueue()
|
||||
]);
|
||||
};
|
||||
|
||||
const formatUptime = (seconds) => {
|
||||
const d = Math.floor(seconds / (3600 * 24));
|
||||
const h = Math.floor((seconds % (3600 * 24)) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}天 ${h}小时 ${m}分`;
|
||||
if (h > 0) return `${h}小时 ${m}分`;
|
||||
return `${m}分`;
|
||||
};
|
||||
|
||||
const formatMemory = (mb) => {
|
||||
if (!mb || mb === 0) return '0 MB';
|
||||
if (mb > 1024) {
|
||||
return parseFloat((mb / 1024).toFixed(2)) + ' GB';
|
||||
}
|
||||
return parseFloat(Number(mb).toFixed(2)) + ' MB';
|
||||
};
|
||||
|
||||
const getLoadColor = (usage) => {
|
||||
if (usage < 50) return '#52c41a'; // 绿色
|
||||
if (usage < 80) return '#faad14'; // 橙色
|
||||
return '#f5222d'; // 红色
|
||||
};
|
||||
|
||||
// 状态映射
|
||||
const getStatusConfig = (status) => {
|
||||
const map = {
|
||||
'normal': { color: 'green', text: '正常模式 (Normal)' },
|
||||
'headless': { color: 'blue', text: '无头模式 (Headless)' },
|
||||
'xvfb': { color: 'purple', text: '虚拟显示 (Xvfb)' }
|
||||
};
|
||||
return map[status] || { color: 'red', text: '未运行' };
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
refreshData();
|
||||
timer.value = setInterval(refreshData, 5000); // 每5秒轮询
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="width: 100%; background: transparent;">
|
||||
<!-- 响应式布局:手机竖向,电脑横向 -->
|
||||
<a-row :gutter="[16, 16]" style="margin-bottom: 24px">
|
||||
<!-- 系统信息卡片 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="系统状态" :bordered="false" style="height: 100%">
|
||||
<a-space direction="vertical" style="width: 100%" size="middle">
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span>
|
||||
<DesktopOutlined /> 系统版本:
|
||||
</span>
|
||||
<b>{{ systemStore.systemVersion }}</b>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span>
|
||||
<FieldTimeOutlined /> 运行时间:
|
||||
</span>
|
||||
<b>{{ formatUptime(systemStore.uptime) }}</b>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span>
|
||||
<ChromeOutlined /> 状态:
|
||||
</span>
|
||||
<a-tag :color="getStatusConfig(systemStore.status).color">
|
||||
{{ getStatusConfig(systemStore.status).text }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span>
|
||||
<LineChartOutlined /> CPU 使用率:
|
||||
</span>
|
||||
<span>{{ systemStore.cpuUsage }}%</span>
|
||||
</div>
|
||||
<a-progress :percent="systemStore.cpuUsage"
|
||||
:stroke-color="getLoadColor(systemStore.cpuUsage)" :show-info="false" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span>
|
||||
<PieChartOutlined /> 内存使用:
|
||||
</span>
|
||||
<span>{{ formatMemory(systemStore.memoryUsage.used) }} / {{
|
||||
formatMemory(systemStore.memoryUsage.total) }}</span>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="Math.round((systemStore.memoryUsage.used / systemStore.memoryUsage.total) * 100) || 0"
|
||||
:stroke-color="getLoadColor((systemStore.memoryUsage.used / systemStore.memoryUsage.total) * 100)"
|
||||
:show-info="false" />
|
||||
</div>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- 统计数据卡片 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="业务统计" :bordered="false" style="height: 100%">
|
||||
<a-row :gutter="16" style="margin-bottom: 24px">
|
||||
<a-col :span="12">
|
||||
<a-statistic title="窗口数量" :value="systemStore.stats.workers || 0">
|
||||
<template #suffix>
|
||||
<span style="font-size: 14px; color: #8c8c8c;">个</span>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-statistic title="实例数量" :value="systemStore.stats.instances || 0">
|
||||
<template #suffix>
|
||||
<span style=" font-size: 14px; color: #8c8c8c;">个</span>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-statistic title="正在进行" :value="queueStats.processing">
|
||||
<template #suffix>
|
||||
<span style="font-size: 14px; color: #8c8c8c;">/ {{ queueStats.total }}</span>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-statistic title="等待排队" :value="queueStats.waiting">
|
||||
<template #suffix>
|
||||
<span style="font-size: 14px; color: #8c8c8c;">/ {{ queueStats.total }}</span>
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 任务队列列表 -->
|
||||
<a-card title="任务队列实时监控" :bordered="false" style="width: 100%" :bodyStyle="{ padding: '0 24px' }">
|
||||
<template #extra>
|
||||
<div style="color: #8c8c8c; font-size: 12px;">
|
||||
<SyncOutlined :spin="true" style="margin-right: 4px" /> 实时刷新中
|
||||
</div>
|
||||
</template>
|
||||
<a-list item-layout="horizontal" :data-source="queueData">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta :description="`ID: ${item.id}`">
|
||||
<template #title>
|
||||
<span style="font-weight: 500; margin-right: 8px;">{{ item.model }}</span>
|
||||
<a-tag v-if="item.worker" color="blue">{{ item.worker }}</a-tag>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
|
||||
<div>
|
||||
<a-tag v-if="item.status === 'processing'" color="processing">
|
||||
<template #icon>
|
||||
<SyncOutlined :spin="true" />
|
||||
</template>
|
||||
进行中
|
||||
</a-tag>
|
||||
<a-tag v-else-if="item.status === 'waiting'" color="warning">
|
||||
<template #icon>
|
||||
<ExclamationCircleOutlined />
|
||||
</template>
|
||||
等待中
|
||||
</a-tag>
|
||||
<a-tag v-else-if="item.status === 'success'" color="success">
|
||||
<template #icon>
|
||||
<CheckCircleOutlined />
|
||||
</template>
|
||||
已完成
|
||||
</a-tag>
|
||||
</div>
|
||||
</a-list-item>
|
||||
</template>
|
||||
<div v-if="queueData.length === 0" style="text-align: center; padding: 24px; color: #8c8c8c;">
|
||||
暂无任务
|
||||
</div>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive, computed } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { SettingOutlined, AppstoreOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const currentAdapter = ref(null);
|
||||
const currentConfig = reactive({});
|
||||
|
||||
// 挂载时获取数据
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
settingsStore.fetchAdaptersMeta(),
|
||||
settingsStore.fetchAdapterConfig()
|
||||
]);
|
||||
});
|
||||
|
||||
// 适配器列表
|
||||
const adapters = computed(() => settingsStore.adaptersMeta);
|
||||
|
||||
// 打开抽屉进行编辑
|
||||
const handleEdit = (adapter) => {
|
||||
currentAdapter.value = adapter;
|
||||
// 加载现有配置或默认值
|
||||
const existing = settingsStore.adapterConfig[adapter.id] || {};
|
||||
|
||||
// 重置当前配置表单
|
||||
Object.keys(currentConfig).forEach(key => delete currentConfig[key]);
|
||||
|
||||
// 使用现有值或schema中的默认值初始化表单
|
||||
if (adapter.configSchema) {
|
||||
adapter.configSchema.forEach(field => {
|
||||
if (existing[field.key] !== undefined) {
|
||||
currentConfig[field.key] = existing[field.key];
|
||||
} else {
|
||||
currentConfig[field.key] = field.default;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 保存配置
|
||||
const handleSave = async () => {
|
||||
if (!currentAdapter.value) return;
|
||||
|
||||
const configToSave = {
|
||||
[currentAdapter.value.id]: { ...currentConfig }
|
||||
};
|
||||
|
||||
const success = await settingsStore.saveAdapterConfig(configToSave);
|
||||
if (success) {
|
||||
drawerVisible.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="background: transparent;">
|
||||
<a-card title="适配器管理" :bordered="false">
|
||||
<template #extra>
|
||||
<a-button type="link" @click="settingsStore.fetchAdaptersMeta">刷新列表</a-button>
|
||||
</template>
|
||||
|
||||
<a-list :grid="{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 3, xl: 4, xxl: 4 }" :data-source="adapters">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-card hoverable @click="handleEdit(item)" :bodyStyle="{ padding: '16px' }">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<AppstoreOutlined style="font-size: 20px; color: #1890ff; margin-right: 12px;" />
|
||||
<span style="font-weight: 600; font-size: 15px;">{{ item.id }}</span>
|
||||
</div>
|
||||
<SettingOutlined style="font-size: 16px; color: #8c8c8c;" />
|
||||
</div>
|
||||
</a-card>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
|
||||
<!-- 配置抽屉 -->
|
||||
<a-drawer v-if="currentAdapter" v-model:open="drawerVisible" :title="`配置适配器 - ${currentAdapter.name}`"
|
||||
width="500" placement="right">
|
||||
<div v-if="!currentAdapter.configSchema || currentAdapter.configSchema.length === 0">
|
||||
<a-empty description="该适配器没有可配置项" />
|
||||
</div>
|
||||
|
||||
<a-form layout="vertical" v-else>
|
||||
<template v-for="field in currentAdapter.configSchema" :key="field.key">
|
||||
<a-form-item :label="field.label" :required="field.required">
|
||||
<!-- 字符串输入 -->
|
||||
<a-input v-if="field.type === 'string'" v-model:value="currentConfig[field.key]"
|
||||
:placeholder="field.placeholder" />
|
||||
|
||||
<!-- 数字输入 -->
|
||||
<a-input-number v-if="field.type === 'number'" v-model:value="currentConfig[field.key]"
|
||||
:min="field.min" :max="field.max" style="width: 100%;" />
|
||||
|
||||
<!-- 布尔开关 -->
|
||||
<div v-if="field.type === 'boolean'">
|
||||
<a-switch v-model:checked="currentConfig[field.key]" />
|
||||
</div>
|
||||
|
||||
<!-- 下拉选择 -->
|
||||
<a-select v-if="field.type === 'select'" v-model:value="currentConfig[field.key]"
|
||||
:options="field.options" />
|
||||
|
||||
<div v-if="field.note" style="font-size: 12px; color: #8c8c8c; margin-top: 4px;">
|
||||
{{ field.note }}
|
||||
</div>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
|
||||
<template #footer>
|
||||
<div style="text-align: right;">
|
||||
<a-button style="margin-right: 8px" @click="drawerVisible = false">取消</a-button>
|
||||
<a-button type="primary" @click="handleSave">保存配置</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
path: '',
|
||||
headless: false,
|
||||
// 全局代理
|
||||
proxyEnable: false,
|
||||
proxyType: 'http',
|
||||
proxyHost: '127.0.0.1',
|
||||
proxyPort: 7890,
|
||||
proxyAuth: false,
|
||||
proxyUser: '',
|
||||
proxyPasswd: ''
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.fetchBrowserConfig();
|
||||
const cfg = settingsStore.browserConfig || {};
|
||||
formData.path = cfg.path || '';
|
||||
formData.headless = cfg.headless || false;
|
||||
|
||||
if (cfg.proxy) {
|
||||
formData.proxyEnable = cfg.proxy.enable || false;
|
||||
formData.proxyType = cfg.proxy.type || 'http';
|
||||
formData.proxyHost = cfg.proxy.host || '';
|
||||
formData.proxyPort = cfg.proxy.port || 7890;
|
||||
formData.proxyAuth = cfg.proxy.auth || false;
|
||||
formData.proxyUser = cfg.proxy.username || '';
|
||||
formData.proxyPasswd = cfg.proxy.password || '';
|
||||
}
|
||||
});
|
||||
|
||||
// 保存设置
|
||||
const handleSave = async () => {
|
||||
const config = {
|
||||
path: formData.path,
|
||||
headless: formData.headless,
|
||||
proxy: {
|
||||
enable: formData.proxyEnable,
|
||||
type: formData.proxyType,
|
||||
host: formData.proxyHost,
|
||||
port: formData.proxyPort,
|
||||
auth: formData.proxyAuth,
|
||||
username: formData.proxyUser,
|
||||
password: formData.proxyPasswd
|
||||
}
|
||||
};
|
||||
await settingsStore.saveBrowserConfig(config);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="background: transparent;">
|
||||
<a-card title="浏览器设置" :bordered="false" style="width: 100%;">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<!-- 浏览器可执行文件路径 -->
|
||||
<a-col :xs="24" :md="24">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">浏览器可执行文件路径</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
留空则使用 Camoufox 默认下载路径<br>
|
||||
Windows示例: C:\camoufox\camoufox.exe<br>
|
||||
Linux示例: /opt/camoufox/camoufox
|
||||
</div>
|
||||
<a-input v-model:value="formData.path" placeholder="留空使用默认路径" />
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 无头模式 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">无头模式</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
启用后浏览器将在后台运行,不显示窗口
|
||||
</div>
|
||||
<a-switch v-model:checked="formData.headless" />
|
||||
<span style="margin-left: 8px;">
|
||||
{{ formData.headless ? '已启用' : '未启用' }}
|
||||
</span>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 全局代理设置(折叠面板) -->
|
||||
<div style="margin-top: 16px;">
|
||||
<a-collapse>
|
||||
<a-collapse-panel key="proxy" header="全局代理设置">
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 16px;">
|
||||
如果实例没有独立配置代理,将使用此全局代理配置
|
||||
</div>
|
||||
|
||||
<!-- 是否启用代理 -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<a-switch v-model:checked="formData.proxyEnable" />
|
||||
<span style="margin-left: 8px;">
|
||||
{{ formData.proxyEnable ? '已启用全局代理' : '未启用全局代理' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 代理类型 -->
|
||||
<div style="margin-bottom: 16px;" v-if="formData.proxyEnable">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">代理类型</div>
|
||||
<a-segmented v-model:value="formData.proxyType" block :options="[
|
||||
{ label: 'HTTP', value: 'http' },
|
||||
{ label: 'SOCKS5', value: 'socks5' }
|
||||
]" />
|
||||
</div>
|
||||
|
||||
<a-row :gutter="16" v-if="formData.proxyEnable">
|
||||
<!-- 代理主机 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">代理主机</div>
|
||||
<a-input v-model:value="formData.proxyHost" placeholder="例如: 127.0.0.1" />
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 代理端口 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">代理端口</div>
|
||||
<a-input-number v-model:value="formData.proxyPort" :min="1" :max="65535"
|
||||
style="width: 100%" placeholder="例如: 7890" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 是否需要验证 -->
|
||||
<div style="margin-bottom: 16px;" v-if="formData.proxyEnable">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">代理认证</div>
|
||||
<a-switch v-model:checked="formData.proxyAuth" />
|
||||
<span style="margin-left: 8px;">
|
||||
{{ formData.proxyAuth ? '需要认证' : '无需认证' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="16" v-if="formData.proxyEnable && formData.proxyAuth">
|
||||
<!-- 用户名 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">用户名</div>
|
||||
<a-input v-model:value="formData.proxyUser" placeholder="请输入用户名" />
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 密码 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">密码</div>
|
||||
<a-input-password v-model:value="formData.proxyPasswd" placeholder="请输入密码" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</div>
|
||||
|
||||
<!-- 保存按钮(右下角) -->
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 24px;">
|
||||
<a-button type="primary" @click="handleSave">
|
||||
保存设置
|
||||
</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
port: 5173,
|
||||
authToken: '',
|
||||
keepaliveMode: 'comment',
|
||||
queueBuffer: 2,
|
||||
imageLimit: 5
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.fetchServerConfig();
|
||||
Object.assign(formData, settingsStore.serverConfig);
|
||||
});
|
||||
|
||||
// 保存设置
|
||||
const handleSave = async () => {
|
||||
await settingsStore.saveServerConfig(formData);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="background: transparent;">
|
||||
<a-card title="服务器设置" :bordered="false" style="width: 100%;">
|
||||
<!-- 4宫格表单布局 -->
|
||||
<a-row :gutter="[16, 16]">
|
||||
<!-- 监听端口 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">监听端口</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
设置服务器监听的端口号,默认为 5173
|
||||
</div>
|
||||
<a-input-number v-model:value="formData.port" :min="1" :max="65535" placeholder="请输入端口号"
|
||||
style="width: 100%" />
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 鉴权 Token -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">鉴权 Token</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
用于 API 请求鉴权的密钥,留空则不启用鉴权
|
||||
</div>
|
||||
<a-input-password v-model:value="formData.authToken" placeholder="请输入 Token" type="password" />
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 心跳包类型 (Keepalive Mode) -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">心跳包类型</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
选择 SSE 流式响应的心跳包格式
|
||||
</div>
|
||||
<a-select v-model:value="formData.keepaliveMode" style="width: 100%" placeholder="请选择心跳包类型">
|
||||
<a-select-option value="comment">Comment - 注释格式</a-select-option>
|
||||
<a-select-option value="content">Content - 内容格式</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 保存按钮(右下角) -->
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 24px;">
|
||||
<a-button type="primary" @click="handleSave">
|
||||
保存设置
|
||||
</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 队列设置 -->
|
||||
<a-card title="队列设置" :bordered="false" style="width: 100%; margin-top: 10px;">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<!-- 队列缓冲区大小 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">队列缓冲区大小</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
非流式请求的额外排队数(设为 0 则不限制非流式请求数量)<br>
|
||||
实际队列上限 = Workers数量 + 缓冲区大小
|
||||
</div>
|
||||
<a-input-number v-model:value="formData.queueBuffer" :min="0" :max="100" placeholder="默认为 2"
|
||||
style="width: 100%" />
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 图片数量上限 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">图片数量上限</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
单次请求最多支持的图片附件数量<br>
|
||||
网页最多支持10个附件,超出会被丢弃
|
||||
</div>
|
||||
<a-input-number v-model:value="formData.imageLimit" :min="1" :max="10" placeholder="默认为 5"
|
||||
style="width: 100%" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 保存按钮(右下角) -->
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 24px;">
|
||||
<a-button type="primary" @click="handleSave">
|
||||
保存设置
|
||||
</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 确保在手机端也能正常显示 */
|
||||
.ant-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,490 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const poolConfig = computed({
|
||||
get: () => settingsStore.poolConfig,
|
||||
set: (val) => settingsStore.poolConfig = val
|
||||
});
|
||||
|
||||
const handleSavePool = async () => {
|
||||
await settingsStore.savePoolConfig(poolConfig.value);
|
||||
};
|
||||
|
||||
// 获取初始数据
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
settingsStore.fetchWorkerConfig(),
|
||||
settingsStore.fetchPoolConfig(),
|
||||
settingsStore.fetchAdaptersMeta()
|
||||
]);
|
||||
});
|
||||
|
||||
// 计算属性:适配器选项
|
||||
const adapterOptions = computed(() => {
|
||||
const options = settingsStore.adaptersMeta.map(a => ({
|
||||
label: a.name,
|
||||
value: a.id
|
||||
}));
|
||||
// 手动添加 Merge 选项(如果没有在元数据中返回)
|
||||
if (!options.find(o => o.value === 'merge')) {
|
||||
options.push({ label: 'Merge(聚合模式)', value: 'merge' });
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
// 实例列表表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: '实例名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Worker 数量',
|
||||
dataIndex: 'workerCount',
|
||||
key: 'workerCount',
|
||||
},
|
||||
{
|
||||
title: '代理',
|
||||
dataIndex: 'proxy',
|
||||
key: 'proxy',
|
||||
},
|
||||
{
|
||||
title: '数据标记',
|
||||
key: 'userDataMark',
|
||||
dataIndex: 'userDataMark',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
},
|
||||
];
|
||||
|
||||
// 实例列表数据 (从 Store 获取)
|
||||
const instanceData = computed({
|
||||
get: () => settingsStore.workerConfig,
|
||||
set: (val) => { settingsStore.workerConfig = val; }
|
||||
});
|
||||
|
||||
// 抽屉状态
|
||||
const drawerOpen = ref(false);
|
||||
const editingInstance = ref(null);
|
||||
|
||||
// 编辑表单数据
|
||||
const editForm = ref({
|
||||
name: '',
|
||||
userDataMark: '',
|
||||
proxy: false,
|
||||
proxyType: 'socks5',
|
||||
proxyHost: '',
|
||||
proxyPort: 1080,
|
||||
proxyAuth: false,
|
||||
proxyUsername: '',
|
||||
proxyPassword: '',
|
||||
workers: []
|
||||
});
|
||||
|
||||
// 创建实例
|
||||
const handleCreateInstance = () => {
|
||||
editingInstance.value = null; // null表示创建新实例
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 7);
|
||||
// 重置表单为默认值
|
||||
editForm.value = {
|
||||
name: `instance-${(instanceData.value || []).length + 1}-${randomSuffix}`,
|
||||
userDataMark: '',
|
||||
proxy: false,
|
||||
proxyType: 'socks5',
|
||||
proxyHost: '',
|
||||
proxyPort: 1080,
|
||||
proxyAuth: false,
|
||||
proxyUsername: '',
|
||||
proxyPassword: '',
|
||||
workers: []
|
||||
};
|
||||
drawerOpen.value = true;
|
||||
};
|
||||
|
||||
// 编辑实例
|
||||
const handleEdit = (record) => {
|
||||
editingInstance.value = record;
|
||||
// 填充表单数据
|
||||
editForm.value = {
|
||||
name: record.name,
|
||||
userDataMark: record.userDataMark || '',
|
||||
proxy: record.proxy ? true : false,
|
||||
proxyType: record.proxy?.type || 'socks5',
|
||||
proxyHost: record.proxy?.host || '',
|
||||
proxyPort: record.proxy?.port || 1080,
|
||||
proxyAuth: record.proxy?.auth || false,
|
||||
proxyUsername: record.proxy?.username || '',
|
||||
proxyPassword: record.proxy?.password || '',
|
||||
workers: record.workers ? [...record.workers] : []
|
||||
};
|
||||
// 兼容前端展示用的 proxy 布尔值
|
||||
if (record.proxy === null || record.proxy === undefined) {
|
||||
editForm.value.proxy = false;
|
||||
}
|
||||
drawerOpen.value = true;
|
||||
};
|
||||
|
||||
// 删除实例
|
||||
const handleDelete = async (record) => {
|
||||
const newList = instanceData.value.filter(item => item.id !== record.id);
|
||||
await settingsStore.saveWorkerConfig(newList);
|
||||
};
|
||||
|
||||
// 保存编辑
|
||||
const handleSaveEdit = async () => {
|
||||
// 构建要保存的对象结构
|
||||
const instanceToSave = {
|
||||
id: editingInstance.value ? editingInstance.value.id : `inst_${Date.now()}`,
|
||||
name: editForm.value.name,
|
||||
userDataMark: editForm.value.userDataMark,
|
||||
workers: editForm.value.workers,
|
||||
// 如果启用了代理,则构建代理对象,否则为 null
|
||||
proxy: editForm.value.proxy ? {
|
||||
enable: true,
|
||||
type: editForm.value.proxyType,
|
||||
host: editForm.value.proxyHost,
|
||||
port: editForm.value.proxyPort,
|
||||
auth: editForm.value.proxyAuth,
|
||||
username: editForm.value.proxyUsername,
|
||||
password: editForm.value.proxyPassword
|
||||
} : null
|
||||
};
|
||||
|
||||
let newList = [...(instanceData.value || [])];
|
||||
if (editingInstance.value === null) {
|
||||
// 创建
|
||||
newList.push(instanceToSave);
|
||||
} else {
|
||||
// 更新
|
||||
const index = newList.findIndex(item => item.id === editingInstance.value.id);
|
||||
if (index > -1) {
|
||||
newList[index] = instanceToSave;
|
||||
}
|
||||
}
|
||||
|
||||
const success = await settingsStore.saveWorkerConfig(newList);
|
||||
if (success) {
|
||||
drawerOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 编辑中的Worker索引
|
||||
const editingWorkerIndex = ref(-1);
|
||||
const workerFormVisible = ref(false);
|
||||
const workerForm = ref({
|
||||
name: '',
|
||||
type: 'lmarena',
|
||||
mergeTypes: [],
|
||||
mergeMonitor: ''
|
||||
});
|
||||
|
||||
// 添加Worker
|
||||
const handleAddWorker = () => {
|
||||
editingWorkerIndex.value = -1;
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 7);
|
||||
workerForm.value = {
|
||||
name: `worker-${editForm.value.workers.length + 1}-${randomSuffix}`,
|
||||
type: 'lmarena',
|
||||
mergeTypes: [],
|
||||
mergeMonitor: ''
|
||||
};
|
||||
workerFormVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑Worker
|
||||
const handleEditWorker = (index) => {
|
||||
editingWorkerIndex.value = index;
|
||||
const worker = editForm.value.workers[index];
|
||||
workerForm.value = {
|
||||
name: worker.name,
|
||||
type: worker.type,
|
||||
mergeTypes: worker.mergeTypes ? [...worker.mergeTypes] : [],
|
||||
mergeMonitor: worker.mergeMonitor || ''
|
||||
};
|
||||
workerFormVisible.value = true;
|
||||
};
|
||||
|
||||
// 保存Worker配置
|
||||
const handleSaveWorker = () => {
|
||||
if (editingWorkerIndex.value === -1) {
|
||||
// 新增
|
||||
editForm.value.workers.push({ ...workerForm.value });
|
||||
} else {
|
||||
// 编辑
|
||||
editForm.value.workers[editingWorkerIndex.value] = { ...workerForm.value };
|
||||
}
|
||||
workerFormVisible.value = false;
|
||||
};
|
||||
|
||||
// 删除Worker
|
||||
const handleRemoveWorker = (index) => {
|
||||
editForm.value.workers.splice(index, 1);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="background: transparent;">
|
||||
<a-card title="负载均衡" :bordered="false" style="width: 100%; margin-bottom: 10px;">
|
||||
<!-- 调度策略 -->
|
||||
<div style="margin-bottom: 24px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">调度策略</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 12px;">
|
||||
选择任务分配到工作实例的调度算法
|
||||
</div>
|
||||
<a-segmented v-model:value="poolConfig.strategy" block :options="[
|
||||
{ label: '最少繁忙', value: 'least_busy' },
|
||||
{ label: '轮询', value: 'round_robin' },
|
||||
{ label: '随机', value: 'random' }
|
||||
]" />
|
||||
</div>
|
||||
|
||||
<!-- 故障转移 -->
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">故障转移</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 12px;">
|
||||
启用后,任务失败时会自动切换到其他可用实例重试
|
||||
</div>
|
||||
<a-switch v-model:checked="poolConfig.failover.enabled" />
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="12">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">重试次数</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 12px;">
|
||||
故障转移时最大重试次数,范围 1-10
|
||||
</div>
|
||||
<a-input-number v-model:value="poolConfig.failover.maxRetries" :min="1" :max="10"
|
||||
:disabled="!poolConfig.failover.enabled" style="width: 100%" placeholder="请输入重试次数" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 24px;">
|
||||
<a-button type="primary" @click="handleSavePool">
|
||||
保存设置
|
||||
</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
|
||||
<a-card :bordered="false" style="width: 100%;">
|
||||
<!-- 卡片标题和创建按钮 -->
|
||||
<template #title>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>实例列表</span>
|
||||
<a-button type="primary" @click="handleCreateInstance">
|
||||
创建实例
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 实例表格 -->
|
||||
<a-table :columns="columns" :data-source="instanceData" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 实例名称 -->
|
||||
<template v-if="column.key === 'name'">
|
||||
<a>{{ record.name }}</a>
|
||||
</template>
|
||||
|
||||
<!-- Worker 数量 -->
|
||||
<template v-else-if="column.key === 'workerCount'">
|
||||
{{ record.workers ? record.workers.length : 0 }}
|
||||
</template>
|
||||
|
||||
<!-- 代理状态 -->
|
||||
<template v-else-if="column.key === 'proxy'">
|
||||
<a-tag :color="record.proxy ? 'green' : 'default'">
|
||||
{{ record.proxy ? '已启用' : '未启用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<span>
|
||||
<a @click="handleEdit(record)">编辑</a>
|
||||
<a-divider type="vertical" />
|
||||
<a style="color: #ff4d4f" @click="handleDelete(record)">删除</a>
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 编辑/创建抽屉 -->
|
||||
<a-drawer v-model:open="drawerOpen"
|
||||
:title="editingInstance === null ? '创建实例' : `编辑实例 - ${editingInstance.name}`" placement="right" width="500">
|
||||
<div style="margin-bottom: 24px;">
|
||||
<!-- 实例名称 -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">实例名称</div>
|
||||
<div style="font-size: 12px; color: #ff4d4f; margin-bottom: 8px;">
|
||||
* 名称必须全局唯一,不可重复
|
||||
</div>
|
||||
<a-input v-model:value="editForm.name" placeholder="请输入实例名称" />
|
||||
</div>
|
||||
|
||||
<!-- 数据标记 -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">数据标记</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
用于区分实例数据存储的文件夹名称 (userDataMark)
|
||||
</div>
|
||||
<a-input v-model:value="editForm.userDataMark" placeholder="请输入数据标记,如: main-gemini" />
|
||||
</div>
|
||||
|
||||
<!-- 代理设置(折叠面板) -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<a-collapse>
|
||||
<a-collapse-panel key="proxy" header="代理设置">
|
||||
<!-- 是否启用代理 -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<a-switch v-model:checked="editForm.proxy" />
|
||||
<span style="margin-left: 8px;">
|
||||
{{ editForm.proxy ? '已启用代理' : '未启用代理' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 代理类型 -->
|
||||
<div style="margin-bottom: 16px;" v-if="editForm.proxy">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">代理类型</div>
|
||||
<a-segmented v-model:value="editForm.proxyType" block :options="[
|
||||
{ label: 'SOCKS5', value: 'socks5' },
|
||||
{ label: 'HTTP', value: 'http' }
|
||||
]" style="width: 100%" />
|
||||
</div>
|
||||
|
||||
<!-- 服务器地址 -->
|
||||
<div style="margin-bottom: 16px;" v-if="editForm.proxy">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">服务器地址</div>
|
||||
<a-input v-model:value="editForm.proxyHost" placeholder="例如: 127.0.0.1" />
|
||||
</div>
|
||||
|
||||
<!-- 端口 -->
|
||||
<div style="margin-bottom: 16px;" v-if="editForm.proxy">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">端口</div>
|
||||
<a-input-number v-model:value="editForm.proxyPort" :min="1" :max="65535"
|
||||
style="width: 100%" placeholder="例如: 1080" />
|
||||
</div>
|
||||
|
||||
<!-- 是否需要验证 -->
|
||||
<div style="margin-bottom: 16px;" v-if="editForm.proxy">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">身份验证</div>
|
||||
<a-switch v-model:checked="editForm.proxyAuth" />
|
||||
<span style="margin-left: 8px;">
|
||||
{{ editForm.proxyAuth ? '需要验证' : '无需验证' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 用户名 -->
|
||||
<div style="margin-bottom: 16px;" v-if="editForm.proxy && editForm.proxyAuth">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">用户名</div>
|
||||
<a-input v-model:value="editForm.proxyUsername" placeholder="请输入用户名" />
|
||||
</div>
|
||||
|
||||
<!-- 密码 -->
|
||||
<div style="margin-bottom: 16px;" v-if="editForm.proxy && editForm.proxyAuth">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">密码</div>
|
||||
<a-input-password v-model:value="editForm.proxyPassword" placeholder="请输入密码" />
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</div>
|
||||
|
||||
<!-- Worker 列表 -->
|
||||
<div>
|
||||
<div
|
||||
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<div style="font-weight: 600;">Worker 列表</div>
|
||||
<a-button size="small" type="primary" @click="handleAddWorker">
|
||||
添加 Worker
|
||||
</a-button>
|
||||
</div>
|
||||
<a-list bordered :data-source="editForm.workers" style="margin-top: 8px;">
|
||||
<template #renderItem="{ item, index }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a @click="handleEditWorker(index)">编辑</a>
|
||||
<a style="color: #ff4d4f" @click="handleRemoveWorker(index)">删除</a>
|
||||
</template>
|
||||
<div>
|
||||
<div style="font-weight: 600;">{{ item.name }}</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c;">
|
||||
类型: {{ item.type }}
|
||||
<span v-if="item.type === 'merge'">
|
||||
| 聚合: {{ item.mergeTypes?.join(', ') || '无' }}
|
||||
<span v-if="item.mergeMonitor">
|
||||
| 监控: {{ item.mergeMonitor }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 抽屉底部保存按钮 -->
|
||||
<template #footer>
|
||||
<div style="text-align: right;">
|
||||
<a-button style="margin-right: 8px" @click="drawerOpen = false">取消</a-button>
|
||||
<a-button type="primary" @click="handleSaveEdit">保存</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
|
||||
<!-- Worker配置模态框 -->
|
||||
<a-modal v-model:open="workerFormVisible" :title="editingWorkerIndex === -1 ? '添加 Worker' : '编辑 Worker'"
|
||||
okText="确定" cancelText="取消" @ok="handleSaveWorker">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">Worker 名称</div>
|
||||
<div style="font-size: 12px; color: #ff4d4f; margin-bottom: 8px;">
|
||||
* 名称必须全局唯一,不可重复
|
||||
</div>
|
||||
<a-input v-model:value="workerForm.name" placeholder="例如: default" />
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">适配器类型</div>
|
||||
<a-select v-model:value="workerForm.type" style="width: 100%" :options="adapterOptions" />
|
||||
</div>
|
||||
|
||||
<!-- Merge 模式额外配置 -->
|
||||
<template v-if="workerForm.type === 'merge'">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">聚合类型</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
选择要聚合的后端适配器(可多选)
|
||||
</div>
|
||||
<a-select v-model:value="workerForm.mergeTypes" mode="multiple" style="width: 100%"
|
||||
placeholder="选择要聚合的适配器" :options="adapterOptions">
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">空闲监控后端</div>
|
||||
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 8px;">
|
||||
空闲时挂机监控的后端(可选)
|
||||
</div>
|
||||
<a-select v-model:value="workerForm.mergeMonitor" style="width: 100%" placeholder="选择监控后端(可留空)"
|
||||
allow-clear>
|
||||
<a-select-option value="">无</a-select-option>
|
||||
<a-select-option v-for="type in workerForm.mergeTypes" :key="type" :value="type">
|
||||
{{ type }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user