Files
cursor-api/static/proxies.html
2025-07-27 09:04:19 +08:00

2014 lines
53 KiB
HTML
Raw Permalink 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.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="data:image/x-icon;," />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>代理信息管理</title>
<!-- 引入共享样式 -->
<link rel="stylesheet" href="/static/shared-styles.css" />
<script src="/static/shared.js"></script>
<style>
/* 代理列表布局样式 */
.proxy-system {
background: var(--card-background);
border-radius: var(--border-radius);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-top: var(--spacing);
overflow: hidden;
height: calc(100vh - 250px);
display: flex;
flex-direction: column;
}
.toolbar {
display: flex;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
background: var(--card-background);
gap: 8px;
}
.toolbar .search-box {
flex: 1;
position: relative;
}
.toolbar .search-box input {
width: 100%;
padding-left: 36px;
padding-right: 36px;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-secondary);
pointer-events: none;
}
.clear-search {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
display: none;
min-height: auto;
transition: all var(--transition-fast);
}
.clear-search:hover {
background: var(--primary-color-alpha);
color: var(--text-primary);
}
.clear-search.show {
display: block;
}
.proxy-list {
flex: 1;
overflow: auto;
position: relative;
user-select: none;
background: var(--card-background);
}
/* 表格滚动时的阴影效果 */
.proxy-list.scrolled .proxy-table thead {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.proxy-table {
width: 100%;
border-collapse: collapse;
margin: 0;
}
.proxy-table thead {
position: sticky;
top: 0;
z-index: 10;
background: var(--card-background);
transition: box-shadow var(--transition-fast);
}
.proxy-table th {
padding: 12px 16px;
text-align: left;
font-weight: 500;
border-bottom: 2px solid var(--border-color);
color: var(--text-primary);
white-space: nowrap;
}
.proxy-table td {
padding: 10px 16px;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
}
.proxy-table tbody tr {
cursor: pointer;
transition: background-color var(--transition-fast);
}
.proxy-table tbody tr:hover {
background: var(--primary-color-alpha);
}
.proxy-table tbody tr.selected {
background: var(--primary-color-alpha);
position: relative;
}
.proxy-table tbody tr.selected td:first-child {
position: relative;
padding-left: 20px;
}
.proxy-table tbody tr.selected td:first-child::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--primary-color);
}
.proxy-table tbody tr.general {
font-weight: 600;
}
.proxy-table tbody tr.general .proxy-name::after {
content: " ⭐";
color: var(--primary-color);
}
/* 代理类型图标 */
.proxy-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin-right: 8px;
font-size: 16px;
}
/* 空状态样式 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--text-secondary);
}
.empty-state-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state h3 {
margin: 0 0 8px 0;
color: var(--text-primary);
}
.empty-state p {
margin: 0 0 24px 0;
}
/* 状态栏 */
.status-bar {
padding: 8px 16px;
background: var(--card-background);
border-top: 1px solid var(--border-color);
color: var(--text-secondary);
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-info {
display: flex;
gap: 16px;
}
/* 右键菜单 */
.context-menu {
position: fixed;
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
z-index: 1000;
min-width: 200px;
display: none;
animation: contextMenuIn 0.15s ease-out;
}
@keyframes contextMenuIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.context-menu-item {
padding: 8px 16px;
cursor: pointer;
transition: all var(--transition-fast);
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-primary);
}
.context-menu-item:hover,
.context-menu-item:focus {
background: var(--primary-color-alpha);
outline: none;
}
.context-menu-item.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.context-menu-item.disabled:hover {
background: transparent;
}
.context-menu-divider {
height: 1px;
background: var(--border-color);
margin: 4px 0;
}
.context-menu-shortcut {
margin-left: auto;
padding-left: 16px;
color: var(--text-secondary);
font-size: 12px;
opacity: 0.7;
}
/* 筛选面板 */
.filter-panel {
padding: 16px;
background: var(--card-background);
border-radius: var(--border-radius);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-top: var(--spacing);
}
.filter-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.filter-group {
flex: 1;
min-width: 250px;
}
/* 多选下拉框样式 */
.multiselect {
position: relative;
width: 100%;
}
.multiselect-dropdown {
width: 100%;
padding: 8px 12px;
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
min-height: 44px;
transition: all var(--transition-fast);
}
.multiselect-dropdown:hover {
border-color: var(--primary-color);
}
.multiselect-dropdown.active {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color-alpha);
}
.multiselect-dropdown::after {
content: "▼";
font-size: 12px;
color: var(--text-secondary);
transition: transform var(--transition-fast);
}
.multiselect-dropdown.active::after {
transform: rotate(180deg);
}
.multiselect-options {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 100;
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-height: 240px;
overflow-y: auto;
display: none;
animation: dropdownIn 0.2s ease-out;
}
@keyframes dropdownIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.multiselect-options.show {
display: block;
}
.multiselect-option {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
transition: background-color var(--transition-fast);
}
.multiselect-option:hover {
background: var(--primary-color-alpha);
}
.multiselect-option input[type="checkbox"] {
margin-right: 8px;
pointer-events: none;
}
.multiselect-option label {
margin: 0;
cursor: pointer;
user-select: none;
flex: 1;
}
.selected-options {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1;
}
.selected-option {
background: var(--primary-color-alpha);
color: var(--primary-color);
padding: 2px 8px;
border-radius: 4px;
display: inline-flex;
align-items: center;
font-size: 13px;
font-weight: 500;
}
.selected-options-placeholder {
color: var(--text-secondary);
}
/* 对话框样式 */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: none;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.9);
background: var(--card-background);
padding: 24px;
border-radius: var(--border-radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 1000;
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
display: none;
animation: modalIn 0.2s ease-out forwards;
}
@keyframes modalIn {
to {
transform: translate(-50%, -50%) scale(1);
}
}
.modal-header {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 20px;
}
.modal-close {
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 24px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all var(--transition-fast);
}
.modal-close:hover {
background: var(--primary-color-alpha);
color: var(--text-primary);
}
.modal-body {
margin-bottom: 20px;
}
.modal-footer {
padding-top: 16px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* 加载骨架屏 */
.skeleton {
animation: skeleton-loading 1s linear infinite alternate;
}
@keyframes skeleton-loading {
0% {
background-color: var(--border-color);
}
100% {
background-color: var(--disabled-bg);
}
}
.skeleton-row {
height: 48px;
margin-bottom: 1px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.filter-row {
flex-direction: column;
}
.filter-group {
min-width: 100%;
}
.proxy-table {
font-size: 14px;
}
.proxy-table th,
.proxy-table td {
padding: 8px 12px;
}
/* 移动端表格优化 */
.proxy-table thead th:nth-child(3) {
display: none;
}
.proxy-table tbody td:nth-child(3) {
display: none;
}
.status-info {
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.modal {
width: 95%;
padding: 16px;
}
}
/* 高对比度模式支持 */
@media (prefers-contrast: high) {
.proxy-table tbody tr.selected td:first-child::before {
width: 5px;
}
.context-menu,
.multiselect-options {
border-width: 2px;
}
}
/* 减少动画模式 */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
<h1>代理信息管理</h1>
<div class="container">
<div class="form-group">
<label for="authToken">认证令牌:</label>
<input
type="password"
id="authToken"
placeholder="输入 AUTH_TOKEN"
aria-label="认证令牌"
/>
</div>
</div>
<!-- 筛选面板 -->
<div class="filter-panel">
<div class="filter-row">
<div class="filter-group">
<label for="proxyTypeFilter">代理类型:</label>
<div class="multiselect" id="proxyTypeFilter">
<div
class="multiselect-dropdown"
role="button"
tabindex="0"
aria-haspopup="listbox"
aria-expanded="false"
>
<div class="selected-options" id="selectedProxyTypes">
<span class="selected-options-placeholder"
>请选择代理类型...</span
>
</div>
</div>
<div
class="multiselect-options"
role="listbox"
aria-multiselectable="true"
>
<div class="multiselect-option" role="option" data-value="non">
<input type="checkbox" id="type-non" value="non" checked />
<label for="type-non">不使用代理</label>
</div>
<div class="multiselect-option" role="option" data-value="sys">
<input type="checkbox" id="type-sys" value="sys" checked />
<label for="type-sys">系统代理</label>
</div>
<div class="multiselect-option" role="option" data-value="http">
<input type="checkbox" id="type-http" value="http" checked />
<label for="type-http">HTTP</label>
</div>
<div class="multiselect-option" role="option" data-value="https">
<input type="checkbox" id="type-https" value="https" checked />
<label for="type-https">HTTPS</label>
</div>
<div class="multiselect-option" role="option" data-value="socks4">
<input
type="checkbox"
id="type-socks4"
value="socks4"
checked
/>
<label for="type-socks4">SOCKS4</label>
</div>
<div class="multiselect-option" role="option" data-value="socks5">
<input
type="checkbox"
id="type-socks5"
value="socks5"
checked
/>
<label for="type-socks5">SOCKS5</label>
</div>
<div
class="multiselect-option"
role="option"
data-value="socks5h"
>
<input
type="checkbox"
id="type-socks5h"
value="socks5h"
checked
/>
<label for="type-socks5h">SOCKS5H</label>
</div>
</div>
</div>
</div>
<div class="filter-group">
<label class="visually-hidden">操作按钮</label>
<div class="button-group">
<button
id="refreshBtn"
onclick="getProxyInfo()"
class="primary"
title="刷新代理列表 (F5)"
>
<span>刷新列表</span>
<span class="context-menu-shortcut">F5</span>
</button>
<button
id="addProxyBtn"
onclick="showAddProxyModal()"
class="secondary"
title="添加新代理"
>
添加代理
</button>
<button
id="exportBtn"
onclick="exportProxies()"
class="secondary"
title="导出代理配置"
>
导出配置
</button>
<button
id="importBtn"
onclick="showImportModal()"
class="secondary"
title="导入代理配置"
>
导入配置
</button>
</div>
</div>
</div>
</div>
<!-- 代理列表区域 -->
<div class="proxy-system">
<div class="toolbar">
<div class="search-box">
<span class="search-icon">🔍</span>
<input
type="text"
id="searchInput"
placeholder="搜索代理名称..."
aria-label="搜索代理"
/>
<button
class="clear-search"
id="clearSearch"
onclick="clearSearchInput()"
aria-label="清除搜索"
>
</button>
</div>
</div>
<div class="proxy-list" id="proxyListContainer">
<table class="proxy-table" role="table">
<thead>
<tr role="row">
<th role="columnheader" style="width: 30%">代理名称</th>
<th role="columnheader" style="width: 25%">代理类型</th>
<th role="columnheader" style="width: 45%">代理地址</th>
</tr>
</thead>
<tbody id="proxyListBody" role="rowgroup">
<!-- 动态生成的代理列表 -->
</tbody>
</table>
<div id="emptyState" class="empty-state" style="display: none">
<div class="empty-state-icon">🌐</div>
<h3>没有找到代理</h3>
<p>请添加代理或更改筛选条件</p>
<button onclick="showAddProxyModal()" class="primary">
添加代理
</button>
</div>
<div id="loadingState" class="skeleton" style="display: none">
<div class="skeleton-row skeleton"></div>
<div class="skeleton-row skeleton"></div>
<div class="skeleton-row skeleton"></div>
</div>
</div>
<div class="status-bar">
<div class="status-info">
<span id="selectionStatus">已选择: 0 个</span>
<span id="totalCount">共 0 个代理</span>
</div>
</div>
</div>
<!-- 右键菜单 -->
<div id="contextMenu" class="context-menu" role="menu">
<div
class="context-menu-item"
onclick="setAsGeneral()"
role="menuitem"
tabindex="0"
>
<span>设为通用代理</span>
<span class="context-menu-shortcut">Ctrl+G</span>
</div>
<div
class="context-menu-item"
onclick="copyProxyUrl()"
role="menuitem"
tabindex="0"
>
<span>复制代理地址</span>
<span class="context-menu-shortcut">Ctrl+C</span>
</div>
<div class="context-menu-divider" role="separator"></div>
<div
class="context-menu-item"
onclick="deleteSelectedProxies()"
role="menuitem"
tabindex="0"
>
<span>删除</span>
<span class="context-menu-shortcut">Delete</span>
</div>
</div>
<!-- 添加代理对话框 -->
<div
class="modal-backdrop"
id="addProxyModal-backdrop"
onclick="closeModal('addProxyModal')"
></div>
<div
class="modal"
id="addProxyModal"
role="dialog"
aria-labelledby="addProxyTitle"
aria-modal="true"
>
<div class="modal-header">
<h3 id="addProxyTitle">添加代理</h3>
<button
class="modal-close"
onclick="closeModal('addProxyModal')"
aria-label="关闭对话框"
>
×
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="proxyName">代理名称:</label>
<input
type="text"
id="proxyName"
placeholder="输入代理名称"
required
/>
</div>
<div class="form-group">
<label for="proxyType">代理类型:</label>
<select id="proxyType" onchange="toggleProxyUrl()">
<option value="non">不使用代理</option>
<option value="sys">系统代理</option>
<option value="custom" selected>自定义代理</option>
</select>
</div>
<div class="form-group" id="proxyUrlGroup">
<label for="proxyUrl">代理地址:</label>
<input
type="text"
id="proxyUrl"
placeholder="例如: http://localhost:7890"
/>
<div class="help-text">
支持的格式: http://localhost:7890,
socks5://username:password@localhost:1080
</div>
</div>
</div>
<div class="modal-footer">
<button onclick="closeModal('addProxyModal')" class="secondary">
取消
</button>
<button onclick="confirmAddProxy()" class="primary">添加</button>
</div>
</div>
<!-- 确认删除对话框 -->
<div
class="modal-backdrop"
id="confirmModal-backdrop"
onclick="closeModal('confirmModal')"
></div>
<div
class="modal"
id="confirmModal"
role="dialog"
aria-labelledby="confirmTitle"
aria-modal="true"
>
<div class="modal-header">
<h3 id="confirmTitle">确认删除</h3>
<button
class="modal-close"
onclick="closeModal('confirmModal')"
aria-label="关闭对话框"
>
×
</button>
</div>
<div class="modal-body">
<p id="confirmMessage">确定要删除选中的代理吗?</p>
</div>
<div class="modal-footer">
<button onclick="closeModal('confirmModal')" class="secondary">
取消
</button>
<button onclick="confirmDeleteProxies()" class="danger">删除</button>
</div>
</div>
<!-- 导入对话框 -->
<div
class="modal-backdrop"
id="importModal-backdrop"
onclick="closeModal('importModal')"
></div>
<div
class="modal"
id="importModal"
role="dialog"
aria-labelledby="importTitle"
aria-modal="true"
>
<div class="modal-header">
<h3 id="importTitle">导入代理配置</h3>
<button
class="modal-close"
onclick="closeModal('importModal')"
aria-label="关闭对话框"
>
×
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="importFile">选择JSON文件:</label>
<input
type="file"
id="importFile"
accept=".json"
onchange="previewImportFile()"
/>
</div>
<div id="importPreview" style="display: none; margin-top: 16px">
<h4>文件预览:</h4>
<pre
id="importPreviewContent"
style="
background: var(--disabled-bg);
padding: 12px;
border-radius: 4px;
overflow: auto;
max-height: 200px;
"
></pre>
</div>
</div>
<div class="modal-footer">
<button onclick="closeModal('importModal')" class="secondary">
取消
</button>
<button
id="confirmImportBtn"
onclick="confirmImport()"
class="primary"
disabled
>
导入
</button>
</div>
</div>
<!-- 全局通知区域 -->
<div id="toast-container" class="toast-container"></div>
<script>
// 全局变量
let allProxies = {};
let generalProxy = "";
let selectedProxies = new Set();
let proxiesToDelete = [];
let searchDebounceTimer = null;
// 初始化TokenHandling
initializeTokenHandling("authToken");
// 初始化
document.addEventListener("DOMContentLoaded", () => {
// 获取代理信息
getProxyInfo();
// 监听搜索输入(防抖)
const searchInput = document.getElementById("searchInput");
searchInput.addEventListener("input", (e) => {
clearTimeout(searchDebounceTimer);
const value = e.target.value;
// 显示/隐藏清除按钮
const clearBtn = document.getElementById("clearSearch");
clearBtn.classList.toggle("show", value.length > 0);
// 防抖搜索
searchDebounceTimer = setTimeout(() => {
filterProxies();
}, 300);
});
// 监听代理列表滚动
const proxyListContainer =
document.getElementById("proxyListContainer");
proxyListContainer.addEventListener("scroll", () => {
const scrolled = proxyListContainer.scrollTop > 0;
proxyListContainer.classList.toggle("scrolled", scrolled);
});
// 初始化多选下拉框
initMultiSelect();
// 设置键盘快捷键
setupKeyboardShortcuts();
// 设置上下文菜单
setupContextMenu();
updateStatusBar();
});
// 初始化多选下拉框
function initMultiSelect() {
const dropdown = document.querySelector(".multiselect-dropdown");
const options = document.querySelector(".multiselect-options");
// 点击下拉框显示/隐藏选项
dropdown.addEventListener("click", function (e) {
e.stopPropagation();
const isOpen = dropdown.classList.contains("active");
if (isOpen) {
closeMultiSelect();
} else {
openMultiSelect();
}
});
// 键盘导航支持
dropdown.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
dropdown.click();
}
});
// 点击外部关闭下拉框
document.addEventListener("click", function (e) {
if (!e.target.closest(".multiselect")) {
closeMultiSelect();
}
});
// 监听选项点击
const optionDivs = document.querySelectorAll(".multiselect-option");
optionDivs.forEach((div) => {
div.addEventListener("click", function (e) {
e.stopPropagation();
const checkbox = this.querySelector('input[type="checkbox"]');
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event("change"));
});
// 监听复选框变化
const checkbox = div.querySelector('input[type="checkbox"]');
checkbox.addEventListener("change", function () {
updateSelectedOptions();
filterProxies();
});
});
// 初始化已选项
updateSelectedOptions();
}
// 打开多选下拉框
function openMultiSelect() {
const dropdown = document.querySelector(".multiselect-dropdown");
const options = document.querySelector(".multiselect-options");
dropdown.classList.add("active");
dropdown.setAttribute("aria-expanded", "true");
options.classList.add("show");
}
// 关闭多选下拉框
function closeMultiSelect() {
const dropdown = document.querySelector(".multiselect-dropdown");
const options = document.querySelector(".multiselect-options");
dropdown.classList.remove("active");
dropdown.setAttribute("aria-expanded", "false");
options.classList.remove("show");
}
// 更新已选选项显示
function updateSelectedOptions() {
const selectedContainer = document.getElementById("selectedProxyTypes");
const checkboxes = document.querySelectorAll(
".multiselect-option input:checked",
);
if (checkboxes.length === 0) {
selectedContainer.innerHTML =
'<span class="selected-options-placeholder">请选择代理类型...</span>';
return;
}
let html = "";
checkboxes.forEach((checkbox) => {
const label = checkbox.nextElementSibling.textContent;
html += `<span class="selected-option">${label}</span>`;
});
selectedContainer.innerHTML = html;
}
// 清除搜索输入
function clearSearchInput() {
const searchInput = document.getElementById("searchInput");
const clearBtn = document.getElementById("clearSearch");
searchInput.value = "";
clearBtn.classList.remove("show");
filterProxies();
searchInput.focus();
}
// 检测是Mac还是其他系统
function isModifierKey(e) {
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
return isMac ? e.metaKey : e.ctrlKey;
}
// 设置键盘快捷键
function setupKeyboardShortcuts() {
document.addEventListener("keydown", function (e) {
// 忽略输入框中的快捷键
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") {
return;
}
switch (e.key) {
case "Delete":
e.preventDefault();
deleteSelectedProxies();
break;
case "F5":
e.preventDefault();
getProxyInfo();
break;
case "g":
if (isModifierKey(e)) {
e.preventDefault();
setAsGeneral();
}
break;
case "c":
if (isModifierKey(e)) {
e.preventDefault();
copyProxyUrl();
}
break;
case "a":
if (isModifierKey(e)) {
e.preventDefault();
selectAllProxies();
}
break;
case "Escape":
closeAllModals();
closeContextMenu();
closeMultiSelect();
break;
}
});
}
// 设置上下文菜单
function setupContextMenu() {
const proxyList = document.querySelector(".proxy-list");
const contextMenu = document.getElementById("contextMenu");
// 右键点击显示菜单
proxyList.addEventListener("contextmenu", function (e) {
const row = e.target.closest("tr");
if (!row || row.closest("thead")) return;
if (!row.dataset.name) return;
e.preventDefault();
const proxyName = row.dataset.name;
if (!selectedProxies.has(proxyName)) {
clearSelection();
selectProxy(proxyName);
}
updateContextMenuItems();
showContextMenu(e.pageX, e.pageY);
});
// 点击其他地方关闭菜单
document.addEventListener("click", function () {
closeContextMenu();
});
// 阻止右键菜单内部点击导致菜单关闭
contextMenu.addEventListener("click", function (e) {
e.stopPropagation();
});
// 键盘导航支持
setupContextMenuKeyboardNav();
}
// 设置右键菜单键盘导航
function setupContextMenuKeyboardNav() {
const contextMenu = document.getElementById("contextMenu");
const items = contextMenu.querySelectorAll(
".context-menu-item:not(.disabled)",
);
items.forEach((item, index) => {
item.addEventListener("keydown", function (e) {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
const nextIndex = (index + 1) % items.length;
items[nextIndex].focus();
break;
case "ArrowUp":
e.preventDefault();
const prevIndex = (index - 1 + items.length) % items.length;
items[prevIndex].focus();
break;
case "Enter":
case " ":
e.preventDefault();
item.click();
break;
case "Escape":
e.preventDefault();
closeContextMenu();
break;
}
});
});
}
// 显示右键菜单
function showContextMenu(x, y) {
const menu = document.getElementById("contextMenu");
// 先设置在鼠标位置,然后调整避免超出边界
menu.style.left = x + "px";
menu.style.top = y + "px";
menu.style.display = "block";
// 调整位置以避免超出边界
positionContextMenu(menu, x, y);
// 聚焦第一个可用项
const firstItem = menu.querySelector(
".context-menu-item:not(.disabled)",
);
if (firstItem) {
firstItem.focus();
}
}
// 关闭右键菜单
function closeContextMenu() {
const contextMenu = document.getElementById("contextMenu");
contextMenu.style.display = "none";
}
// 更新右键菜单项状态
function updateContextMenuItems() {
const selectedCount = selectedProxies.size;
const items = document.querySelectorAll(".context-menu-item");
items.forEach((item) => {
// 根据选择状态启用/禁用菜单项
if (item.onclick.toString().includes("setAsGeneral")) {
item.classList.toggle("disabled", selectedCount !== 1);
}
});
}
// 计算菜单位置
function positionContextMenu(menu, x, y) {
menu.style.visibility = "hidden";
menu.style.display = "block";
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const menuWidth = menu.offsetWidth;
const menuHeight = menu.offsetHeight;
let adjustedX = x;
let adjustedY = y;
if (x + menuWidth > windowWidth - 10) {
adjustedX = windowWidth - menuWidth - 10;
}
if (y + menuHeight > windowHeight - 10) {
adjustedY = windowHeight - menuHeight - 10;
}
menu.style.left = Math.max(10, adjustedX) + "px";
menu.style.top = Math.max(10, adjustedY) + "px";
menu.style.visibility = "visible";
}
// 获取代理信息
async function getProxyInfo() {
showLoadingState();
showToast("正在加载代理列表...", "info");
const data = await makeAuthenticatedRequest("/proxies/get");
if (data) {
allProxies = data.proxies || {};
generalProxy = data.general_proxy || "";
renderProxies();
updateStatusBar();
showToast(
`代理列表已更新:共 ${data.proxies_count || 0}`,
"success",
);
} else {
showToast("获取代理列表失败", "error");
hideLoadingState();
}
}
// 显示加载状态
function showLoadingState() {
const loadingState = document.getElementById("loadingState");
const emptyState = document.getElementById("emptyState");
const proxyListBody = document.getElementById("proxyListBody");
loadingState.style.display = "block";
emptyState.style.display = "none";
proxyListBody.innerHTML = "";
}
// 隐藏加载状态
function hideLoadingState() {
const loadingState = document.getElementById("loadingState");
loadingState.style.display = "none";
}
// 渲染代理列表
function renderProxies() {
const proxyListBody = document.getElementById("proxyListBody");
const emptyState = document.getElementById("emptyState");
const loadingState = document.getElementById("loadingState");
loadingState.style.display = "none";
const proxyEntries = Object.entries(allProxies);
if (proxyEntries.length === 0) {
proxyListBody.innerHTML = "";
emptyState.style.display = "flex";
return;
}
emptyState.style.display = "none";
proxyListBody.innerHTML = proxyEntries
.map(([name, value]) => {
const isGeneral = name === generalProxy;
const type = getProxyType(value);
const url = type === "non" || type === "sys" ? "-" : value;
const icon = getProxyIcon(type);
return `
<tr role="row" data-name="${escapeHtml(name)}" class="${selectedProxies.has(name) ? "selected" : ""} ${isGeneral ? "general" : ""}">
<td><span class="proxy-icon">${icon}</span><span class="proxy-name">${escapeHtml(name)}</span></td>
<td>${formatProxyType(type)}</td>
<td>${escapeHtml(url)}</td>
</tr>
`;
})
.join("");
// 添加点击事件处理
setupProxyRowEvents();
}
// 设置代理行事件
function setupProxyRowEvents() {
const rows = document.querySelectorAll("#proxyListBody tr");
rows.forEach((row) => {
row.addEventListener("click", function (e) {
const name = this.dataset.name;
if (e.shiftKey && selectedProxies.size > 0) {
// Shift+点击进行范围选择
selectRange(name);
} else if (isModifierKey(e)) {
// Ctrl/Cmd+点击进行多选
if (selectedProxies.has(name)) {
deselectProxy(name);
} else {
selectProxy(name, true);
}
} else {
// 普通点击
clearSelection();
selectProxy(name);
}
updateSelectionStatus();
});
});
}
// 选择范围内的代理
function selectRange(endName) {
const rows = Array.from(document.querySelectorAll("#proxyListBody tr"));
const lastSelected = Array.from(selectedProxies).pop();
const startIndex = rows.findIndex(
(row) => row.dataset.name === lastSelected,
);
const endIndex = rows.findIndex((row) => row.dataset.name === endName);
if (startIndex === -1 || endIndex === -1) return;
const [min, max] = [
Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex),
];
for (let i = min; i <= max; i++) {
const name = rows[i].dataset.name;
selectProxy(name, true);
}
}
// 选择所有代理
function selectAllProxies() {
const rows = document.querySelectorAll("#proxyListBody tr");
rows.forEach((row) => {
const name = row.dataset.name;
selectProxy(name, true);
});
updateSelectionStatus();
}
// HTML转义
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// 获取代理图标
function getProxyIcon(type) {
const icons = {
non: "🚫",
sys: "🖥️",
http: "🌐",
https: "🔒",
socks4: "🧦",
socks5: "🧦",
socks5h: "🧦",
unknown: "❓",
};
return icons[type] || icons.unknown;
}
// 获取代理类型
function getProxyType(value) {
if (value === "non") return "non";
if (value === "sys") return "sys";
try {
const proxyUrl = new URL(value);
const protocol = proxyUrl.protocol.replace(":", "");
return protocol;
} catch (e) {
return "unknown";
}
}
// 格式化代理类型
function formatProxyType(type) {
const types = {
non: "不使用代理",
sys: "系统代理",
http: "HTTP",
https: "HTTPS",
socks4: "SOCKS4",
socks5: "SOCKS5",
socks5h: "SOCKS5H",
unknown: "未知",
};
return types[type] || type;
}
// 验证代理地址格式
function validateProxyUrl(url, type) {
if (type === "non" || type === "sys") return true;
try {
const proxyUrl = new URL(url);
const protocol = proxyUrl.protocol.replace(":", "");
const validProtocols = [
"http",
"https",
"socks4",
"socks5",
"socks5h",
];
if (!validProtocols.includes(protocol)) {
showToast(`不支持的代理协议: ${protocol}`, "warning");
return false;
}
if (!proxyUrl.hostname || !proxyUrl.port) {
showToast("代理地址必须包含主机名和端口号", "warning");
return false;
}
return true;
} catch (e) {
showToast("代理地址格式无效", "warning");
return false;
}
}
// 筛选代理
function filterProxies() {
const searchTerm = document
.getElementById("searchInput")
.value.toLowerCase();
const selectedTypes = Array.from(
document.querySelectorAll(".multiselect-option input:checked"),
).map((cb) => cb.value);
const filteredProxies = Object.entries(allProxies).filter(
([name, value]) => {
const type = getProxyType(value);
const nameMatch = name.toLowerCase().includes(searchTerm);
const typeMatch = selectedTypes.includes(type);
return nameMatch && typeMatch;
},
);
const filteredObj = Object.fromEntries(filteredProxies);
renderFilteredProxies(filteredObj);
updateStatusBar(filteredProxies.length);
}
// 渲染筛选后的代理
function renderFilteredProxies(proxies) {
const temp = allProxies;
allProxies = proxies;
renderProxies();
allProxies = temp;
}
// 更新状态栏
function updateStatusBar(filteredCount) {
const total = Object.keys(allProxies).length;
const selected = selectedProxies.size;
const selectionStatus = document.getElementById("selectionStatus");
const totalCount = document.getElementById("totalCount");
if (selectionStatus) {
selectionStatus.textContent = `已选择: ${selected}`;
}
if (totalCount) {
const count = filteredCount !== undefined ? filteredCount : total;
totalCount.textContent = `${count} 个代理`;
}
}
// 选择代理
function selectProxy(name, append = false) {
if (!name) return;
if (!append) {
clearSelection();
}
selectedProxies.add(name);
const row = document.querySelector(`tr[data-name="${name}"]`);
if (row) {
row.classList.add("selected");
}
updateSelectionStatus();
}
// 取消选择代理
function deselectProxy(name) {
if (!name) return;
selectedProxies.delete(name);
const row = document.querySelector(`tr[data-name="${name}"]`);
if (row) {
row.classList.remove("selected");
}
updateSelectionStatus();
}
// 清除所有选择
function clearSelection() {
selectedProxies.clear();
const rows = document.querySelectorAll("#proxyListBody tr.selected");
rows.forEach((row) => {
row.classList.remove("selected");
});
updateSelectionStatus();
}
// 更新选择状态
function updateSelectionStatus() {
updateStatusBar();
}
// 显示添加代理对话框
function showAddProxyModal() {
document.getElementById("proxyName").value = "";
document.getElementById("proxyType").value = "custom";
document.getElementById("proxyUrl").value = "";
toggleProxyUrl();
showModal("addProxyModal");
// 聚焦第一个输入框
setTimeout(() => {
document.getElementById("proxyName").focus();
}, 200);
}
// 切换代理地址输入框显示
function toggleProxyUrl() {
const type = document.getElementById("proxyType").value;
const urlGroup = document.getElementById("proxyUrlGroup");
urlGroup.style.display = type === "custom" ? "block" : "none";
}
// 确认添加代理
async function confirmAddProxy() {
const name = document.getElementById("proxyName").value.trim();
const type = document.getElementById("proxyType").value;
const url = document.getElementById("proxyUrl").value.trim();
if (!name) {
showToast("请输入代理名称", "warning");
document.getElementById("proxyName").focus();
return;
}
if (type === "custom") {
if (!url) {
showToast("请输入代理地址", "warning");
document.getElementById("proxyUrl").focus();
return;
}
if (!validateProxyUrl(url, type)) {
return;
}
}
closeModal("addProxyModal");
showToast("正在添加代理...", "info");
const proxy = {
[name]: type === "non" || type === "sys" ? type : url,
};
const data = await makeAuthenticatedRequest("/proxies/add", {
body: JSON.stringify({
proxies: proxy,
}),
});
if (data) {
showToast(data.message || "添加成功", "success");
getProxyInfo();
} else {
showToast("添加代理失败", "error");
}
}
// 删除选中的代理
function deleteSelectedProxies() {
if (selectedProxies.size === 0) {
showToast("请先选择要删除的代理", "info");
return;
}
proxiesToDelete = [...selectedProxies];
document.getElementById("confirmMessage").textContent =
proxiesToDelete.length > 1
? `确定要删除选中的 ${proxiesToDelete.length} 个代理吗?`
: "确定要删除选中的代理吗?";
closeContextMenu();
showModal("confirmModal");
}
// 确认删除代理
async function confirmDeleteProxies() {
if (proxiesToDelete.length === 0) return;
closeModal("confirmModal");
showToast("正在删除代理...", "info");
const data = await makeAuthenticatedRequest("/proxies/del", {
body: JSON.stringify({
names: proxiesToDelete,
expectation: "failed_tokens",
}),
});
if (data) {
const failedCount = data.failed_names?.length || 0;
const successCount = proxiesToDelete.length - failedCount;
const message = failedCount
? `删除成功: ${successCount} 个,失败: ${failedCount}`
: `成功删除 ${successCount} 个代理`;
showToast(message, "success");
clearSelection();
getProxyInfo();
} else {
showToast("删除代理失败", "error");
}
proxiesToDelete = [];
}
// 设置为通用代理
async function setAsGeneral() {
if (selectedProxies.size !== 1) {
showToast("请选择一个代理设为通用代理", "info");
return;
}
closeContextMenu();
const name = [...selectedProxies][0];
showToast("正在设置通用代理...", "info");
const data = await makeAuthenticatedRequest("/proxies/set-general", {
body: JSON.stringify({
name: name,
}),
});
if (data) {
showToast("通用代理设置成功", "success");
getProxyInfo();
} else {
showToast("设置通用代理失败", "error");
}
}
// 复制代理地址
async function copyProxyUrl() {
if (selectedProxies.size === 0) {
showToast("请先选择代理", "info");
return;
}
const name = [...selectedProxies][0];
const value = allProxies[name];
closeContextMenu();
if (typeof value === "string" && value !== "non" && value !== "sys") {
// 使用共享的复制方法
const success = await copyToClipboard(value, {
showMessage: false, // 使用自定义的 toast 消息
onSuccess: () => {
showToast("代理地址已复制到剪贴板", "success");
},
onError: () => {
showToast("复制失败,请手动复制", "error");
},
});
} else {
showToast("该代理没有可复制的地址", "warning");
}
}
// 导出代理配置
function exportProxies() {
const config = {
proxies: allProxies,
general: generalProxy,
};
const jsonData = JSON.stringify(config, null, 2);
const blob = new Blob([jsonData], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `proxies_export_${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast("代理配置已导出", "success");
}
// 显示导入对话框
function showImportModal() {
document.getElementById("importFile").value = "";
document.getElementById("importPreview").style.display = "none";
document.getElementById("confirmImportBtn").disabled = true;
showModal("importModal");
}
// 预览导入文件
function previewImportFile() {
const fileInput = document.getElementById("importFile");
const preview = document.getElementById("importPreview");
const previewContent = document.getElementById("importPreviewContent");
const confirmBtn = document.getElementById("confirmImportBtn");
if (!fileInput.files || fileInput.files.length === 0) {
preview.style.display = "none";
confirmBtn.disabled = true;
return;
}
const file = fileInput.files[0];
const reader = new FileReader();
reader.onload = function (e) {
try {
const config = JSON.parse(e.target.result);
// 验证文件格式
if (!config.proxies || typeof config.proxies !== "object") {
throw new Error("无效的配置文件格式");
}
// 显示预览
const proxyCount = Object.keys(config.proxies).length;
const previewText = `代理数量: ${proxyCount}\n通用代理: ${config.general || "无"}\n\n代理列表:\n${Object.entries(
config.proxies,
)
.slice(0, 10)
.map(([name, value]) => `- ${name}: ${value}`)
.join("\n")}${proxyCount > 10 ? "\n..." : ""}`;
previewContent.textContent = previewText;
preview.style.display = "block";
confirmBtn.disabled = false;
} catch (error) {
showToast("文件格式无效: " + error.message, "error");
preview.style.display = "none";
confirmBtn.disabled = true;
}
};
reader.readAsText(file);
}
// 确认导入
async function confirmImport() {
const fileInput = document.getElementById("importFile");
if (!fileInput.files || fileInput.files.length === 0) {
showToast("请选择要导入的文件", "warning");
return;
}
const file = fileInput.files[0];
const reader = new FileReader();
reader.onload = async function (e) {
try {
const config = JSON.parse(e.target.result);
closeModal("importModal");
showToast("正在导入代理配置...", "info");
const data = await makeAuthenticatedRequest("/proxies/set", {
body: JSON.stringify(config),
});
if (data) {
showToast("代理配置导入成功", "success");
fileInput.value = "";
getProxyInfo();
} else {
showToast("导入失败", "error");
}
} catch (error) {
showToast("导入失败: " + error.message, "error");
}
};
reader.readAsText(file);
}
// 显示对话框
function showModal(modalId) {
const modal = document.getElementById(modalId);
const backdrop = document.getElementById(`${modalId}-backdrop`);
if (modal && backdrop) {
modal.style.display = "block";
backdrop.style.display = "block";
}
}
// 关闭对话框
function closeModal(modalId) {
const modal = document.getElementById(modalId);
const backdrop = document.getElementById(`${modalId}-backdrop`);
if (modal && backdrop) {
modal.style.display = "none";
backdrop.style.display = "none";
}
}
// 关闭所有对话框
function closeAllModals() {
const modals = document.querySelectorAll(".modal");
const backdrops = document.querySelectorAll(".modal-backdrop");
modals.forEach((modal) => {
modal.style.display = "none";
});
backdrops.forEach((backdrop) => {
backdrop.style.display = "none";
});
}
// 显示通知消息
function showToast(message, type = "info", duration = 3000) {
// 获取或创建通知容器
let container = document.getElementById("toast-container");
if (!container) {
container = document.createElement("div");
container.id = "toast-container";
container.className = "toast-container";
document.body.appendChild(container);
}
// 创建新的通知元素
const toast = document.createElement("div");
toast.className = `toast ${type}`;
// 添加文本容器
toast.innerHTML = `<div class="toast-content">${message}</div>`;
// 添加到容器
container.appendChild(toast);
// 强制一次重排,确保过渡动画生效
void toast.offsetWidth;
// 显示通知
requestAnimationFrame(() => {
toast.classList.add("show");
});
// 设置自动移除
setTimeout(() => {
toast.classList.remove("show");
// 通知消失动画完成后移除元素
setTimeout(() => {
if (container.contains(toast)) {
container.removeChild(toast);
}
// 如果没有更多通知,移除容器
if (container.children.length === 0) {
if (document.body.contains(container)) {
document.body.removeChild(container);
}
}
}, 400);
}, duration);
// 限制最大显示数量
const maxToasts = 5;
const toasts = container.querySelectorAll(".toast");
if (toasts.length > maxToasts) {
const oldestToast = toasts[0];
oldestToast.classList.remove("show");
setTimeout(() => {
if (container.contains(oldestToast)) {
container.removeChild(oldestToast);
}
}, 400);
}
}
</script>
</body>
</html>