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
+82
View File
@@ -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>
+237
View File
@@ -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>
+128
View File
@@ -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>
+171
View File
@@ -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>
+123
View File
@@ -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>
+490
View File
@@ -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>
+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>