// ==UserScript==
// @name Claude Session Key 管理器
// @name:zh-CN Claude Session Key 管理器
// @name:en Claude Session Key Manager
// @version 1.1.2
// @description Claude Session Key 管理工具(移动端布局兼容),支持拖拽、测活、导入导出、WebDAV云备份等功能
// @description:zh-CN Claude Session Key 管理工具(移动端布局兼容),支持拖拽、测活、批量导入导出、WebDAV云备份等功能
// @description:en Claude Session Key Manager with drag-and-drop, token validation, import/export, WebDAV backup and more
// @author xiaoye6688
// @namespace https://gf.qytechs.cn/users/1317128-xiaoye6688
// @homepage https://gf.qytechs.cn/zh-CN/users/1317128-xiaoye6688
// @supportURL https://gf.qytechs.cn/zh-CN/users/1317128-xiaoye6688
// @license MIT
// @date 2025-03-09
// @modified 2025-03-09
// @match https://claude.ai/*
// @match https://claude.asia/*
// @match https://demo.fuclaude.com/*
// @include https://*fuclaude*/*
//
// @icon https://claude.ai/favicon.ico
// @run-at document-end
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_info
// @grant GM_cookie
// @connect ipapi.co
// @connect api.claude.ai
// @connect *
// ==/UserScript==
(function () {
"use strict";
const config = {
storageKey: "claudeTokens",
ipApiUrl: "https://ipapi.co/country_code",
defaultToken: {
name: "Token00",
key: "sk-key",
},
currentTokenKey: "currentClaudeToken",
testResultsKey: "claudeTokenTestResults",
testResultExpiry: 1800000, // 30分钟过期
};
const theme = {
light: {
bgColor: "#fcfaf5",
textColor: "#333",
borderColor: "#ccc",
buttonBg: "#f5f1e9",
buttonHoverBg: "#e5e1d9",
modalBg: "rgba(0, 0, 0, 0.5)",
},
dark: {
bgColor: "#2c2b28",
textColor: "#f5f4ef",
borderColor: "#3f3f3c",
buttonBg: "#3f3f3c",
buttonHoverBg: "#4a4a47",
modalBg: "rgba(0, 0, 0, 0.7)",
},
};
const getStyles = (isDarkMode) => `
:root {
--bg-color: ${isDarkMode ? theme.dark.bgColor : theme.light.bgColor};
--text-color: ${isDarkMode ? theme.dark.textColor : theme.light.textColor};
--border-color: ${
isDarkMode ? theme.dark.borderColor : theme.light.borderColor
};
--button-bg: ${isDarkMode ? theme.dark.buttonBg : theme.light.buttonBg};
--button-hover-bg: ${
isDarkMode ? theme.dark.buttonHoverBg : theme.light.buttonHoverBg
};
--modal-bg: ${isDarkMode ? theme.dark.modalBg : theme.light.modalBg};
}
/* 浮动按钮样式 */
#claude-toggle-button {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--bg-color);
color: #b3462f;
cursor: move;
position: fixed;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
transition: background-color 0.3s ease, transform 0.2s ease;
outline: none;
padding: 0;
user-select: none;
touch-action: none;
border: 1px solid var(--border-color);
font-size: 18px;
}
#claude-toggle-button:hover {
transform: scale(1.1);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
/* 下拉容器样式 */
.claude-dropdown-container {
position: fixed;
background-color: var(--bg-color);
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
display: none;
flex-direction: column;
gap: 0; /* 移除flex布局产生的空隙 */
width: 600px;
max-height: 80vh;
overflow-y: auto;
z-index: 9999;
border: 1px solid var(--border-color);
opacity: 0;
transform: scale(0.95);
transition: opacity 0.3s ease, transform 0.3s ease;
scrollbar-gutter: stable; /* 保持滚动条空间稳定 */
scrollbar-width: thin; /* Firefox滚动条样式 */
scrollbar-color: ${
isDarkMode
? "rgba(255, 255, 255, 0.2) transparent"
: "rgba(0, 0, 0, 0.2) transparent"
};
}
/* 标题容器 */
.claude-title-container {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 10px;
}
.claude-title-container h2 {
margin: 0;
color: var(--text-color);
font-size: 18px;
font-weight: 600;
}
.claude-ip-display {
font-size: 14px;
color: var(--text-color);
padding: 4px 10px;
background-color: var(--button-bg);
border-radius: 12px;
}
/* Token 网格容器 */
.claude-token-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
max-height: calc(2 * (90px + 12px) + 24px); /* 两行token的高度加上间隙和padding */
overflow-y: auto;
padding: 12px 0 12px 12px;
scrollbar-gutter: stable; /* 保持滚动条空间稳定,防止出现时推动内容 */
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: ${
isDarkMode ? "rgba(255, 255, 255, 0.03)" : "rgba(0, 0, 0, 0.02)"
};
/* Firefox滚动条样式支持 */
scrollbar-width: thin;
scrollbar-color: ${
isDarkMode
? "rgba(255, 255, 255, 0.2) transparent"
: "rgba(0, 0, 0, 0.2) transparent"
};
}
/* Token 卡片样式 */
.claude-token-item {
padding: 15px;
border-radius: 8px;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.3s ease;
position: relative;
height: 90px; /* 固定高度 */
box-sizing: border-box; /* 确保padding不会增加总高度 */
display: flex;
flex-direction: column;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.claude-token-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: ${
isDarkMode ? "rgba(255, 255, 255, 0.3)" : "rgba(0, 0, 0, 0.2)"
};
}
.claude-token-item.current-token {
border: 2px solid #b3462f;
background-color: ${
isDarkMode ? "rgba(179, 70, 47, 0.1)" : "rgba(179, 70, 47, 0.05)"
};
position: relative;
}
.current-token-badge {
position: absolute;
top: -8px;
left: 8px;
background-color: #b3462f;
width: 20px;
height: 20px;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
}
.current-token-badge::after {
content: "";
display: block;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: white;
}
/* Token 内容样式 */
.token-info {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1; /* 填充可用空间 */
justify-content: space-between; /* 顶部行和底部行分别位于容器顶部和底部 */
}
.token-top-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.token-name-container {
display: flex;
align-items: center;
gap: 8px;
}
.token-number {
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
background-color: var(--button-bg);
}
/* 账号类型标签样式 */
.account-type-label {
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
background-color: var(--button-bg);
color: var(--text-color);
margin-right: 6px;
font-weight: 500;
display: inline-block;
}
.token-name {
font-weight: 500;
font-size: 14px;
}
.token-actions {
display: flex;
gap: 8px;
}
.token-action-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-color);
transition: all 0.2s ease;
}
.token-action-btn:hover {
background-color: var(--button-hover-bg);
transform: scale(1.1);
}
.token-action-btn.delete-btn {
color: #e24a4a;
}
.token-bottom-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.token-status {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #888;
}
.status-indicator.success {
background-color: #48bb78;
}
.status-indicator.error {
background-color: #e53e3e;
}
.status-indicator.loading {
background-color: #888;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { opacity: 0.4; }
50% { opacity: 1; }
100% { opacity: 0.4; }
}
.token-time {
font-size: 12px;
color: var(--text-color);
opacity: 0.7;
}
/* 按钮容器 */
.claude-button-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding-top: 12px;
}
.claude-button {
padding: 8px 10px;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 13px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
}
.claude-button:hover {
transform: translateY(-2px);
}
.claude-button.primary {
background-color: #b3462f;
color: white;
}
.claude-button.primary:hover {
background-color: #a03d2a;
}
.claude-button.secondary {
background-color: var(--button-bg);
color: var(--text-color);
}
.claude-button.secondary:hover {
background-color: var(--button-hover-bg);
}
/* 工具提示样式 */
.claude-button[data-tooltip]:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
z-index: 10001;
margin-bottom: 5px;
pointer-events: none;
opacity: 0;
animation: tooltip-fade-in 0.2s ease forwards;
}
@keyframes tooltip-fade-in {
from { opacity: 0; transform: translate(-50%, 5px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
/* 模态框样式 */
.claude-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--modal-bg);
display: flex;
justify-content: center;
align-items: center;
z-index: 10001;
}
.claude-modal-content {
background-color: var(--bg-color);
padding: 20px;
padding-right: 14px; /* 右侧padding稍微增加,为滚动条预留空间但不过多 */
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
width: 500px;
max-width: 90%;
overflow-y: auto;
position: relative;
scrollbar-gutter: stable; /* 保持滚动条空间稳定 */
scrollbar-width: thin; /* Firefox滚动条样式 */
scrollbar-color: ${
isDarkMode
? "rgba(255, 255, 255, 0.2) transparent"
: "rgba(0, 0, 0, 0.2) transparent"
};
}
.claude-modal-content.narrow-modal {
width: 400px;
max-width: 80%;
}
.claude-modal h2 {
margin-top: 0;
margin-bottom: 15px;
color: var(--text-color);
font-size: 18px;
font-weight: 600;
}
.claude-modal input, .claude-modal textarea {
width: 100%;
padding: 10px;
margin-bottom: 15px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 14px;
background-color: var(--bg-color);
color: var(--text-color);
}
.claude-modal-buttons {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 15px;
}
.claude-close-button {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: var(--text-color);
padding: 5px;
line-height: 1;
}
/* 自定义滚动条样式 */
.claude-token-grid::-webkit-scrollbar {
width: 6px;
/* 初始状态下滚动条透明 */
background-color: transparent;
}
.claude-token-grid::-webkit-scrollbar-track {
background: transparent;
margin: 4px 0;
}
.claude-token-grid::-webkit-scrollbar-thumb {
background-color: ${
isDarkMode ? "rgba(255, 255, 255, 0.15)" : "rgba(0, 0, 0, 0.15)"
};
border-radius: 6px;
transition: background-color 0.3s ease;
/* 初始状态下滚动条半透明 */
opacity: 0.6;
}
.claude-token-grid::-webkit-scrollbar-thumb:hover {
background-color: ${
isDarkMode ? "rgba(255, 255, 255, 0.3)" : "rgba(0, 0, 0, 0.3)"
};
opacity: 1;
}
.claude-token-grid:hover::-webkit-scrollbar-thumb {
background-color: ${
isDarkMode ? "rgba(255, 255, 255, 0.3)" : "rgba(0, 0, 0, 0.3)"
};
opacity: 1;
}
/* 滚动条样式 */
.claude-dropdown-container::-webkit-scrollbar,
.claude-modal-content::-webkit-scrollbar {
width: 6px;
background-color: transparent;
}
.claude-dropdown-container::-webkit-scrollbar-track,
.claude-modal-content::-webkit-scrollbar-track {
background: transparent;
margin: 4px 0;
}
.claude-dropdown-container::-webkit-scrollbar-thumb,
.claude-modal-content::-webkit-scrollbar-thumb {
background-color: ${
isDarkMode ? "rgba(255, 255, 255, 0.15)" : "rgba(0, 0, 0, 0.15)"
};
border-radius: 6px;
transition: background-color 0.3s ease;
opacity: 0.6;
}
.claude-dropdown-container::-webkit-scrollbar-thumb:hover,
.claude-modal-content::-webkit-scrollbar-thumb:hover {
background-color: ${
isDarkMode ? "rgba(255, 255, 255, 0.3)" : "rgba(0, 0, 0, 0.3)"
};
opacity: 1;
}
.claude-dropdown-container:hover::-webkit-scrollbar-thumb,
.claude-modal-content:hover::-webkit-scrollbar-thumb {
opacity: 1;
}
/* 预览容器 */
.claude-preview-container {
margin-top: 15px;
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 15px;
}
.claude-preview-title {
font-size: 16px;
margin-bottom: 10px;
color: var(--text-color);
border-bottom: 1px solid var(--border-color);
padding-bottom: 5px;
}
.claude-preview-item {
margin-bottom: 8px;
font-size: 14px;
padding: 8px;
border-radius: 4px;
background-color: var(--button-bg);
}
/* 滚动提示样式 */
.scroll-indicator {
grid-column: 1 / -1; /* 横跨所有列 */
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
margin-top: 5px;
color: ${isDarkMode ? "rgba(255, 255, 255, 0.6)" : "rgba(0, 0, 0, 0.5)"};
font-size: 12px;
background-color: ${
isDarkMode ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.03)"
};
border-radius: 6px;
gap: 8px;
}
.scroll-arrow {
animation: bounce 1.5s infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(3px); }
}
@media (max-width: 768px) {
/* 浮动按钮移动端优化 */
#claude-toggle-button {
width: 36px;
height: 36px;
font-size: 10px;
top: 10px;
right: 10px;
left: auto !important; /* 强制右下角显示 */
}
/* 下拉容器移动端适配 */
.claude-dropdown-container {
position: fixed;
top: 10px !important;
left: 10px !important;
right: 10px !important;
bottom: auto !important;
width: auto !important;
max-width: none;
max-height: calc(100vh - 80px);
padding: 15px;
border-radius: 8px;
}
/* 标题容器移动端适配 */
.claude-title-container {
flex-direction: column;
gap: 8px;
align-items: flex-start;
padding-bottom: 8px;
}
.claude-title-container h2 {
font-size: 16px;
}
.claude-ip-display {
font-size: 12px;
padding: 3px 8px;
}
/* Token网格移动端单列布局 */
.claude-token-grid {
grid-template-columns: 1fr; /* 单列显示 */
gap: 10px;
max-height: calc(3 * (80px + 10px) + 20px); /* 三行token的高度 */
padding: 10px;
}
/* Token卡片移动端优化 */
.claude-token-item {
height: 100px;
padding: 12px;
}
.token-number {
font-size: 11px;
padding: 1px 6px;
}
.token-name {
font-size: 13px;
}
.token-action-btn {
padding: 3px;
}
.token-action-btn svg {
width: 12px;
height: 12px;
}
.token-time {
font-size: 11px;
}
.status-indicator {
width: 8px;
height: 8px;
}
/* 按钮容器移动端适配 */
.claude-button-container {
grid-template-columns: repeat(2, 1fr); /* 改为2列布局 */
gap: 6px;
padding-top: 10px;
}
.claude-button {
padding: 10px 8px;
font-size: 12px;
gap: 3px;
}
/* 模态框移动端适配 */
.claude-modal {
padding: 10px;
}
.claude-modal-content {
width: 100%;
max-width: calc(100vw - 20px);
max-height: calc(100vh - 40px);
padding: 15px;
margin: 0;
}
.claude-modal-content.narrow-modal {
width: 100%;
max-width: calc(100vw - 20px);
}
.claude-modal h2 {
font-size: 16px;
margin-bottom: 12px;
}
.claude-modal input,
.claude-modal textarea {
padding: 12px;
font-size: 16px; /* 防止iOS缩放 */
border-radius: 6px;
}
.claude-modal-buttons {
flex-direction: column-reverse;
gap: 8px;
}
.claude-modal-buttons .claude-button {
width: 100%;
padding: 12px;
font-size: 14px;
}
/* WebDAV表单移动端优化 */
.input-group {
flex-direction: column !important;
gap: 5px !important;
}
.input-group label {
min-width: auto !important;
text-align: left !important;
font-size: 14px;
}
.claude-webdav-actions {
grid-template-columns: 1fr !important;
gap: 8px;
}
/* 预览容器移动端优化 */
.claude-preview-container {
max-height: 150px;
padding: 10px;
}
.claude-preview-title {
font-size: 14px;
}
.claude-preview-item {
font-size: 12px;
padding: 6px;
}
/* 信息提示移动端优化 */
.claude-info-section {
font-size: 10px !important;
padding: 6px !important;
}
/* 滚动提示移动端优化 */
.scroll-indicator {
padding: 6px;
font-size: 11px;
}
/* 移动端触摸优化 */
.claude-token-item,
.claude-button,
.token-action-btn {
min-height: 44px; /* iOS推荐的最小触摸区域 */
}
/* 命名规则容器移动端适配 */
.claude-naming-rule {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.claude-naming-rule label {
font-size: 14px;
}
.claude-naming-rule input {
padding: 10px;
font-size: 16px;
}
}
/* 超小屏幕适配 (小于480px) */
@media (max-width: 480px) {
.claude-dropdown-container {
top: 5px !important;
left: 5px !important;
right: 5px !important;
padding: 12px;
}
.claude-button-container {
grid-template-columns: 1fr; /* 超小屏幕单列 */
}
.claude-token-grid {
max-height: calc(2 * (80px + 10px) + 20px); /* 两行token */
}
}
`;
const UI = {
createElem(tag, className = "", styles = {}) {
const elem = document.createElement(tag);
if (className) elem.className = className;
Object.assign(elem.style, styles);
return elem;
},
createButton(text, className, icon = "") {
const button = this.createElem("button", className);
if (icon) {
button.innerHTML = `${icon} ${text}`;
} else {
button.textContent = text;
}
return button;
},
createModal(title, content, includeCloseButton = true) {
const modal = this.createElem("div", "claude-modal");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("role", "dialog");
const modalContent = this.createElem("div", "claude-modal-content");
const titleElem = this.createElem("h2");
titleElem.textContent = title;
modalContent.appendChild(titleElem);
if (includeCloseButton) {
const closeButton = this.createElem("button", "claude-close-button");
closeButton.textContent = "×";
closeButton.addEventListener("click", () =>
document.body.removeChild(modal)
);
modalContent.appendChild(closeButton);
}
modalContent.appendChild(content);
const buttonContainer = this.createElem("div", "claude-modal-buttons");
modalContent.appendChild(buttonContainer);
modal.appendChild(modalContent);
document.body.appendChild(modal);
return {
modal,
buttonContainer,
close: () => document.body.removeChild(modal),
};
},
};
const App = {
init() {
this.isDarkMode =
document.documentElement.getAttribute("data-mode") === "dark";
this.injectStyles();
this.tokens = this.loadTokens();
this.createUI();
this.setupEventListeners();
this.observeThemeChanges();
// 获取保存的位置或使用默认值
const savedPosition = {
left: GM_getValue("buttonLeft", 10),
bottom: GM_getValue("buttonBottom", 10),
};
// 设置按钮位置
this.toggleButton.style.left = `${savedPosition.left}px`;
this.toggleButton.style.bottom = `${savedPosition.bottom}px`;
// 初始化拖拽状态
this.isDragging = false;
this.buttonLeft = savedPosition.left;
this.buttonBottom = savedPosition.bottom;
// 获取IP信息
this.fetchIPCountryCode();
// 添加窗口大小变化监听
window.addEventListener("resize", () => {
const isMobile = window.innerWidth <= 768;
if (isMobile) {
// 移动端时重置按钮位置
this.toggleButton.style.right = "10px";
this.toggleButton.style.top = "10px";
this.toggleButton.style.left = "auto";
// 如果下拉窗口可见,重新定位
if (this.state.isDropdownVisible) {
this.dropdownContainer.style.top = "10px";
this.dropdownContainer.style.left = "10px";
this.dropdownContainer.style.right = "10px";
this.dropdownContainer.style.bottom = "auto";
this.dropdownContainer.style.width = "auto";
}
} else {
// 桌面端恢复保存的位置
const savedPosition = {
left: GM_getValue("buttonLeft", 10),
bottom: GM_getValue("buttonBottom", 10),
};
this.toggleButton.style.left = `${savedPosition.left}px`;
this.toggleButton.style.bottom = `${savedPosition.bottom}px`;
this.toggleButton.style.right = "auto";
// 如果下拉窗口可见,重新计算位置
if (this.state.isDropdownVisible) {
this.updateDropdownPosition();
}
}
});
},
injectStyles() {
this.styleElem = document.createElement("style");
this.styleElem.textContent = getStyles(this.isDarkMode);
document.head.appendChild(this.styleElem);
},
updateStyles() {
this.styleElem.textContent = getStyles(this.isDarkMode);
},
loadTokens() {
try {
const savedTokens = GM_getValue(config.storageKey);
let tokens =
savedTokens && savedTokens.length > 0
? savedTokens
: [config.defaultToken];
// 为没有创建时间的token添加默认值
tokens = tokens.map((token) => {
if (!token.createdAt) {
const now = new Date();
return {
...token,
createdAt: now.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}),
timestamp: now.getTime(),
};
}
return token;
});
return tokens;
} catch (error) {
console.error("加载 tokens 失败:", error);
return [config.defaultToken];
}
},
saveTokens() {
try {
GM_setValue(config.storageKey, this.tokens);
} catch (error) {
console.error("保存 tokens 失败:", error);
alert("保存 tokens 失败,请重试。");
}
},
createUI() {
// 创建浮动按钮
this.toggleButton = UI.createElem("button", "claude-toggle-button");
this.toggleButton.id = "claude-toggle-button";
// 移动端检测并设置按钮位置
const isMobile = window.innerWidth <= 768;
if (isMobile) {
// 移动端固定在右下角
this.toggleButton.style.right = "20px";
this.toggleButton.style.bottom = "20px";
this.toggleButton.style.left = "auto";
} else {
// 桌面端使用保存的位置
const savedPosition = {
left: GM_getValue("buttonLeft", 10),
bottom: GM_getValue("buttonBottom", 10),
};
this.toggleButton.style.left = `${savedPosition.left}px`;
this.toggleButton.style.bottom = `${savedPosition.bottom}px`;
}
this.toggleButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" pointer-events="none">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" pointer-events="none"></path>
</svg>
`;
document.body.appendChild(this.toggleButton);
// 创建下拉容器
this.dropdownContainer = UI.createElem(
"div",
"claude-dropdown-container"
);
document.body.appendChild(this.dropdownContainer);
// 创建标题容器
const titleContainer = UI.createElem("div", "claude-title-container");
const title = UI.createElem("h2");
title.textContent = "Claude Session Key 管理器";
this.ipDisplay = UI.createElem("div", "claude-ip-display");
this.ipDisplay.textContent = "IP: 加载中...";
titleContainer.appendChild(title);
titleContainer.appendChild(this.ipDisplay);
this.dropdownContainer.appendChild(titleContainer);
// 创建 Token 网格
this.tokenGrid = UI.createElem("div", "claude-token-grid");
this.dropdownContainer.appendChild(this.tokenGrid);
// 更新 Token 网格
this.updateTokenGrid();
// 创建按钮容器
const buttonContainer = UI.createElem("div", "claude-button-container");
// 测试所有按钮
const testAllButton = UI.createButton(
"测活",
"claude-button primary",
"🔍"
);
testAllButton.setAttribute("data-tooltip", "测试所有Token是否有效");
testAllButton.addEventListener("click", () => this.testAllTokens());
buttonContainer.appendChild(testAllButton);
// 清理无效按钮
const cleanInvalidButton = UI.createButton(
"清理",
"claude-button secondary",
"🗑️"
);
cleanInvalidButton.setAttribute("data-tooltip", "清理所有无效的Token");
cleanInvalidButton.addEventListener("click", () =>
this.removeInvalidTokens()
);
buttonContainer.appendChild(cleanInvalidButton);
// 添加 Token 按钮
const addTokenButton = UI.createButton(
"添加",
"claude-button secondary",
"➕"
);
addTokenButton.setAttribute("data-tooltip", "添加新的Token");
addTokenButton.addEventListener("click", () => this.showAddTokenModal());
buttonContainer.appendChild(addTokenButton);
// 批量导入按钮
const importButton = UI.createButton(
"导入",
"claude-button secondary",
"📥"
);
importButton.setAttribute("data-tooltip", "批量导入多个Token");
importButton.addEventListener("click", () => this.showBulkImportModal());
buttonContainer.appendChild(importButton);
// 批量导出按钮
const exportButton = UI.createButton(
"导出",
"claude-button secondary",
"📤"
);
exportButton.setAttribute("data-tooltip", "导出所有Token");
exportButton.addEventListener("click", () => this.exportTokens());
buttonContainer.appendChild(exportButton);
// WebDAV备份按钮
const webdavButton = UI.createButton(
"云备份",
"claude-button secondary",
"☁️"
);
webdavButton.setAttribute("data-tooltip", "WebDAV云备份与恢复");
webdavButton.addEventListener("click", () => this.showWebDAVModal());
buttonContainer.appendChild(webdavButton);
this.dropdownContainer.appendChild(buttonContainer);
// 添加信息提示
const infoSection = UI.createElem("div", "claude-info-section", {
marginTop: "10px",
padding: "8px",
backgroundColor: "#f8f9fa",
borderRadius: "6px",
fontSize: "11px",
color: "#666",
textAlign: "center",
});
infoSection.innerHTML =
"悬停显示面板 • 拖拽按钮调整位置 • 支持WebDAV云备份";
this.dropdownContainer.appendChild(infoSection);
},
updateTokenGrid() {
this.tokenGrid.innerHTML = "";
// 获取当前使用的 token
const currentTokenName = GM_getValue(config.currentTokenKey);
// 加载测试结果
const testResults = this.loadTestResults();
this.tokens.forEach((token, index) => {
const tokenItem = UI.createElem("div", "claude-token-item");
if (token.name === currentTokenName) {
tokenItem.classList.add("current-token");
// 添加选中标记
const currentBadge = UI.createElem("div", "current-token-badge");
tokenItem.appendChild(currentBadge);
}
// Token 信息容器
const tokenInfo = UI.createElem("div", "token-info");
// 顶部行:名称和操作按钮
const topRow = UI.createElem("div", "token-top-row");
// 名称容器
const nameContainer = UI.createElem("div", "token-name-container");
const numberBadge = UI.createElem("span", "token-number");
numberBadge.textContent = `#${(index + 1).toString().padStart(2, "0")}`;
const nameSpan = UI.createElem("span", "token-name");
nameSpan.textContent = token.name;
nameContainer.appendChild(numberBadge);
nameContainer.appendChild(nameSpan);
// 操作按钮
const actions = UI.createElem("div", "token-actions");
// 编辑按钮
const editButton = UI.createElem("button", "token-action-btn edit-btn");
editButton.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
</svg>
`;
editButton.dataset.index = index;
editButton.addEventListener("click", (e) => {
e.stopPropagation();
this.showEditTokenModal(index);
});
// 删除按钮
const deleteButton = UI.createElem(
"button",
"token-action-btn delete-btn"
);
deleteButton.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
`;
deleteButton.dataset.index = index;
deleteButton.addEventListener("click", (e) => {
e.stopPropagation();
this.confirmDeleteToken(index);
});
actions.appendChild(editButton);
actions.appendChild(deleteButton);
topRow.appendChild(nameContainer);
topRow.appendChild(actions);
// 底部行:状态和时间
const bottomRow = UI.createElem("div", "token-bottom-row");
// 添加时间戳(使用token的创建时间)
const timeSpan = UI.createElem("span", "token-time");
timeSpan.textContent = token.createdAt || "";
bottomRow.appendChild(timeSpan);
// 状态指示器
const status = UI.createElem("div", "token-status");
const statusIndicator = UI.createElem("div", "status-indicator");
// 检查缓存的测试结果
const testResult = testResults[token.key];
if (testResult) {
statusIndicator.classList.add(testResult.status);
}
status.appendChild(statusIndicator);
status.addEventListener("click", async (e) => {
e.stopPropagation();
await this.testSingleToken(token, statusIndicator, bottomRow);
});
// 如果有缓存的账号类型,添加标签到状态容器内
if (testResult && testResult.accountType) {
const accountTypeLabel = UI.createElem('span', 'account-type-label');
accountTypeLabel.textContent = testResult.accountType;
status.insertBefore(accountTypeLabel, statusIndicator);
}
bottomRow.appendChild(status);
// 将行添加到信息容器
tokenInfo.appendChild(topRow);
tokenInfo.appendChild(bottomRow);
// 将信息容器添加到 token 项
tokenItem.appendChild(tokenInfo);
// 点击切换 token
tokenItem.addEventListener("click", () => this.switchToToken(token));
// 将 token 项添加到网格
this.tokenGrid.appendChild(tokenItem);
});
// 如果token数量超过4个(两行),添加滚动提示
if (this.tokens.length > 4) {
const scrollIndicator = UI.createElem("div", "scroll-indicator");
scrollIndicator.innerHTML = `
<div class="scroll-text">向下滚动查看更多 (${
this.tokens.length - 4
})</div>
<div class="scroll-arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M7 13l5 5 5-5"></path>
<path d="M7 6l5 5 5-5"></path>
</svg>
</div>
`;
this.tokenGrid.appendChild(scrollIndicator);
}
},
async switchToToken(token) {
// 检查是否有缓存的测试结果
const cachedResult = this.getTestResult(token.key);
// 如果有缓存的测试结果且为无效,提示用户并询问是否继续
if (cachedResult && cachedResult.status === "error") {
const confirmResult = await this.showConfirmDialog(
"警告",
`该 Token "${token.name}" 已被标记为无效,是否仍要切换到该 Token?`
);
if (!confirmResult) {
return;
}
}
// 应用 token
this.applyToken(token.key);
GM_setValue(config.currentTokenKey, token.name);
// 隐藏下拉菜单
this.hideDropdown();
},
applyToken(token) {
const currentURL = window.location.href;
if (currentURL.startsWith("https://claude.ai/")) {
GM_cookie.set(
{
name: "sessionKey",
value: token,
domain: ".claude.ai",
path: "/",
secure: true,
sameSite: "lax",
},
function () {
window.location.reload();
}
);
} else {
let loginUrl;
const hostname = new URL(currentURL).hostname;
if (hostname !== "claude.ai") {
loginUrl = `https://${hostname}/login_token?session_key=${token}`;
}
if (loginUrl) {
window.location.href = loginUrl;
}
}
},
async testSingleToken(token, statusIndicator, bottomRow) {
// 显示加载状态
statusIndicator.className = "status-indicator loading";
// 测试 token
const result = await this.testToken(token.key);
// 保存测试结果
this.saveTestResult(token.key, result);
// 更新状态指示器
statusIndicator.className = `status-indicator ${result.status}`;
// 添加或更新账号类型标签
this.updateAccountTypeLabel(bottomRow, result);
// 不再更新时间戳,保持显示token的创建时间
},
// 更新账号类型标签
updateAccountTypeLabel(bottomRow, result) {
// 移除现有的账号类型标签
const existingLabel = bottomRow.querySelector('.account-type-label');
if (existingLabel) {
existingLabel.remove();
}
// 如果测试成功且有账号类型信息,添加标签
if (result.status === 'success' && result.accountType) {
const accountTypeLabel = document.createElement('span');
accountTypeLabel.className = 'account-type-label';
accountTypeLabel.textContent = result.accountType;
// 找到状态容器并在状态指示器前插入标签
const statusContainer = bottomRow.querySelector('.token-status');
const statusIndicator = bottomRow.querySelector('.status-indicator');
if (statusContainer && statusIndicator) {
statusContainer.insertBefore(accountTypeLabel, statusIndicator);
}
}
},
async testAllTokens() {
// 获取所有 token 项
const tokenItems = this.tokenGrid.querySelectorAll(".claude-token-item");
// 禁用测试按钮
const testButton = this.dropdownContainer.querySelector(
".claude-button.primary"
);
testButton.disabled = true;
testButton.textContent = "测试中...";
// 清除所有缓存的测试结果
GM_setValue(config.testResultsKey, {});
const tokens = Array.from(tokenItems);
// 按4个一组处理所有tokens
for (let i = 0; i < tokens.length; i += 4) {
// 取出当前4个(或更少)token
const currentChunk = tokens.slice(i, Math.min(i + 4, tokens.length));
// 并行处理这最多4个token
await Promise.all(
currentChunk.map(async (tokenItem) => {
const index = Array.from(tokenItems).indexOf(tokenItem);
const token = this.tokens[index];
const statusIndicator =
tokenItem.querySelector(".status-indicator");
const bottomRow = tokenItem.querySelector(".token-bottom-row");
await this.testSingleToken(token, statusIndicator, bottomRow);
})
);
}
// 恢复测试按钮
testButton.disabled = false;
testButton.innerHTML = "🔍 测活";
},
async testToken(key) {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "GET",
url: "https://claude.ai/api/organizations",
headers: {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"accept-language": "en-US,en;q=0.9",
"cache-control": "max-age=0",
cookie: `sessionKey=${key}`,
"user-agent": "Mozilla/5.0 (X11; Linux x86_64)",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
},
onload: (response) => {
try {
if (response.status !== 200) {
resolve({
status: "error",
message: "无效",
});
return;
}
const responseText = response.responseText;
if (responseText.toLowerCase().includes("unauthorized")) {
resolve({
status: "error",
message: "无效",
});
return;
}
if (responseText.trim() === "") {
resolve({
status: "error",
message: "无响应",
});
return;
}
try {
const objects = JSON.parse(responseText);
if (objects && objects.length > 0) {
// 解析账号类型
const accountType = this.parseAccountType(objects[0]);
resolve({
status: "success",
message: "有效",
accountType: accountType,
organizationData: objects[0]
});
return;
}
} catch (e) {
resolve({
status: "error",
message: "解析失败",
});
return;
}
resolve({
status: "error",
message: "无效数据",
});
} catch (error) {
console.error("解析响应时发生错误:", error);
resolve({
status: "error",
message: "测试失败",
});
}
},
onerror: (error) => {
console.error("请求发生错误:", error);
resolve({
status: "error",
message: "网络错误",
});
},
ontimeout: () => {
resolve({
status: "error",
message: "超时",
});
},
});
});
},
// 解析账号类型
parseAccountType(organizationData) {
const rateLimitTier = organizationData.rate_limit_tier;
const capabilities = organizationData.capabilities || [];
const billingType = organizationData.billing_type;
const apiDisabledReason = organizationData.api_disabled_reason;
// 根据 rate_limit_tier 判断账号类型
if (rateLimitTier === "default_claude_max_5x") {
return "Max(5x)";
} else if (rateLimitTier === "default_claude_max_20x") {
return "Max(20x)";
} else if (rateLimitTier === "default_claude_ai") {
return "Free";
} else if (rateLimitTier === "auto_api_evaluation") {
if (apiDisabledReason === "out_of_credits") {
return "API(无额度)";
}
return "API";
} else if (capabilities.includes("claude_max")) {
return "Max";
} else if (capabilities.includes("api")) {
return "API";
} else if (capabilities.includes("chat")) {
return "Free";
}
return "未知";
},
loadTestResults() {
try {
const cached = GM_getValue(config.testResultsKey, {});
const now = Date.now();
// 清理过期的测试结果
const filtered = Object.entries(cached).reduce((acc, [key, value]) => {
if (now - value.timestamp < config.testResultExpiry) {
acc[key] = value;
}
return acc;
}, {});
return filtered;
} catch (error) {
console.error("加载测试结果缓存失败:", error);
return {};
}
},
saveTestResult(key, result) {
try {
const testResults = this.loadTestResults();
const now = new Date();
// 统一使用简短时间格式
const formattedTime = now.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
testResults[key] = {
status: result.status,
message: result.message,
timestamp: now.getTime(),
testTime: formattedTime, // 保存简短格式的时间
accountType: result.accountType, // 保存账号类型
organizationData: result.organizationData // 保存组织信息
};
GM_setValue(config.testResultsKey, testResults);
} catch (error) {
console.error("保存测试结果失败:", error);
}
},
getTestResult(key) {
const testResults = this.loadTestResults();
return testResults[key];
},
async removeInvalidTokens() {
const confirmResult = await this.showConfirmDialog(
"确认清理",
"是否删除所有无效的 Tokens?此操作不可撤销。"
);
if (!confirmResult) return;
const testResults = this.loadTestResults();
const validTokens = this.tokens.filter((token) => {
const result = testResults[token.key];
return !result || result.status === "success";
});
if (validTokens.length === this.tokens.length) {
alert("没有发现无效的 Tokens");
return;
}
this.tokens = validTokens;
this.saveTokens();
this.updateTokenGrid();
},
showAddTokenModal() {
const content = UI.createElem("div", "claude-add-token-form");
const nameInput = UI.createElem("input");
nameInput.placeholder = "Token 名称";
nameInput.setAttribute("aria-label", "Token 名称");
const keyInput = UI.createElem("input");
keyInput.placeholder = "Token 密钥";
keyInput.setAttribute("aria-label", "Token 密钥");
content.appendChild(nameInput);
content.appendChild(keyInput);
const { modal, buttonContainer, close } = UI.createModal(
"添加 Token",
content
);
modal
.querySelector(".claude-modal-content")
.classList.add("narrow-modal");
const addButton = UI.createButton("添加", "claude-button primary");
addButton.addEventListener("click", () => {
if (this.validateInput(nameInput.value, keyInput.value)) {
// 获取当前时间并格式化
const now = new Date();
const formattedTime = now.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
this.tokens.push({
name: nameInput.value,
key: keyInput.value,
createdAt: formattedTime, // 添加创建时间
timestamp: now.getTime(), // 添加时间戳用于排序
});
this.saveTokens();
this.updateTokenGrid();
close();
}
});
const cancelButton = UI.createButton("取消", "claude-button secondary");
cancelButton.addEventListener("click", close);
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(addButton);
},
showEditTokenModal(index) {
const token = this.tokens[index];
const content = UI.createElem("div", "claude-edit-token-form");
const nameInput = UI.createElem("input");
nameInput.value = token.name;
nameInput.placeholder = "Token 名称";
const keyInput = UI.createElem("input");
keyInput.value = token.key;
keyInput.placeholder = "Token 密钥";
content.appendChild(nameInput);
content.appendChild(keyInput);
const { modal, buttonContainer, close } = UI.createModal(
"编辑 Token",
content
);
modal
.querySelector(".claude-modal-content")
.classList.add("narrow-modal");
const saveButton = UI.createButton("保存", "claude-button primary");
saveButton.addEventListener("click", () => {
if (this.validateInput(nameInput.value, keyInput.value)) {
// 保留原有的创建时间和时间戳
this.tokens[index] = {
name: nameInput.value,
key: keyInput.value,
createdAt: token.createdAt || "",
// 保留原有的创建时间
timestamp: token.timestamp || Date.now(), // 保留原有的时间戳
};
this.saveTokens();
this.updateTokenGrid();
close();
}
});
const cancelButton = UI.createButton("取消", "claude-button secondary");
cancelButton.addEventListener("click", close);
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(saveButton);
},
confirmDeleteToken(index) {
const token = this.tokens[index];
const content = UI.createElem("div", "claude-delete-confirm");
content.innerHTML = `
<div style="font-size: 48px; text-align: center; margin-bottom: 16px;">⚠️</div>
<div style="font-size: 18px; font-weight: 600; text-align: center; margin-bottom: 12px;">删除确认</div>
<div style="text-align: center; margin-bottom: 24px;">
您确定要删除 Token "${token.name}" 吗?<br>
此操作无法撤销。
</div>
`;
const { modal, buttonContainer, close } = UI.createModal("", content);
modal
.querySelector(".claude-modal-content")
.classList.add("narrow-modal");
const deleteButton = UI.createButton("删除", "claude-button primary");
deleteButton.style.backgroundColor = "#e53e3e";
deleteButton.addEventListener("click", () => {
this.deleteToken(index);
close();
});
const cancelButton = UI.createButton("取消", "claude-button secondary");
cancelButton.addEventListener("click", close);
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(deleteButton);
},
deleteToken(index) {
this.tokens.splice(index, 1);
this.saveTokens();
this.updateTokenGrid();
},
showBulkImportModal() {
const content = UI.createElem("div", "claude-bulk-import-form");
// 文本区域标签
const textareaLabel = UI.createElem("label");
textareaLabel.innerHTML =
"<strong>1️⃣ Tokens 粘贴区:</strong><br>在这里粘贴您需要导入的 Tokens,每行一个!";
content.appendChild(textareaLabel);
// 文本区域
const textarea = UI.createElem("textarea");
textarea.rows = 10;
content.appendChild(textarea);
// 命名规则容器
const namingRuleContainer = UI.createElem("div", "claude-naming-rule");
// 命名规则标签
const namingRuleLabel = UI.createElem("label");
namingRuleLabel.innerHTML = "<strong>2️⃣ Tokens 命名规则:</strong>";
namingRuleContainer.appendChild(namingRuleLabel);
// 名称前缀
const prefixLabel = UI.createElem("label");
prefixLabel.textContent = "名称前缀:";
namingRuleContainer.appendChild(prefixLabel);
const prefixInput = UI.createElem("input");
prefixInput.value = "token";
namingRuleContainer.appendChild(prefixInput);
// 起始编号
const startNumberLabel = UI.createElem("label");
startNumberLabel.textContent = "名称起始编号:";
namingRuleContainer.appendChild(startNumberLabel);
const startNumberInput = UI.createElem("input");
startNumberInput.type = "number";
startNumberInput.value = "1";
namingRuleContainer.appendChild(startNumberInput);
content.appendChild(namingRuleContainer);
// 预览容器
const previewLabel = UI.createElem("label");
previewLabel.innerHTML = "<strong>3️⃣ Tokens 导入结果预览:</strong>";
content.appendChild(previewLabel);
const previewContainer = UI.createElem("div", "claude-preview-container");
content.appendChild(previewContainer);
const { modal, buttonContainer, close } = UI.createModal(
"批量导入 Tokens",
content
);
const importButton = UI.createButton("导入", "claude-button primary");
importButton.addEventListener("click", () => {
this.performBulkImport(
textarea.value,
prefixInput.value,
startNumberInput.value
);
close();
});
const cancelButton = UI.createButton("取消", "claude-button secondary");
cancelButton.addEventListener("click", close);
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(importButton);
// 更新预览
const updatePreview = () => {
this.previewBulkImport(
textarea.value,
prefixInput.value,
startNumberInput.value,
previewContainer
);
};
[textarea, prefixInput, startNumberInput].forEach((elem) => {
elem.addEventListener("input", updatePreview);
});
// 初始化预览
updatePreview();
},
previewBulkImport(input, namePrefix, startNumber, previewContainer) {
previewContainer.innerHTML = "";
const tokens = this.parseTokens(input);
const namedTokens = this.applyNamingRule(
tokens,
namePrefix,
parseInt(startNumber)
);
const previewTitle = UI.createElem("div", "claude-preview-title");
previewTitle.textContent = "请核对下方导入结果:";
previewContainer.appendChild(previewTitle);
if (namedTokens.length === 0) {
const emptyMessage = UI.createElem("div", "claude-preview-item");
emptyMessage.textContent = "等待输入...";
previewContainer.appendChild(emptyMessage);
return;
}
namedTokens.forEach((token) => {
const previewItem = UI.createElem("div", "claude-preview-item");
previewItem.innerHTML = `
<strong>${token.name}:</strong>
<span style="font-family: monospace; word-break: break-all;">${token.key}</span>
`;
previewContainer.appendChild(previewItem);
});
},
performBulkImport(input, namePrefix, startNumber) {
const tokens = this.parseTokens(input);
const namedTokens = this.applyNamingRule(
tokens,
namePrefix,
parseInt(startNumber)
);
if (namedTokens.length === 0) {
alert("没有有效的 Tokens 可导入");
return;
}
this.tokens = [...this.tokens, ...namedTokens];
this.saveTokens();
this.updateTokenGrid();
},
parseTokens(input) {
return input
.split("\n")
.map((line) => line.trim())
.filter((line) => this.validateTokenKey(line))
.map((key) => ({
key,
}));
},
applyNamingRule(tokens, namePrefix, startNumber) {
return tokens.map((token, index) => {
const number = startNumber + index;
const name = `${namePrefix}${number.toString().padStart(2, "0")}`;
return {
...token,
name,
};
});
},
exportTokens() {
const testResults = this.loadTestResults();
const exportData = this.tokens.map((token) => {
const testResult = testResults[token.key] || {};
return {
name: token.name,
sessionKey: token.key,
isValid:
testResult.status === "success"
? true
: testResult.status === "error"
? false
: null,
testTime: testResult.testTime || null,
testMessage: testResult.message || null,
};
});
// 创建并下载 JSON 文件
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `claude_tokens_${
new Date().toISOString().split("T")[0]
}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
},
showWebDAVModal() {
const content = UI.createElem("div", "claude-webdav-form");
content.style.cssText = "width: 100%; max-width: 600px;";
// 添加帮助信息
const helpInfo = UI.createElem("div", "claude-webdav-help");
helpInfo.style.cssText = `
margin-bottom: 10px;
padding: 12px;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 13px;
color: var(--text-color);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
`;
helpInfo.innerHTML = `
<p style="margin: 0 0 10px 0; font-weight: 600; color: var(--text-color);">📝 WebDAV服务器设置说明:</p>
<ul style="margin: 0; padding-left: 20px; line-height: 1.6;">
<li>URL必须是完整的WebDAV路径,例如:https://dav.jianguoyun.com/dav/Claude/</li>
<li>确保路径末尾有斜杠"/"</li>
<li>如果遇到404错误,请确认路径是否存在</li>
<li>坚果云用户请使用应用专用密码</li>
</ul>
`;
content.appendChild(helpInfo);
// 创建表单容器
const formContainer = UI.createElem(
"div",
"claude-webdav-form-container"
);
formContainer.style.cssText = `
display: grid;
gap: 8px;
margin-bottom: 15px;
`;
// WebDAV服务器URL输入
const urlGroup = UI.createElem("div", "input-group");
urlGroup.style.cssText = "display: flex; align-items: center; gap: 10px;";
const urlLabel = UI.createElem("label");
urlLabel.textContent = "WebDAV URL:";
urlLabel.style.cssText =
"font-weight: 500; color: var(--text-color); min-width: 120px; text-align: right;";
const urlInput = UI.createElem("input");
urlInput.type = "text";
urlInput.placeholder = "https://dav.jianguoyun.com/dav/Claude/";
urlInput.value = GM_getValue("webdav_url", "");
urlInput.style.cssText = `
flex: 1;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
background-color: var(--bg-color);
color: var(--text-color);
transition: all 0.3s ease;
`;
urlGroup.appendChild(urlLabel);
urlGroup.appendChild(urlInput);
formContainer.appendChild(urlGroup);
// 用户名输入
const usernameGroup = UI.createElem("div", "input-group");
usernameGroup.style.cssText =
"display: flex; align-items: center; gap: 10px;";
const usernameLabel = UI.createElem("label");
usernameLabel.textContent = "用户名:";
usernameLabel.style.cssText =
"font-weight: 500; color: var(--text-color); min-width: 120px; text-align: right;";
const usernameInput = UI.createElem("input");
usernameInput.type = "text";
usernameInput.placeholder = "用户名";
usernameInput.value = GM_getValue("webdav_username", "");
usernameInput.style.cssText = `
flex: 1;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
background-color: var(--bg-color);
color: var(--text-color);
transition: all 0.3s ease;
`;
usernameGroup.appendChild(usernameLabel);
usernameGroup.appendChild(usernameInput);
formContainer.appendChild(usernameGroup);
// 密码输入
const passwordGroup = UI.createElem("div", "input-group");
passwordGroup.style.cssText =
"display: flex; align-items: center; gap: 10px;";
const passwordLabel = UI.createElem("label");
passwordLabel.textContent = "密码:";
passwordLabel.style.cssText =
"font-weight: 500; color: var(--text-color); min-width: 120px; text-align: right;";
const passwordInput = UI.createElem("input");
passwordInput.type = "password";
passwordInput.placeholder = "密码";
passwordInput.value = GM_getValue("webdav_password", "");
passwordInput.style.cssText = `
flex: 1;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
background-color: var(--bg-color);
color: var(--text-color);
transition: all 0.3s ease;
`;
passwordGroup.appendChild(passwordLabel);
passwordGroup.appendChild(passwordInput);
formContainer.appendChild(passwordGroup);
// 文件名输入
const filenameGroup = UI.createElem("div", "input-group");
filenameGroup.style.cssText =
"display: flex; align-items: center; gap: 10px;";
const filenameLabel = UI.createElem("label");
filenameLabel.textContent = "文件名:";
filenameLabel.style.cssText =
"font-weight: 500; color: var(--text-color); min-width: 120px; text-align: right;";
const filenameInput = UI.createElem("input");
filenameInput.type = "text";
filenameInput.placeholder = "claude_tokens.json";
filenameInput.value = GM_getValue(
"webdav_filename",
"claude_tokens.json"
);
filenameInput.style.cssText = `
flex: 1;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
background-color: var(--bg-color);
color: var(--text-color);
transition: all 0.3s ease;
`;
filenameGroup.appendChild(filenameLabel);
filenameGroup.appendChild(filenameInput);
formContainer.appendChild(filenameGroup);
content.appendChild(formContainer);
// 测试连接按钮
const testConnectionButton = UI.createButton(
"测试连接",
"claude-button secondary"
);
testConnectionButton.style.cssText = `
width: 100%;
margin: 10px 0;
padding: 10px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
background-color: var(--button-bg);
color: var(--text-color);
border: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.3s ease;
`;
testConnectionButton.addEventListener("click", async () => {
this.saveWebDAVSettings(
urlInput.value,
usernameInput.value,
passwordInput.value,
filenameInput.value
);
statusDisplay.style.display = "block";
statusDisplay.textContent = "正在测试连接...";
statusDisplay.style.backgroundColor = "var(--bg-color)";
try {
await this.checkWebDAVDirectory(
urlInput.value,
usernameInput.value,
passwordInput.value
);
statusDisplay.textContent = "✅ 连接成功!目录存在且可访问。";
statusDisplay.style.backgroundColor = "#d4edda";
} catch (error) {
statusDisplay.textContent = `❌ 连接失败: ${error.message}`;
statusDisplay.style.backgroundColor = "#f8d7da";
}
});
content.appendChild(testConnectionButton);
// 状态显示
const statusDisplay = UI.createElem("div", "claude-webdav-status");
statusDisplay.style.cssText = `
margin: 10px 0;
padding: 10px;
border-radius: 6px;
font-size: 14px;
text-align: center;
display: none;
transition: all 0.3s ease;
`;
content.appendChild(statusDisplay);
// 操作按钮容器
const actionsContainer = UI.createElem("div", "claude-webdav-actions");
actionsContainer.style.cssText = `
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 15px;
`;
// 备份按钮
const backupButton = UI.createButton(
"备份到WebDAV",
"claude-button primary"
);
backupButton.style.cssText = `
padding: 12px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
background-color: #b3462f;
color: white;
border: none;
cursor: pointer;
transition: all 0.3s ease;
`;
backupButton.addEventListener("click", async () => {
this.saveWebDAVSettings(
urlInput.value,
usernameInput.value,
passwordInput.value,
filenameInput.value
);
statusDisplay.style.display = "block";
statusDisplay.textContent = "正在备份...";
statusDisplay.style.backgroundColor = "var(--bg-color)";
try {
await this.backupToWebDAV(
urlInput.value,
usernameInput.value,
passwordInput.value,
filenameInput.value
);
statusDisplay.textContent = "✅ 备份成功!";
statusDisplay.style.backgroundColor = "#d4edda";
} catch (error) {
statusDisplay.textContent = `❌ 备份失败: ${error.message}`;
statusDisplay.style.backgroundColor = "#f8d7da";
}
});
// 恢复按钮
const restoreButton = UI.createButton(
"从WebDAV恢复",
"claude-button secondary"
);
restoreButton.style.cssText = `
padding: 12px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
background-color: var(--button-bg);
color: var(--text-color);
border: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.3s ease;
`;
restoreButton.addEventListener("click", async () => {
this.saveWebDAVSettings(
urlInput.value,
usernameInput.value,
passwordInput.value,
filenameInput.value
);
statusDisplay.style.display = "block";
statusDisplay.textContent = "正在恢复...";
statusDisplay.style.backgroundColor = "var(--bg-color)";
try {
await this.restoreFromWebDAV(
urlInput.value,
usernameInput.value,
passwordInput.value,
filenameInput.value
);
statusDisplay.textContent = "✅ 恢复成功!";
statusDisplay.style.backgroundColor = "#d4edda";
this.updateTokenGrid();
} catch (error) {
statusDisplay.textContent = `❌ 恢复失败: ${error.message}`;
statusDisplay.style.backgroundColor = "#f8d7da";
}
});
// 关闭按钮
const closeButton = UI.createButton("关闭", "claude-button secondary");
closeButton.style.cssText = `
padding: 12px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
background-color: var(--button-bg);
color: var(--text-color);
border: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.3s ease;
`;
closeButton.addEventListener("click", () => {
modal.remove();
});
actionsContainer.appendChild(backupButton);
actionsContainer.appendChild(restoreButton);
actionsContainer.appendChild(closeButton);
content.appendChild(actionsContainer);
// 创建模态框
const { modal, buttonContainer } = UI.createModal(
"☁️ WebDAV备份与恢复",
content,
true
);
document.body.appendChild(modal);
// 添加关闭按钮事件监听
const closeBtn = modal.querySelector(".claude-close-button");
if (closeBtn) {
closeBtn.addEventListener("click", () => {
document.body.removeChild(modal);
});
}
},
saveWebDAVSettings(url, username, password, filename) {
GM_setValue("webdav_url", url);
GM_setValue("webdav_username", username);
GM_setValue("webdav_password", password);
GM_setValue("webdav_filename", filename);
},
async backupToWebDAV(url, username, password, filename) {
// 准备备份数据
const testResults = this.loadTestResults();
const exportData = this.tokens.map((token) => {
const testResult = testResults[token.key] || {};
return {
name: token.name,
sessionKey: token.key,
isValid:
testResult.status === "success"
? true
: testResult.status === "error"
? false
: null,
testTime: testResult.testTime || null,
testMessage: testResult.message || null,
};
});
const jsonData = JSON.stringify(exportData, null, 2);
// 确保URL以/结尾
if (!url.endsWith("/")) {
url += "/";
}
// 先检查目录是否存在
try {
await this.checkWebDAVDirectory(url, username, password);
} catch (error) {
// 如果是404错误,尝试创建目录
if (error.message.includes("404")) {
try {
// 尝试创建父目录
const parentUrl = url.substring(
0,
url.lastIndexOf("/", url.length - 2) + 1
);
if (parentUrl !== url) {
await this.createWebDAVDirectory(
parentUrl,
username,
password,
url.substring(parentUrl.length, url.length - 1)
);
} else {
throw new Error("无法确定父目录");
}
} catch (createError) {
throw new Error(`目录不存在且无法创建: ${createError.message}`);
}
} else {
throw error;
}
}
// 发送到WebDAV服务器
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "PUT",
url: url + filename,
headers: {
"Content-Type": "application/json",
Authorization: "Basic " + btoa(username + ":" + password),
},
data: jsonData,
onload: function (response) {
if (response.status >= 200 && response.status < 300) {
resolve();
} else {
console.error("WebDAV备份失败:", response);
reject(
new Error(
`HTTP错误: ${response.status} ${
response.statusText || ""
}\n响应: ${response.responseText || "无响应内容"}`
)
);
}
},
onerror: function (error) {
console.error("WebDAV备份网络错误:", error);
reject(new Error(`网络错误: ${error.statusText || "连接失败"}`));
},
});
});
},
// 检查WebDAV目录是否存在
checkWebDAVDirectory(url, username, password) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "PROPFIND",
url: url,
headers: {
Depth: "0",
Authorization: "Basic " + btoa(username + ":" + password),
},
onload: function (response) {
if (response.status >= 200 && response.status < 300) {
resolve();
} else {
reject(
new Error(
`HTTP错误: ${response.status} ${response.statusText || ""}`
)
);
}
},
onerror: function (error) {
reject(new Error(`网络错误: ${error.statusText || "连接失败"}`));
},
});
});
},
// 创建WebDAV目录
createWebDAVDirectory(parentUrl, username, password, dirName) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "MKCOL",
url: parentUrl + dirName,
headers: {
Authorization: "Basic " + btoa(username + ":" + password),
},
onload: function (response) {
if (response.status >= 200 && response.status < 300) {
resolve();
} else {
reject(
new Error(
`无法创建目录: HTTP错误 ${response.status} ${
response.statusText || ""
}`
)
);
}
},
onerror: function (error) {
reject(new Error(`网络错误: ${error.statusText || "连接失败"}`));
},
});
});
},
async restoreFromWebDAV(url, username, password, filename) {
// 确保URL以/结尾
if (!url.endsWith("/")) {
url += "/";
}
// 从WebDAV服务器获取数据
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url + filename,
headers: {
Authorization: "Basic " + btoa(username + ":" + password),
},
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
try {
const data = JSON.parse(response.responseText);
// 转换数据格式
const tokens = data.map((item) => ({
name: item.name,
key: item.sessionKey,
createdAt: new Date().toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}),
timestamp: Date.now(),
}));
// 更新tokens
this.tokens = tokens;
this.saveTokens();
resolve();
} catch (error) {
console.error(
"解析WebDAV数据失败:",
error,
response.responseText
);
reject(new Error(`解析数据失败: ${error.message}`));
}
} else {
console.error("WebDAV恢复失败:", response);
reject(
new Error(
`HTTP错误: ${response.status} ${
response.statusText || ""
}\n响应: ${response.responseText || "无响应内容"}`
)
);
}
},
onerror: function (error) {
console.error("WebDAV恢复网络错误:", error);
reject(new Error(`网络错误: ${error.statusText || "连接失败"}`));
},
});
});
},
validateInput(name, key) {
if (!name || !key) {
alert("Token 名称和密钥都要填写!");
return false;
}
// 移除对token名称的严格限制,允许更多字符,包括@和.
// if (!/^[a-zA-Z0-9-_]+$/.test(name)) {
// alert("Token 名称只能包含字母、数字、下划线和连字符!");
// return false;
// }
if (!this.validateTokenKey(key)) {
alert("无效的 Token 密钥格式!");
return false;
}
return true;
},
validateTokenKey(key) {
return /^sk-ant-sid\d{2}-[A-Za-z0-9_-]*$/.test(key);
},
showConfirmDialog(title, message) {
return new Promise((resolve) => {
const content = UI.createElem("div", "claude-confirm-dialog");
content.innerHTML = `
<div style="font-size: 48px; text-align: center; margin-bottom: 16px;">⚠️</div>
<div style="font-size: 18px; font-weight: 600; text-align: center; margin-bottom: 12px;">${title}</div>
<div style="text-align: center; margin-bottom: 24px;">${message}</div>
`;
const { modal, buttonContainer, close } = UI.createModal("", content);
modal
.querySelector(".claude-modal-content")
.classList.add("narrow-modal");
const confirmButton = UI.createButton("确认", "claude-button primary");
confirmButton.addEventListener("click", () => {
close();
resolve(true);
});
const cancelButton = UI.createButton("取消", "claude-button secondary");
cancelButton.addEventListener("click", () => {
close();
resolve(false);
});
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(confirmButton);
});
},
fetchIPCountryCode() {
this.ipDisplay.textContent = "IP: 加载中...";
GM_xmlhttpRequest({
method: "GET",
url: config.ipApiUrl,
onload: (response) => {
if (response.status === 200) {
this.ipDisplay.textContent = "IP: " + response.responseText.trim();
} else {
this.ipDisplay.textContent = "IP: 获取失败";
}
},
onerror: () => {
this.ipDisplay.textContent = "IP: 获取失败";
},
});
},
setupEventListeners() {
// 拖拽相关事件
this.toggleButton.addEventListener(
"mousedown",
this.onMouseDown.bind(this)
);
document.addEventListener("mousemove", this.onMouseMove.bind(this));
document.addEventListener("mouseup", this.onMouseUp.bind(this));
// 状态管理对象
this.state = {
isDropdownVisible: false,
isDragging: false,
isProcessingClick: false, // 处理点击状态
isClosing: false, // 窗口正在关闭的状态
};
// 定时器
this.closeTimeout = null;
// 移除 hover 展开逻辑,改为纯点击展开
// 按钮点击事件
this.toggleButton.addEventListener("click", (e) => {
if (this.state.isDragging) return; // 如果正在拖拽,忽略点击
this.state.isProcessingClick = true;
clearTimeout(this.closeTimeout);
if (this.state.isDropdownVisible) {
this.hideDropdown();
} else {
this.showDropdown();
}
setTimeout(() => {
this.state.isProcessingClick = false;
}, 100);
});
// 移除弹窗 hover 逻辑
// 点击其他区域关闭下拉菜单
document.addEventListener("click", (e) => {
if (
this.dropdownContainer.style.display === "flex" &&
!this.dropdownContainer.contains(e.target) &&
e.target !== this.toggleButton
) {
this.hideDropdown();
}
});
// 添加触摸事件支持
const isMobile = window.innerWidth <= 768;
if (isMobile) {
// 移动端触摸事件
this.toggleButton.addEventListener("touchstart", (e) => {
e.preventDefault();
this.state.isProcessingClick = true;
clearTimeout(this.closeTimeout);
if (this.state.isDropdownVisible) {
this.hideDropdown();
} else {
this.showDropdown();
}
setTimeout(() => {
this.state.isProcessingClick = false;
}, 100);
});
// 点击其他区域关闭 - 触摸版本
document.addEventListener("touchstart", (e) => {
if (
this.dropdownContainer.style.display === "flex" &&
!this.dropdownContainer.contains(e.target) &&
e.target !== this.toggleButton
) {
this.hideDropdown();
}
});
}
},
onMouseDown(e) {
const isMobile = window.innerWidth <= 768;
if (isMobile) {
// 移动端禁用拖拽功能
return;
}
if (e.button !== 0) return; // 只处理左键点击
this.isDragging = true;
this.state.isDragging = true;
this.startX = e.clientX;
this.startY = e.clientY;
const rect = this.toggleButton.getBoundingClientRect();
this.offsetX = this.startX - rect.left;
this.offsetY = this.startY - rect.top;
this.toggleButton.style.cursor = "grabbing";
// 清除关闭定时器
clearTimeout(this.closeTimeout);
// 阻止默认行为和事件冒泡
e.preventDefault();
e.stopPropagation();
},
onMouseMove(e) {
if (!this.isDragging) return;
const x = e.clientX - this.offsetX;
const y = e.clientY - this.offsetY;
// 计算底部位置
const bottom = window.innerHeight - y - this.toggleButton.offsetHeight;
// 确保按钮在窗口范围内
const maxX = window.innerWidth - this.toggleButton.offsetWidth;
const maxBottom = window.innerHeight - this.toggleButton.offsetHeight;
this.buttonLeft = Math.max(0, Math.min(x, maxX));
this.buttonBottom = Math.max(0, Math.min(bottom, maxBottom));
// 更新按钮位置
this.toggleButton.style.left = `${this.buttonLeft}px`;
this.toggleButton.style.bottom = `${this.buttonBottom}px`;
this.toggleButton.style.top = "auto";
// 如果下拉窗口可见,更新其位置
if (this.state.isDropdownVisible) {
this.updateDropdownPosition();
}
e.preventDefault();
},
// 智能下拉窗口位置计算方法
updateDropdownPosition() {
const buttonRect = this.toggleButton.getBoundingClientRect();
const dropdownWidth = 600; // 下拉窗口宽度
const margin = 10; // 边距
// 获取窗口尺寸
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
// 预估下拉窗口高度
const dropdownHeight = Math.min(
this.dropdownContainer.scrollHeight || 400,
windowHeight * 0.8
);
// 计算按钮在屏幕中的位置比例
const buttonCenterX = buttonRect.left + buttonRect.width / 2;
const buttonCenterY = buttonRect.top + buttonRect.height / 2;
// 检测按钮是否在各个角落区域
const isLeftSide = buttonCenterX < windowWidth * 0.3;
const isRightSide = buttonCenterX > windowWidth * 0.7;
const isTopSide = buttonCenterY < windowHeight * 0.3;
const isBottomSide = buttonCenterY > windowHeight * 0.7;
// 计算各个方向的可用空间
const spaceRight = windowWidth - buttonRect.right - margin;
const spaceLeft = buttonRect.left - margin;
const spaceBelow = windowHeight - buttonRect.bottom - margin;
const spaceAbove = buttonRect.top - margin;
let left, top;
let preferredDirection = '';
// 智能决策展开方向
if (isLeftSide && isTopSide) {
// 左上角:优先向右下展开
if (spaceRight >= dropdownWidth && spaceBelow >= dropdownHeight) {
left = buttonRect.right + margin;
top = buttonRect.top;
preferredDirection = 'right-down';
} else if (spaceRight >= dropdownWidth) {
left = buttonRect.right + margin;
top = Math.max(margin, windowHeight - dropdownHeight - margin);
preferredDirection = 'right-up';
} else if (spaceBelow >= dropdownHeight) {
left = Math.max(margin, buttonRect.left);
top = buttonRect.bottom + margin;
preferredDirection = 'down-right';
} else {
// 空间不足,居中显示
left = Math.max(margin, (windowWidth - dropdownWidth) / 2);
top = Math.max(margin, (windowHeight - dropdownHeight) / 2);
preferredDirection = 'center';
}
} else if (isRightSide && isTopSide) {
// 右上角:优先向左下展开
if (spaceLeft >= dropdownWidth && spaceBelow >= dropdownHeight) {
left = buttonRect.left - dropdownWidth - margin;
top = buttonRect.top;
preferredDirection = 'left-down';
} else if (spaceLeft >= dropdownWidth) {
left = buttonRect.left - dropdownWidth - margin;
top = Math.max(margin, windowHeight - dropdownHeight - margin);
preferredDirection = 'left-up';
} else if (spaceBelow >= dropdownHeight) {
left = Math.max(margin, buttonRect.right - dropdownWidth);
top = buttonRect.bottom + margin;
preferredDirection = 'down-left';
} else {
left = Math.max(margin, (windowWidth - dropdownWidth) / 2);
top = Math.max(margin, (windowHeight - dropdownHeight) / 2);
preferredDirection = 'center';
}
} else if (isLeftSide && isBottomSide) {
// 左下角:优先向右上展开
if (spaceRight >= dropdownWidth && spaceAbove >= dropdownHeight) {
left = buttonRect.right + margin;
// 确保向上展开时与按钮底部对齐,但不超出顶部边距
top = Math.max(margin, buttonRect.bottom - dropdownHeight);
preferredDirection = 'right-up';
} else if (spaceRight >= dropdownWidth) {
left = buttonRect.right + margin;
// 如果空间不足,尽可能向上,但保持顶部边距
top = Math.max(margin, Math.min(buttonRect.top, windowHeight - dropdownHeight - margin));
preferredDirection = 'right-up';
} else if (spaceAbove >= dropdownHeight) {
left = Math.max(margin, buttonRect.left);
// 向上展开时,确保与按钮顶部有间距,并保持顶部边距
top = Math.max(margin, buttonRect.top - dropdownHeight - margin);
preferredDirection = 'up-right';
} else {
left = Math.max(margin, (windowWidth - dropdownWidth) / 2);
top = Math.max(margin, (windowHeight - dropdownHeight) / 2);
preferredDirection = 'center';
}
} else if (isRightSide && isBottomSide) {
// 右下角:优先向左上展开
if (spaceLeft >= dropdownWidth && spaceAbove >= dropdownHeight) {
left = buttonRect.left - dropdownWidth - margin;
// 确保向上展开时与按钮底部对齐,但不超出顶部边距
top = Math.max(margin, buttonRect.bottom - dropdownHeight);
preferredDirection = 'left-up';
} else if (spaceLeft >= dropdownWidth) {
left = buttonRect.left - dropdownWidth - margin;
// 如果空间不足,尽可能向上,但保持顶部边距
top = Math.max(margin, Math.min(buttonRect.top, windowHeight - dropdownHeight - margin));
preferredDirection = 'left-up';
} else if (spaceAbove >= dropdownHeight) {
left = Math.max(margin, buttonRect.right - dropdownWidth);
// 向上展开时,确保与按钮顶部有间距,并保持顶部边距
top = Math.max(margin, buttonRect.top - dropdownHeight - margin);
preferredDirection = 'up-left';
} else {
left = Math.max(margin, (windowWidth - dropdownWidth) / 2);
top = Math.max(margin, (windowHeight - dropdownHeight) / 2);
preferredDirection = 'center';
}
} else {
// 非角落区域,使用原有逻辑
// 如果是底部区域,需要特殊处理向上展开
if (isBottomSide) {
// 底部区域:优先水平展开,如果空间不足则向上展开
if (spaceRight >= dropdownWidth) {
left = buttonRect.right + margin;
// 底部向右展开时,尽量与按钮底部对齐,但确保不超出顶部
top = Math.max(margin, buttonRect.bottom - dropdownHeight);
preferredDirection = 'right-up';
} else if (spaceLeft >= dropdownWidth) {
left = buttonRect.left - dropdownWidth - margin;
// 底部向左展开时,尽量与按钮底部对齐,但确保不超出顶部
top = Math.max(margin, buttonRect.bottom - dropdownHeight);
preferredDirection = 'left-up';
} else {
// 水平空间不足,居中向上展开
left = Math.max(margin, (windowWidth - dropdownWidth) / 2);
// 确保向上展开时保持顶部边距
top = Math.max(margin, buttonRect.top - dropdownHeight - margin);
preferredDirection = 'center-up';
}
} else {
// 非底部区域,使用原有逻辑
// 优先右侧显示
if (spaceRight >= dropdownWidth) {
left = buttonRect.right + margin;
top = Math.max(margin, Math.min(buttonRect.top, windowHeight - dropdownHeight - margin));
preferredDirection = 'right';
} else if (spaceLeft >= dropdownWidth) {
left = buttonRect.left - dropdownWidth - margin;
top = Math.max(margin, Math.min(buttonRect.top, windowHeight - dropdownHeight - margin));
preferredDirection = 'left';
} else {
// 水平空间不足,居中显示
left = Math.max(margin, (windowWidth - dropdownWidth) / 2);
top = Math.max(margin, Math.min(buttonRect.top, windowHeight - dropdownHeight - margin));
preferredDirection = 'center';
}
}
}
// 最终边界检查,确保不会超出屏幕
left = Math.max(margin, Math.min(left, windowWidth - dropdownWidth - margin));
top = Math.max(margin, Math.min(top, windowHeight - dropdownHeight - margin));
// 应用新位置
this.dropdownContainer.style.left = `${left}px`;
this.dropdownContainer.style.top = `${top}px`;
// 可选:添加调试信息
if (window.claudeDebug) {
console.log(`下拉窗口位置决策: ${preferredDirection}`, {
buttonRect,
windowSize: { width: windowWidth, height: windowHeight },
spaces: { left: spaceLeft, right: spaceRight, above: spaceAbove, below: spaceBelow },
position: { left, top },
dropdownSize: { width: dropdownWidth, height: dropdownHeight }
});
}
},
onMouseUp(e) {
if (!this.isDragging) return;
this.isDragging = false;
this.state.isDragging = false;
this.toggleButton.style.cursor = "move";
// 保存位置
GM_setValue("buttonLeft", this.buttonLeft);
GM_setValue("buttonBottom", this.buttonBottom);
// 移除拖拽结束后的 hover 逻辑
e.preventDefault();
},
// 移除 scheduleHideDropdown 方法,因为不再需要 hover 逻辑
showDropdown() {
// 立即更新状态
this.state.isDropdownVisible = true;
this.state.isClosing = false;
const isMobile = window.innerWidth <= 768;
if (isMobile) {
// 移动端固定全屏显示
this.dropdownContainer.style.top = "10px";
this.dropdownContainer.style.left = "10px";
this.dropdownContainer.style.right = "10px";
this.dropdownContainer.style.bottom = "auto";
this.dropdownContainer.style.width = "auto";
} else {
// 桌面端计算位置
this.updateDropdownPosition();
}
this.dropdownContainer.style.opacity = "0";
this.dropdownContainer.style.display = "flex";
setTimeout(() => {
this.dropdownContainer.style.opacity = "1";
this.dropdownContainer.style.transform = "scale(1)";
this.toggleButton.style.transform = "scale(1.1)";
}, 10);
},
hideDropdown() {
// 设置正在关闭状态
this.state.isClosing = true;
// 添加动画
this.dropdownContainer.style.opacity = "0";
this.dropdownContainer.style.transform = "scale(0.95)";
this.toggleButton.style.transform = "scale(1)";
// 等待动画完成后隐藏
this.closeTimeout = setTimeout(() => {
this.dropdownContainer.style.display = "none";
this.state.isDropdownVisible = false;
this.state.isClosing = false; // 重置关闭状态
}, 300);
},
observeThemeChanges() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === "attributes" &&
mutation.attributeName === "data-mode"
) {
this.isDarkMode =
document.documentElement.getAttribute("data-mode") === "dark";
this.updateStyles();
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-mode"],
});
},
};
// 初始化应用
App.init();
})();