Files
WebAI2API/webui/src/App.vue
T
2025-12-21 02:57:39 +08:00

624 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { Modal, message } from 'ant-design-vue';
import {
DashboardOutlined,
SettingOutlined,
ToolOutlined,
PoweroffOutlined,
GithubOutlined,
ApiOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
InboxOutlined,
PictureOutlined
} 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);
};
// 接口测试抽屉
const apiTestDrawer = ref(false);
const apiTestResults = ref({
models: { status: 'pending', data: null, error: null },
cookies: { status: 'pending', data: null, error: null },
chat: { status: 'pending', data: null, error: null }
});
const chatTestPrompt = ref('Say hello in one word');
const chatTestModel = ref('');
const chatModelList = ref([]);
const chatImageList = ref([]);
const chatStreamMode = ref(false);
const chatStreamContent = ref('');
// 获取模型列表
const fetchModelList = async () => {
try {
const res = await fetch('/v1/models', { headers: settingsStore.getHeaders() });
if (res.ok) {
const data = await res.json();
chatModelList.value = data.data || [];
if (chatModelList.value.length > 0 && !chatTestModel.value) {
chatTestModel.value = chatModelList.value[0].id;
}
}
} catch (e) {
console.error('获取模型列表失败', e);
}
};
// 图片转 base64
const fileToBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
});
};
// 图片上传前检查
const beforeUpload = (file) => {
const allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
message.error('仅支持 PNG, JPEG, GIF, WebP 格式');
return false;
}
if (chatImageList.value.length >= 10) {
message.error('最多上传 10 张图片');
return false;
}
return false; // 阻止自动上传,手动处理
};
// 处理图片选择
const handleImageChange = async (info) => {
const file = info.file;
if (file.status === 'removed') {
chatImageList.value = chatImageList.value.filter(f => f.uid !== file.uid);
return;
}
try {
const base64 = await fileToBase64(file.originFileObj || file);
chatImageList.value.push({
uid: file.uid,
name: file.name,
base64
});
} catch (e) {
message.error('图片读取失败');
}
};
// 解析 Markdown 图片
const parseMarkdownImages = (content) => {
if (!content) return { text: '', images: [] };
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const images = [];
let match;
let lastIndex = 0;
let textParts = [];
while ((match = imageRegex.exec(content)) !== null) {
// 添加图片之前的文本
if (match.index > lastIndex) {
textParts.push(content.substring(lastIndex, match.index));
}
// 添加图片
images.push({
alt: match[1] || '图片',
src: match[2]
});
// 不添加占位符,直接跳过图片的 markdown 语法
lastIndex = imageRegex.lastIndex;
}
// 添加剩余文本
if (lastIndex < content.length) {
textParts.push(content.substring(lastIndex));
}
return {
text: textParts.join('').trim(),
images
};
};
const testApi = async (type) => {
apiTestResults.value[type].status = 'loading';
apiTestResults.value[type].error = null;
apiTestResults.value[type].data = null;
chatStreamContent.value = '';
try {
let url, options;
if (type === 'models') {
url = '/v1/models';
options = { headers: settingsStore.getHeaders() };
} else if (type === 'cookies') {
url = '/v1/cookies';
options = { headers: settingsStore.getHeaders() };
} else if (type === 'chat') {
url = '/v1/chat/completions';
// 构建消息内容
let content;
if (chatImageList.value.length > 0) {
// 多模态请求
content = [
{ type: 'text', text: chatTestPrompt.value }
];
for (const img of chatImageList.value) {
content.push({
type: 'image_url',
image_url: { url: img.base64 }
});
}
} else {
content = chatTestPrompt.value;
}
options = {
method: 'POST',
headers: { ...settingsStore.getHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
model: chatTestModel.value,
messages: [{ role: 'user', content }],
stream: chatStreamMode.value
})
};
// 流式请求处理
if (chatStreamMode.value) {
const res = await fetch(url, options);
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error?.message || `HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta?.content || '';
chatStreamContent.value += delta;
} catch { /* 忽略解析错误 */ }
}
}
}
apiTestResults.value[type].status = 'success';
apiTestResults.value[type].data = { content: chatStreamContent.value };
return;
}
}
const res = await fetch(url, options);
const data = await res.json();
if (res.ok) {
apiTestResults.value[type].status = 'success';
// Chat 接口:提取 content
if (type === 'chat' && data.choices?.[0]?.message?.content) {
apiTestResults.value[type].data = { content: data.choices[0].message.content };
} else {
apiTestResults.value[type].data = data;
}
} else {
apiTestResults.value[type].status = 'error';
apiTestResults.value[type].error = data.error?.message || `HTTP ${res.status}`;
}
} catch (e) {
apiTestResults.value[type].status = 'error';
apiTestResults.value[type].error = e.message;
}
};
const openApiTestDrawer = () => {
apiTestDrawer.value = true;
// 重置状态
Object.keys(apiTestResults.value).forEach(key => {
apiTestResults.value[key] = { status: 'pending', data: null, error: null };
});
chatImageList.value = [];
// 获取模型列表
fetchModelList();
};
// 菜单 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',
'tools-logs': '/tools/logs'
};
// 处理菜单点击
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;" :gap="12">
<a-button @click="openApiTestDrawer">
<template #icon>
<ApiOutlined />
</template>
接口测试
</a-button>
<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-menu-item key="tools-logs">日志查看器</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>
<!-- 接口测试抽屉 -->
<a-drawer v-model:open="apiTestDrawer" title="接口测试" placement="right" :width="500">
<a-space direction="vertical" style="width: 100%" size="large">
<!-- Models 接口 -->
<a-card title="GET /v1/models" size="small">
<template #extra>
<a-button size="small" type="primary" @click="testApi('models')"
:loading="apiTestResults.models.status === 'loading'">
测试
</a-button>
</template>
<div v-if="apiTestResults.models.status === 'success'">
<a-tag color="success">
<CheckCircleOutlined /> 成功
</a-tag>
<div style="margin-top: 8px; font-size: 12px; color: #8c8c8c;">
返回 {{ apiTestResults.models.data?.data?.length || 0 }} 个模型
</div>
</div>
<div v-else-if="apiTestResults.models.status === 'error'">
<a-tag color="error">
<CloseCircleOutlined /> 失败
</a-tag>
<div style="margin-top: 8px; font-size: 12px; color: #ff4d4f;">
{{ apiTestResults.models.error }}
</div>
</div>
<div v-else style="color: #8c8c8c; font-size: 12px;">点击测试按钮开始</div>
</a-card>
<!-- Cookies 接口 -->
<a-card title="GET /v1/cookies" size="small">
<template #extra>
<a-button size="small" type="primary" @click="testApi('cookies')"
:loading="apiTestResults.cookies.status === 'loading'">
测试
</a-button>
</template>
<div v-if="apiTestResults.cookies.status === 'success'">
<a-tag color="success">
<CheckCircleOutlined /> 成功
</a-tag>
<div style="margin-top: 8px; font-size: 12px; color: #8c8c8c;">
返回 {{ apiTestResults.cookies.data?.cookies?.length || 0 }} Cookie
</div>
</div>
<div v-else-if="apiTestResults.cookies.status === 'error'">
<a-tag color="error">
<CloseCircleOutlined /> 失败
</a-tag>
<div style="margin-top: 8px; font-size: 12px; color: #ff4d4f;">
{{ apiTestResults.cookies.error }}
</div>
</div>
<div v-else style="color: #8c8c8c; font-size: 12px;">点击测试按钮开始</div>
</a-card>
<!-- Chat 接口 -->
<a-card title="POST /v1/chat/completions" size="small">
<template #extra>
<a-button size="small" type="primary" @click="testApi('chat')"
:loading="apiTestResults.chat.status === 'loading'" :disabled="!chatTestModel">
测试
</a-button>
</template>
<!-- 模型选择 -->
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 4px;">模型</div>
<a-select v-model:value="chatTestModel" style="width: 100%" size="small" placeholder="选择模型" show-search>
<a-select-option v-for="model in chatModelList" :key="model.id" :value="model.id">
{{ model.id }}
</a-select-option>
</a-select>
</div>
<!-- 提示词 -->
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 4px;">提示词</div>
<a-textarea v-model:value="chatTestPrompt" placeholder="输入提示词" :rows="2" size="small" />
</div>
<!-- 图片上传 -->
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; color: #8c8c8c; margin-bottom: 4px;">
附加图片 ({{ chatImageList.length }}/10)
</div>
<a-upload-dragger :file-list="[]" :multiple="true" :before-upload="beforeUpload" @change="handleImageChange"
accept=".png,.jpg,.jpeg,.gif,.webp" :show-upload-list="false" style="padding: 8px;">
<p style="margin: 0;">
<InboxOutlined style="font-size: 24px; color: #1890ff;" />
</p>
<p style="font-size: 12px; margin: 4px 0 0 0; color: #8c8c8c;">
点击或拖拽上传图片 (PNG/JPEG/GIF/WebP)
</p>
</a-upload-dragger>
<div v-if="chatImageList.length > 0" style="margin-top: 8px; display: flex; flex-wrap: wrap; gap: 4px;">
<a-tag v-for="img in chatImageList" :key="img.uid" closable
@close="chatImageList = chatImageList.filter(i => i.uid !== img.uid)">
<PictureOutlined /> {{ img.name.slice(0, 15) }}{{ img.name.length > 15 ? '...' : '' }}
</a-tag>
</div>
</div>
<!-- 流式选项 -->
<div style="margin-bottom: 12px;">
<a-checkbox v-model:checked="chatStreamMode">流式响应</a-checkbox>
</div>
<!-- 测试结果 -->
<!-- 流式模式实时显示内容 -->
<div v-if="chatStreamMode && apiTestResults.chat.status === 'loading'"
style="background: #fafafa; padding: 12px; border-radius: 4px; font-size: 12px; max-height: 400px; overflow-y: auto;">
<div style="color: #1890ff; margin-bottom: 8px;">
<LoadingOutlined /> 正在接收流式响应...
</div>
<!-- 文本内容 -->
<pre v-if="parseMarkdownImages(chatStreamContent).text"
style="white-space: pre-wrap; word-break: break-all; margin: 0 0 8px 0; min-height: 50px;">{{
parseMarkdownImages(chatStreamContent).text || '等待内容...' }}</pre>
<!-- 图片展示 -->
<div v-if="parseMarkdownImages(chatStreamContent).images.length > 0"
style="display: flex; flex-direction: column; gap: 8px;">
<div v-for="(img, index) in parseMarkdownImages(chatStreamContent).images" :key="index"
style="border: 1px solid #d9d9d9; border-radius: 4px; padding: 4px; background: white;">
<div style="font-size: 11px; color: #8c8c8c; margin-bottom: 4px;">{{ img.alt }}</div>
<img :src="img.src" :alt="img.alt"
style="max-width: 100%; height: auto; display: block; border-radius: 2px;" />
</div>
</div>
</div>
<div v-else-if="apiTestResults.chat.status === 'success'">
<a-tag color="success">
<CheckCircleOutlined /> 成功
</a-tag>
<div
style="margin-top: 8px; font-size: 12px; max-height: 400px; overflow-y: auto; background: #fafafa; padding: 8px; border-radius: 4px;">
<!-- 文本内容 -->
<pre v-if="parseMarkdownImages(apiTestResults.chat.data?.content).text"
style="white-space: pre-wrap; word-break: break-all; margin: 0 0 8px 0;">{{
parseMarkdownImages(apiTestResults.chat.data?.content).text }}</pre>
<!-- 图片展示 -->
<div v-if="parseMarkdownImages(apiTestResults.chat.data?.content).images.length > 0"
style="display: flex; flex-direction: column; gap: 8px;">
<div v-for="(img, index) in parseMarkdownImages(apiTestResults.chat.data?.content).images" :key="index"
style="border: 1px solid #d9d9d9; border-radius: 4px; padding: 4px; background: white;">
<div style="font-size: 11px; color: #8c8c8c; margin-bottom: 4px;">{{ img.alt }}</div>
<img :src="img.src" :alt="img.alt"
style="max-width: 100%; height: auto; display: block; border-radius: 2px;" />
</div>
</div>
</div>
</div>
<div v-else-if="apiTestResults.chat.status === 'error'">
<a-tag color="error">
<CloseCircleOutlined /> 失败
</a-tag>
<div style="margin-top: 8px; font-size: 12px; color: #ff4d4f;">
{{ apiTestResults.chat.error }}
</div>
</div>
<div v-else-if="apiTestResults.chat.status === 'loading' && !chatStreamMode"
style="color: #1890ff; font-size: 12px;">
<LoadingOutlined /> 请求中可能需要较长时间...
</div>
</a-card>
</a-space>
</a-drawer>
</div>
</template>
<style scoped>
/* 滚动条美化 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 3px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
</style>