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
+214
View File
@@ -0,0 +1,214 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { Modal } from 'ant-design-vue';
import {
DashboardOutlined,
SettingOutlined,
ToolOutlined,
PoweroffOutlined,
GithubOutlined
} from '@ant-design/icons-vue';
import { useSettingsStore } from '@/stores/settings';
import LoginModal from '@/components/auth/LoginModal.vue';
const router = useRouter();
const settingsStore = useSettingsStore();
const selectedKeys = ref(['dash']);
const collapsed = ref(false);
const loginVisible = ref(false);
const iconLoading = ref(false);
const enterIconLoading = () => {
iconLoading.value = true;
settingsStore.setToken('');
setTimeout(() => {
iconLoading.value = false;
loginVisible.value = true;
}, 500);
};
// 菜单 key 到路由路径的映射
const menuRoutes = {
'dash': '/',
'settings-server': '/settings/server',
'settings-workers': '/settings/workers',
'settings-browser': '/settings/browser',
'settings-adapters': '/settings/adapters',
'tools-display': '/tools/display',
'tools-cache': '/tools/cache'
};
// 处理菜单点击
const handleMenuClick = ({ key }) => {
const route = menuRoutes[key];
if (route) {
router.push(route);
}
};
const isInitializing = ref(true);
// 后端连接检测
let connectionCheckInterval = null;
let disconnectModalShown = false;
async function checkConnection() {
try {
const res = await fetch('/admin/status', {
headers: settingsStore.getHeaders(),
signal: AbortSignal.timeout(5000)
});
if (res.ok && disconnectModalShown) {
// 连接恢复,刷新页面
disconnectModalShown = false;
Modal.destroyAll();
window.location.reload();
}
} catch (e) {
if (!disconnectModalShown && !isInitializing.value) {
disconnectModalShown = true;
Modal.warning({
title: '后端连接断开',
content: '无法连接到后端服务,请检查服务是否正在运行。连接恢复后页面将自动刷新。',
okText: '我知道了',
centered: true
});
}
}
}
// 挂载时检查身份验证
onMounted(async () => {
// 响应式侧边栏
const checkScreenSize = () => {
if (window.innerWidth <= 768) {
collapsed.value = true;
}
};
checkScreenSize();
window.addEventListener('resize', checkScreenSize);
// 身份验证
try {
if (!settingsStore.token) {
loginVisible.value = true;
} else {
// 使用真实API验证
const isValid = await settingsStore.checkAuth();
if (!isValid) {
settingsStore.setToken(''); // 清除无效token
loginVisible.value = true;
}
}
} catch (e) {
console.error('Auth check failed', e);
loginVisible.value = true;
} finally {
// 隐藏加载状态
isInitializing.value = false;
}
// 启动后端连接检测(每 5 秒检测一次)
connectionCheckInterval = setInterval(checkConnection, 5000);
// 清理监听器
onUnmounted(() => {
window.removeEventListener('resize', checkScreenSize);
if (connectionCheckInterval) {
clearInterval(connectionCheckInterval);
}
});
});
</script>
<template>
<a-spin :spinning="isInitializing" tip="正在验证身份..." size="large"
style="height: 100vh; display: flex; align-items: center; justify-content: center;" v-if="isInitializing" />
<div v-else>
<LoginModal v-model:visible="loginVisible" />
<a-layout style="min-height: 100vh" theme="light">
<a-layout-header class="header"
style="background: rgba(255, 255, 255, 0.7); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border-bottom: 1.5px solid rgba(0, 0, 0, 0.05); display: flex; align-items: center; padding: 0 24px; position: fixed; width: 100%; top: 0; z-index: 1000;">
<div class="logo" style="font-size: 1.25rem; font-weight: bold; color: #1890ff; margin-right: 24px;">
WebAI2API
</div>
<a-flex justify="end" align="center" style="flex: 1;">
<a-button danger :loading="iconLoading" @click="enterIconLoading">
<template #icon>
<PoweroffOutlined />
</template>
退出登录
</a-button>
</a-flex>
</a-layout-header>
<a-layout style="margin-top: 64px;">
<a-layout-sider v-model:collapsed="collapsed" collapsible theme="light"
style="position: fixed; left: 0; top: 64px; height: calc(100vh - 64px); overflow-y: auto; z-index: 100;">
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" @click="handleMenuClick">
<a-menu-item key="dash">
<DashboardOutlined />
<span>状态概览</span>
</a-menu-item>
<a-sub-menu key="settings">
<template #title>
<span>
<SettingOutlined />
<span>系统设置</span>
</span>
</template>
<a-menu-item key="settings-server">服务器</a-menu-item>
<a-menu-item key="settings-workers">工作池</a-menu-item>
<a-menu-item key="settings-browser">浏览器</a-menu-item>
<a-menu-item key="settings-adapters">适配器</a-menu-item>
</a-sub-menu>
<a-sub-menu key="tools">
<template #title>
<span>
<ToolOutlined />
<span>系统管理</span>
</span>
</template>
<a-menu-item key="tools-display">虚拟显示器</a-menu-item>
<a-menu-item key="tools-cache">缓存与重启</a-menu-item>
</a-sub-menu>
</a-menu>
</a-layout-sider>
<a-layout
:style="{ marginLeft: collapsed ? '80px' : '200px', padding: '16px', transition: 'margin-left 0.2s' }">
<a-layout-content style="min-height: 280px">
<router-view />
</a-layout-content>
<a-layout-footer class="footer" style="padding: 0px; margin-top: 10px;">
<a-card :bordered="false"
:bodyStyle="{ padding: '16px 24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }">
<div>
<a href="https://github.com/foxhui/WebAI2API" target="_blank" style="color: #8c8c8c; font-size: 20px;">
<GithubOutlined />
</a>
</div>
</a-card>
</a-layout-footer>
</a-layout>
</a-layout>
</a-layout>
</div>
</template>
<style scoped>
/* 滚动条美化 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 3px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
</style>
+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>
+28
View File
@@ -0,0 +1,28 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Antd from 'ant-design-vue';
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import 'ant-design-vue/dist/reset.css';
const routes = [
{ path: '/', component: () => import('@/components/dash.vue') },
{ path: '/settings/server', component: () => import('@/components/settings/server.vue') },
{ path: '/settings/workers', component: () => import('@/components/settings/workers.vue') },
{ path: '/settings/browser', component: () => import('@/components/settings/browser.vue') },
{ path: '/settings/adapters', component: () => import('@/components/settings/adapters.vue') },
{ path: '/tools/display', component: () => import('@/components/tools/display.vue') },
{ path: '/tools/cache', component: () => import('@/components/tools/cache.vue') },
];
const router = createRouter({
history: createWebHistory(),
routes
})
const pinia = createPinia()
const app = createApp(App);
app.use(pinia)
app.use(router)
app.use(Antd)
app.mount('#app')
+229
View File
@@ -0,0 +1,229 @@
import { defineStore } from 'pinia';
import { message, Modal } from 'ant-design-vue';
export const useSettingsStore = defineStore('settings', {
state: () => ({
token: localStorage.getItem('admin_token') || '',
serverConfig: {},
browserConfig: {},
workerConfig: [],
poolConfig: {
strategy: 'least_busy',
failover: {
enabled: false,
maxRetries: 3
}
},
adapterConfig: {},
adaptersMeta: []
}),
actions: {
setToken(token) {
this.token = token;
if (token) {
localStorage.setItem('admin_token', token);
} else {
localStorage.removeItem('admin_token');
}
},
getHeaders() {
const headers = { 'Content-Type': 'application/json' };
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
return headers;
},
async checkAuth() {
try {
const res = await fetch('/admin/status', {
headers: this.getHeaders()
});
return res.status !== 401;
} catch (e) {
return false;
}
},
// 错误处理辅助函数
async handleResponse(res, successMsg) {
let data = {};
try {
data = await res.json();
} catch (e) {
// 忽略JSON解析错误
}
if (res.ok) {
if (successMsg) message.success(successMsg);
return { success: true, data };
} else {
console.error('Request failed:', res.status, data);
Modal.error({
title: '保存失败',
content: data.message || `请求未成功: ${res.status} ${res.statusText}`,
okText: '好的'
});
return { success: false, data };
}
},
// --- 服务器配置 ---
async fetchServerConfig() {
try {
const res = await fetch('/admin/config/server', { headers: this.getHeaders() });
if (res.ok) this.serverConfig = await res.json();
} catch (e) {
console.error('Fetch server config failed', e);
}
},
async saveServerConfig(config) {
try {
const res = await fetch('/admin/config/server', {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(config)
});
const result = await this.handleResponse(res, '服务器设置保存成功');
if (result.success) {
this.serverConfig = config;
return true;
}
} catch (e) {
Modal.error({ title: '保存失败 (网络异常)', content: e.message });
}
return false;
},
// --- 浏览器配置 ---
async fetchBrowserConfig() {
try {
const res = await fetch('/admin/config/browser', { headers: this.getHeaders() });
if (res.ok) this.browserConfig = await res.json();
} catch (e) {
console.error('Fetch browser config failed', e);
}
},
async saveBrowserConfig(config) {
try {
const res = await fetch('/admin/config/browser', {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(config)
});
const result = await this.handleResponse(res, '浏览器设置保存成功');
if (result.success) {
this.browserConfig = config;
return true;
}
} catch (e) {
Modal.error({ title: '保存失败 (网络异常)', content: e.message });
}
return false;
},
// --- 工作实例配置 ---
async fetchWorkerConfig() {
try {
// 端点已更改为 /admin/config/instances
const res = await fetch('/admin/config/instances', { headers: this.getHeaders() });
if (res.ok) this.workerConfig = await res.json();
} catch (e) {
console.error('Fetch instance configuration failed', e);
}
},
async saveWorkerConfig(config) {
try {
// 端点已更改为 /admin/config/instances
const res = await fetch('/admin/config/instances', {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(config)
});
const result = await this.handleResponse(res, '实例配置保存成功');
if (result.success) {
this.workerConfig = config;
return true;
}
} catch (e) {
Modal.error({ title: '保存失败 (网络异常)', content: e.message });
}
return false;
},
// --- 工作池配置 ---
async fetchPoolConfig() {
try {
const res = await fetch('/admin/config/pool', { headers: this.getHeaders() });
if (res.ok) {
const data = await res.json();
// 合并以确保结构存在
this.poolConfig = {
strategy: data.strategy || 'least_busy',
failover: {
enabled: data.failover?.enabled || false,
maxRetries: data.failover?.maxRetries || 3
}
};
}
} catch (e) {
console.error('Fetch pool config failed', e);
}
},
async savePoolConfig(config) {
try {
const res = await fetch('/admin/config/pool', {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(config)
});
const result = await this.handleResponse(res, '工作池设置保存成功');
if (result.success) {
this.poolConfig = config;
return true;
}
} catch (e) {
Modal.error({ title: '保存失败 (网络异常)', content: e.message });
}
return false;
},
// --- 适配器配置与元数据 ---
async fetchAdaptersMeta() {
try {
const res = await fetch('/admin/adapters', { headers: this.getHeaders() });
if (res.ok) this.adaptersMeta = await res.json();
} catch (e) {
console.error('Fetch adapters meta failed', e);
}
},
async fetchAdapterConfig() {
try {
const res = await fetch('/admin/config/adapters', { headers: this.getHeaders() });
if (res.ok) this.adapterConfig = await res.json();
} catch (e) {
console.error('Fetch adapter config failed', e);
}
},
async saveAdapterConfig(config) {
try {
const res = await fetch('/admin/config/adapters', {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(config)
});
const result = await this.handleResponse(res, '适配器设置保存成功');
if (result.success) {
// 通过合并更新本地状态
this.adapterConfig = { ...this.adapterConfig, ...config };
return true;
}
} catch (e) {
Modal.error({ title: '保存失败 (网络异常)', content: e.message });
}
return false;
}
}
});
+112
View File
@@ -0,0 +1,112 @@
import { defineStore } from 'pinia';
import { message } from 'ant-design-vue';
import { useSettingsStore } from './settings';
export const useSystemStore = defineStore('system', {
state: () => ({
// 系统状态
status: '',
version: '1.0.0',
systemVersion: '',
uptime: 0,
cpuUsage: 0,
memoryUsage: {
total: 0,
used: 0,
free: 0
},
// 仪表盘统计信息
stats: {
totalRequests: 0,
successRate: 0,
activeWorkers: 0,
totalWorkers: 0,
avgResponseTime: 0
}
}),
actions: {
// 获取系统状态
async fetchStatus() {
const settingsStore = useSettingsStore();
try {
const response = await fetch('/admin/status', {
headers: settingsStore.getHeaders()
});
// 如果返回401,状态更新将失败,由App.vue的身份验证检查处理
if (response.ok) {
const data = await response.json();
this.$patch(data);
}
} catch (error) {
console.error('Failed to fetch system status:', error);
}
},
// 获取仪表盘统计信息
async fetchStats() {
const settingsStore = useSettingsStore();
try {
const response = await fetch('/admin/stats', {
headers: settingsStore.getHeaders()
});
if (response.ok) {
const data = await response.json();
this.stats = data;
}
} catch (error) {
console.error('Failed to fetch stats:', error);
}
},
// 重启服务
async restartService(options = {}) {
const settingsStore = useSettingsStore();
const { loginMode, workerName } = options;
try {
const response = await fetch('/admin/restart', {
method: 'POST',
headers: {
...settingsStore.getHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ loginMode, workerName })
});
const data = await response.json();
if (data.success) {
message.success(data.message || '服务重启中...');
return true;
} else {
message.error('重启失败');
return false;
}
} catch (error) {
message.error('重启请求失败');
return false;
}
},
// 停止服务
async stopService() {
const settingsStore = useSettingsStore();
try {
const response = await fetch('/admin/stop', {
method: 'POST',
headers: settingsStore.getHeaders()
});
const data = await response.json();
if (data.success) {
message.success(data.message || '服务停止中...');
return true;
} else {
message.error('停止失败');
return false;
}
} catch (error) {
message.error('停止请求失败');
return false;
}
}
}
});