| | <!DOCTYPE html> |
| | <html> |
| | <head> |
| | <title>代理端点管理</title> |
| | <style> |
| | body { |
| | font-family: Arial, sans-serif; |
| | max-width: 1200px; |
| | margin: 20px auto; |
| | padding: 0 20px; |
| | background-color: #f5f5f5; |
| | } |
| | .endpoint-group { |
| | margin: 20px 0; |
| | padding: 15px; |
| | border: 1px solid #ddd; |
| | border-radius: 8px; |
| | background-color: white; |
| | box-shadow: 0 2px 4px rgba(0,0,0,0.1); |
| | } |
| | .endpoint { |
| | display: flex; |
| | align-items: center; |
| | margin: 10px 0; |
| | padding: 10px; |
| | border: 1px solid #eee; |
| | border-radius: 4px; |
| | background-color: #f9f9f9; |
| | } |
| | .endpoint.frozen { |
| | background-color: #fff3e0; |
| | border: 1px solid #ffe0b2; |
| | } |
| | .endpoint input[type="text"] { |
| | flex: 1; |
| | margin-right: 10px; |
| | padding: 8px; |
| | border: 1px solid #ddd; |
| | border-radius: 4px; |
| | } |
| | .endpoint input[type="number"] { |
| | width: 80px; |
| | margin: 0 10px; |
| | padding: 8px; |
| | border: 1px solid #ddd; |
| | border-radius: 4px; |
| | } |
| | .endpoint input[type="checkbox"] { |
| | margin: 0 10px; |
| | transform: scale(1.2); |
| | } |
| | .channel-input { |
| | width: 120px !important; |
| | } |
| | .endpoint-status { |
| | margin-left: 10px; |
| | font-size: 0.9em; |
| | color: #666; |
| | } |
| | .error-count { |
| | color: #f57c00; |
| | margin-left: 10px; |
| | font-size: 0.9em; |
| | } |
| | .frozen-until { |
| | color: #1976d2; |
| | margin-left: 10px; |
| | font-size: 0.9em; |
| | } |
| | button { |
| | padding: 8px 15px; |
| | margin: 5px; |
| | cursor: pointer; |
| | border: none; |
| | border-radius: 4px; |
| | background-color: #4CAF50; |
| | color: white; |
| | transition: background-color 0.3s; |
| | } |
| | button:hover { |
| | background-color: #45a049; |
| | } |
| | button.delete { |
| | background-color: #f44336; |
| | } |
| | button.delete:hover { |
| | background-color: #da190b; |
| | } |
| | .status { |
| | position: fixed; |
| | top: 20px; |
| | right: 20px; |
| | padding: 10px 20px; |
| | border-radius: 4px; |
| | display: none; |
| | z-index: 1000; |
| | } |
| | .status.success { |
| | background-color: #4CAF50; |
| | color: white; |
| | } |
| | .status.error { |
| | background-color: #f44336; |
| | color: white; |
| | } |
| | .model-list { |
| | display: grid; |
| | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); |
| | gap: 10px; |
| | margin-top: 10px; |
| | } |
| | .model-item { |
| | padding: 10px; |
| | background-color: #f9f9f9; |
| | border-radius: 4px; |
| | border: 1px solid #ddd; |
| | } |
| | .controls { |
| | position: fixed; |
| | bottom: 20px; |
| | right: 20px; |
| | display: flex; |
| | gap: 10px; |
| | } |
| | .refresh-button { |
| | background-color: #2196F3; |
| | } |
| | .refresh-button:hover { |
| | background-color: #1976D2; |
| | } |
| | .settings { |
| | display: grid; |
| | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
| | gap: 10px; |
| | padding: 15px; |
| | background-color: #fff; |
| | border-radius: 4px; |
| | margin: 10px 0; |
| | } |
| | .setting-item { |
| | display: flex; |
| | flex-direction: column; |
| | gap: 5px; |
| | } |
| | .setting-item label { |
| | font-weight: bold; |
| | color: #333; |
| | } |
| | .model-rename { |
| | display: flex; |
| | align-items: center; |
| | gap: 5px; |
| | margin-top: 5px; |
| | } |
| | .model-rename input { |
| | flex: 1; |
| | padding: 4px 8px; |
| | border: 1px solid #ddd; |
| | border-radius: 4px; |
| | } |
| | .model-info { |
| | margin-bottom: 5px; |
| | } |
| | .model-channel { |
| | color: #666; |
| | font-size: 0.9em; |
| | } |
| | </style> |
| | </head> |
| | <body> |
| | <h1>代理端点管理</h1> |
| |
|
| | <div class="endpoint-group"> |
| | <h2>全局设置</h2> |
| | <div class="settings"> |
| | <div class="setting-item"> |
| | <label for="frozenDuration">冷却时间(分钟)</label> |
| | <input type="number" id="frozenDuration" min="1" value="3"> |
| | </div> |
| | <div class="setting-item"> |
| | <label for="maxRetries">最大重试次数</label> |
| | <input type="number" id="maxRetries" min="1" value="3"> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | <div class="endpoint-group"> |
| | <h2>Models 端点</h2> |
| | <div id="modelsEndpoints"></div> |
| | <button onclick="addEndpoint('models')">添加 Models 端点</button> |
| | <button class="refresh-button" onclick="refreshEndpoints('models')">刷新状态</button> |
| | <button onclick="updateAllModels()">更新所有模型信息</button> |
| | </div> |
| |
|
| | <div class="endpoint-group"> |
| | <h2>Chat 端点</h2> |
| | <div id="chatEndpoints"></div> |
| | <button onclick="addEndpoint('chat')">添加 Chat 端点</button> |
| | <button class="refresh-button" onclick="refreshEndpoints('chat')">刷新状态</button> |
| | </div> |
| |
|
| | <div class="endpoint-group"> |
| | <h2>模型列表</h2> |
| | <div class="settings"> |
| | <div class="setting-item"> |
| | <label>默认模型名称格式</label> |
| | <select id="modelNameFormat" onchange="updateModelNameFormat()"> |
| | <option value="original">原始名称</option> |
| | <option value="lowercase">全小写</option> |
| | <option value="uppercase">全大写</option> |
| | <option value="custom">自定义格式</option> |
| | </select> |
| | </div> |
| | </div> |
| | <div id="modelList" class="model-list"></div> |
| | </div> |
| |
|
| | <div class="endpoint-group"> |
| | <h2>IP 黑名单</h2> |
| | <div class="endpoint"> |
| | <input type="text" id="ipInput" placeholder="输入要封禁的IP地址"> |
| | <button onclick="addToBlacklist(document.getElementById('ipInput').value)">添加到黑名单</button> |
| | </div> |
| | <div id="blacklistEntries"></div> |
| | </div> |
| |
|
| | <div class="controls"> |
| | <button onclick="saveConfig()">保存所有配置</button> |
| | <button onclick="logout()" style="background-color: #f44336;">退出登录</button> |
| | </div> |
| |
|
| | <div id="status" class="status"></div> |
| |
|
| | <script> |
| | function showStatus(message, isError = false) { |
| | const status = document.getElementById('status'); |
| | status.textContent = message; |
| | status.className = 'status ' + (isError ? 'error' : 'success'); |
| | status.style.display = 'block'; |
| | setTimeout(() => status.style.display = 'none', 3000); |
| | } |
| | |
| | async function fetchWithAuth(url, options = {}) { |
| | const apiKey = localStorage.getItem('apiKey'); |
| | if (!apiKey) { |
| | const key = prompt('请输入API密钥:'); |
| | if (!key) return null; |
| | localStorage.setItem('apiKey', key); |
| | } |
| | |
| | options.headers = { |
| | ...options.headers, |
| | 'Authorization': localStorage.getItem('apiKey') |
| | }; |
| | |
| | try { |
| | const response = await fetch(url, options); |
| | if (response.status === 401) { |
| | localStorage.removeItem('apiKey'); |
| | showStatus('认证失败,请重新登录', true); |
| | return null; |
| | } |
| | return response; |
| | } catch (error) { |
| | showStatus('请求失败: ' + error.message, true); |
| | return null; |
| | } |
| | } |
| | |
| | function addEndpoint(type, endpoint = null) { |
| | const container = document.getElementById(type + 'Endpoints'); |
| | const div = document.createElement('div'); |
| | |
| | |
| | const now = new Date(); |
| | const frozenUntil = endpoint?.frozen_until ? new Date(endpoint.frozen_until) : null; |
| | const isFrozen = frozenUntil && frozenUntil > now; |
| | |
| | div.className = 'endpoint' + (isFrozen ? ' frozen' : ''); |
| | |
| | |
| | let statusInfo = ''; |
| | if (frozenUntil && frozenUntil > now) { |
| | statusInfo += `<span class="frozen-until">冻结至: ${frozenUntil.toLocaleTimeString()}</span>`; |
| | } |
| | if (endpoint?.error_count > 0) { |
| | statusInfo += `<span class="error-count">错误次数: ${endpoint.error_count}</span>`; |
| | } |
| | |
| | div.innerHTML = ` |
| | <div class="endpoint-inputs"> |
| | <input type="text" placeholder="输入端点URL" value="${endpoint?.url || ''}" class="url-input"> |
| | <input type="number" placeholder="权重" value="${endpoint?.weight || 1}" min="1" class="weight-input"> |
| | <input type="text" placeholder="渠道分类" value="${endpoint?.channel || ''}" class="channel-input"> |
| | <label> |
| | <input type="checkbox" ${endpoint?.enabled ? 'checked' : ''} class="enabled-input"> |
| | 启用 |
| | </label> |
| | </div> |
| | <div class="endpoint-status-info"> |
| | ${statusInfo} |
| | </div> |
| | <button class="delete" onclick="this.parentElement.remove()">删除</button> |
| | `; |
| | |
| | container.appendChild(div); |
| | } |
| | |
| | async function saveConfig() { |
| | const config = { |
| | models: getEndpointsConfig('models'), |
| | chat: getEndpointsConfig('chat'), |
| | frozen_duration: parseInt(document.getElementById('frozenDuration').value), |
| | max_retries: parseInt(document.getElementById('maxRetries').value) |
| | }; |
| | |
| | try { |
| | const response = await fetchWithAuth('/admin/config', { |
| | method: 'POST', |
| | headers: {'Content-Type': 'application/json'}, |
| | body: JSON.stringify(config) |
| | }); |
| | |
| | if (response && response.ok) { |
| | showStatus('配置已保存'); |
| | await loadModelList(); |
| | } |
| | } catch (error) { |
| | showStatus('保存失败: ' + error.message, true); |
| | } |
| | } |
| | |
| | function getEndpointsConfig(type) { |
| | const endpoints = []; |
| | document.querySelectorAll(`#${type}Endpoints .endpoint`).forEach(el => { |
| | endpoints.push({ |
| | url: el.querySelector('.url-input').value.trim(), |
| | weight: parseInt(el.querySelector('.weight-input').value) || 1, |
| | enabled: el.querySelector('.enabled-input').checked, |
| | channel: el.querySelector('.channel-input').value.trim() |
| | }); |
| | }); |
| | return endpoints; |
| | } |
| | |
| | async function refreshEndpoints(type) { |
| | try { |
| | const response = await fetchWithAuth('/admin/status'); |
| | if (!response) return; |
| | |
| | const status = await response.json(); |
| | if (!status || !status[type] || !status[type].endpoints) { |
| | showStatus('获取端点状态失败', true); |
| | return; |
| | } |
| | |
| | const container = document.getElementById(type + 'Endpoints'); |
| | container.innerHTML = ''; |
| | |
| | |
| | status[type].endpoints.forEach(ep => { |
| | const div = document.createElement('div'); |
| | div.className = 'endpoint'; |
| | |
| | |
| | const now = new Date(); |
| | const frozenUntil = ep.frozen_until ? new Date(ep.frozen_until) : null; |
| | const isFrozen = frozenUntil && frozenUntil > now; |
| | |
| | if (isFrozen) { |
| | div.classList.add('frozen'); |
| | } |
| | |
| | |
| | let statusInfo = ''; |
| | if (ep.error_count > 0) { |
| | statusInfo += `<span class="error-count">错误次数: ${ep.error_count}</span>`; |
| | } |
| | if (isFrozen) { |
| | statusInfo += `<span class="frozen-until">冻结至: ${frozenUntil.toLocaleTimeString()}</span>`; |
| | } |
| | |
| | div.innerHTML = ` |
| | <input type="text" placeholder="输入端点URL" value="${ep.url}" class="url-input"> |
| | <input type="number" placeholder="权重" value="${ep.weight || 1}" min="1" class="weight-input"> |
| | <input type="text" placeholder="渠道分类" value="${ep.channel || ''}" class="channel-input"> |
| | <label> |
| | <input type="checkbox" ${ep.enabled ? 'checked' : ''} class="enabled-input"> |
| | 启用 |
| | </label> |
| | ${statusInfo} |
| | <button class="delete" onclick="this.parentElement.remove()">删除</button> |
| | `; |
| | container.appendChild(div); |
| | }); |
| | |
| | showStatus(`${type}端点状态已更新`, false); |
| | } catch (error) { |
| | showStatus('刷新状态失败: ' + error.message, true); |
| | } |
| | } |
| | |
| | |
| | async function loadConfig() { |
| | try { |
| | const response = await fetchWithAuth('/admin/config'); |
| | if (!response) return; |
| | |
| | const config = await response.json(); |
| | |
| | |
| | document.getElementById('modelsEndpoints').innerHTML = ''; |
| | document.getElementById('chatEndpoints').innerHTML = ''; |
| | |
| | |
| | if (Array.isArray(config.models)) { |
| | config.models.forEach(ep => addEndpoint('models', ep)); |
| | } |
| | if (Array.isArray(config.chat)) { |
| | config.chat.forEach(ep => addEndpoint('chat', ep)); |
| | } |
| | |
| | |
| | document.getElementById('frozenDuration').value = config.frozen_duration || 3; |
| | document.getElementById('maxRetries').value = config.max_retries || 3; |
| | |
| | await loadBlacklist(); |
| | await loadModelList(); |
| | await loadModelMappings(); |
| | |
| | showStatus('配置已加载', false); |
| | } catch (error) { |
| | showStatus('加载配置失败: ' + error.message, true); |
| | } |
| | } |
| | |
| | async function loadBlacklist() { |
| | try { |
| | const response = await fetchWithAuth('/admin/blacklist'); |
| | if (!response) return; |
| | |
| | const blacklist = await response.json(); |
| | const container = document.getElementById('blacklistEntries'); |
| | container.innerHTML = ''; |
| | |
| | blacklist.forEach(ip => { |
| | const div = document.createElement('div'); |
| | div.className = 'endpoint'; |
| | div.innerHTML = ` |
| | <span>${ip}</span> |
| | <button class="delete" onclick="removeFromBlacklist('${ip}')">解除封禁</button> |
| | `; |
| | container.appendChild(div); |
| | }); |
| | } catch (error) { |
| | showStatus('加载黑名单失败: ' + error.message, true); |
| | } |
| | } |
| | |
| | async function addToBlacklist(ip) { |
| | if (!ip) return; |
| | |
| | try { |
| | const response = await fetchWithAuth('/admin/blacklist', { |
| | method: 'POST', |
| | headers: {'Content-Type': 'application/json'}, |
| | body: JSON.stringify({ip: ip, action: 'add'}) |
| | }); |
| | |
| | if (response && response.ok) { |
| | showStatus('IP已添加到黑名单'); |
| | document.getElementById('ipInput').value = ''; |
| | await loadBlacklist(); |
| | } |
| | } catch (error) { |
| | showStatus('添加失败: ' + error.message, true); |
| | } |
| | } |
| | |
| | async function removeFromBlacklist(ip) { |
| | try { |
| | const response = await fetchWithAuth('/admin/blacklist', { |
| | method: 'POST', |
| | headers: {'Content-Type': 'application/json'}, |
| | body: JSON.stringify({ip: ip, action: 'remove'}) |
| | }); |
| | |
| | if (response && response.ok) { |
| | showStatus('IP已从黑名单移除'); |
| | await loadBlacklist(); |
| | } |
| | } catch (error) { |
| | showStatus('移除失败: ' + error.message, true); |
| | } |
| | } |
| | |
| | async function updateAllModels() { |
| | try { |
| | const response = await fetchWithAuth('/admin/update-models', { |
| | method: 'POST' |
| | }); |
| | |
| | if (response && response.ok) { |
| | showStatus('模型信息已更新'); |
| | await loadModelList(); |
| | } |
| | } catch (error) { |
| | showStatus('更新失败: ' + error.message, true); |
| | } |
| | } |
| | |
| | async function loadModelList() { |
| | try { |
| | const response = await fetchWithAuth('/v1/models'); |
| | if (!response) return; |
| | |
| | const data = await response.json(); |
| | const container = document.getElementById('modelList'); |
| | container.innerHTML = ''; |
| | |
| | data.data.forEach(model => { |
| | const div = document.createElement('div'); |
| | div.className = 'model-item'; |
| | div.innerHTML = ` |
| | <div class="model-info"> |
| | <strong>${model.id}</strong> |
| | <span class="model-channel">[${model.owned_by.replace('-adapter', '')}]</span> |
| | </div> |
| | <div class="model-rename"> |
| | <input type="text" |
| | value="${model.id}" |
| | placeholder="输入新的模型名称" |
| | class="model-name-input" |
| | data-original-id="${model.id}"> |
| | <button onclick="saveModelMapping('${model.id}', this.parentElement)">保存</button> |
| | <button class="delete" onclick="resetModelMapping('${model.id}', this.parentElement)">重置</button> |
| | </div> |
| | `; |
| | container.appendChild(div); |
| | }); |
| | } catch (error) { |
| | showStatus('加载模型列表失败: ' + error.message, true); |
| | } |
| | } |
| | |
| | async function saveModelMapping(originalId, element) { |
| | const input = element.querySelector('.model-name-input'); |
| | const newName = input.value.trim(); |
| | |
| | if (!newName) { |
| | showStatus('模型名称不能为空', true); |
| | return; |
| | } |
| | |
| | try { |
| | const response = await fetchWithAuth('/admin/model-mapping', { |
| | method: 'POST', |
| | headers: {'Content-Type': 'application/json'}, |
| | body: JSON.stringify({ |
| | original_id: originalId, |
| | mapped_name: newName |
| | }) |
| | }); |
| | |
| | if (response && response.ok) { |
| | showStatus('模型名称映射已保存'); |
| | await loadModelList(); |
| | } |
| | } catch (error) { |
| | showStatus('保存失败: ' + error.message, true); |
| | } |
| | } |
| | |
| | async function resetModelMapping(originalId, element) { |
| | try { |
| | const response = await fetchWithAuth('/admin/model-mapping', { |
| | method: 'POST', |
| | headers: {'Content-Type': 'application/json'}, |
| | body: JSON.stringify({ |
| | original_id: originalId, |
| | mapped_name: originalId |
| | }) |
| | }); |
| | |
| | if (response && response.ok) { |
| | showStatus('模型名称已重置'); |
| | await loadModelList(); |
| | } |
| | } catch (error) { |
| | showStatus('重置失败: ' + error.message, true); |
| | } |
| | } |
| | |
| | async function updateModelNameFormat() { |
| | const format = document.getElementById('modelNameFormat').value; |
| | const modelItems = document.querySelectorAll('.model-name-input'); |
| | |
| | modelItems.forEach(input => { |
| | const originalId = input.dataset.originalId; |
| | let newName = originalId; |
| | |
| | switch(format) { |
| | case 'lowercase': |
| | newName = originalId.toLowerCase(); |
| | break; |
| | case 'uppercase': |
| | newName = originalId.toUpperCase(); |
| | break; |
| | case 'custom': |
| | |
| | break; |
| | default: |
| | newName = originalId; |
| | } |
| | |
| | input.value = newName; |
| | }); |
| | } |
| | |
| | async function loadModelMappings() { |
| | try { |
| | const response = await fetchWithAuth('/admin/model-mappings'); |
| | if (response && response.ok) { |
| | const mappings = await response.json(); |
| | |
| | Object.entries(mappings).forEach(([mappedName, originalId]) => { |
| | const input = document.querySelector(`.model-name-input[data-original-id="${originalId}"]`); |
| | if (input) { |
| | input.value = mappedName; |
| | } |
| | }); |
| | } |
| | } catch (error) { |
| | showStatus('加载模型映射失败: ' + error.message, true); |
| | } |
| | } |
| | |
| | function logout() { |
| | localStorage.removeItem('apiKey'); |
| | window.location.reload(); |
| | } |
| | |
| | document.addEventListener('DOMContentLoaded', loadConfig); |
| | </script> |
| | </body> |
| | </html> |