// ==UserScript==
// @name CLI Proxy API 检测提供商是否可用
// @namespace cli-proxy-api-management
// @version 6.1.0
// @description 上传 YAML 检测 OpenAI 兼容提供商,自动同步启用状态,支持断点续跑、日志筛选、CSV 导出、拖动与折叠
// @match http://127.0.0.1:8317/management.html*
// @match http://localhost:8317/management.html*
// @require https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js
// @connect *
// @grant GM_xmlhttpRequest
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
/* =========================================================
* 配置
* ========================================================= */
const CONFIG = {
panelId: 'tm-yaml-provider-checker',
styleId: 'tm-yaml-provider-checker-style',
settingsKey: 'tm-yaml-provider-checker-settings-v6',
checkpointKey: 'tm-yaml-provider-checker-checkpoint-v6',
// 单个 HTTP 请求超时时间
requestTimeout: 20000,
// 每个 API Key 最多检测次数
requestAttempts: 2,
// 同一个 API Key 重试前的等待时间
requestRetryDelay: 1200,
// 不同 API Key 之间的等待时间
keyDelay: 250,
// 不同提供商之间的等待时间
providerDelay: 500,
// 点击页面开关后等待状态改变的最长时间
switchTimeout: 10000,
// 页面开关状态轮询间隔
switchPollInterval: 100,
// 开关状态改变后的额外等待时间
afterSwitchDelay: 700,
/*
* false:
* GET /models 成功即可判定密钥可用。
*
* true:
* /models 成功后还需要发送最小聊天请求。
* 检测更严格,但可能产生少量费用。
*/
requireChatTest: false,
/*
* 当 /models 返回 404、405 或 501 时,
* 是否回退请求 /chat/completions。
*/
fallbackToChat: true,
/*
* 402 余额不足是否算明确失败:
*
* false:归类为异常,保持开关状态。
* true:归类为失败,所有 Key 均失败时关闭。
*/
paymentRequiredIsFailure: false
};
const Result = Object.freeze({
OK: 'ok',
FAILED: 'failed',
ABNORMAL: 'abnormal',
STOPPED: 'stopped'
});
const LogType = Object.freeze({
SUCCESS: 'success',
FAILED: 'failed',
ABNORMAL: 'abnormal'
});
let running = false;
let stopRequested = false;
let activeLogFilter = 'all';
/*
* 文件内容及 API Key 仅保存在内存中,
* 不会写入 localStorage。
*/
let currentFile = null;
let currentProviders = [];
const sleep = milliseconds =>
new Promise(resolve => setTimeout(resolve, milliseconds));
/* =========================================================
* 通用工具
* ========================================================= */
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function normalizeName(value) {
return String(value ?? '')
.trim()
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeBaseUrl(value) {
return String(value ?? '')
.trim()
.replace(/\/+$/, '');
}
function joinUrl(baseUrl, path) {
return (
normalizeBaseUrl(baseUrl) +
'/' +
String(path ?? '').replace(/^\/+/, '')
);
}
function safeJsonParse(text) {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function getResponseErrorMessage(text, fallback = '请求失败') {
const body = safeJsonParse(text);
if (body) {
const message =
body?.error?.message ||
body?.error?.msg ||
body?.message ||
body?.msg;
if (message) {
return String(message)
.replace(/\s+/g, ' ')
.slice(0, 240);
}
}
const plainText = String(text || '')
.replace(/\s+/g, ' ')
.trim();
return plainText
? plainText.slice(0, 240)
: fallback;
}
function maskKey(key) {
const value = String(key || '');
if (value.length <= 8) {
return '******';
}
return `${value.slice(0, 4)}…${value.slice(-4)}`;
}
function getResultText(result) {
return {
success: '成功',
failed: '失败',
abnormal: '异常',
all: '全部'
}[result] || String(result || '');
}
/* =========================================================
* 设置存储
* ========================================================= */
function loadSettings() {
const defaults = {
left: 20,
top: 100,
collapsed: false
};
try {
return {
...defaults,
...JSON.parse(
localStorage.getItem(CONFIG.settingsKey) || '{}'
)
};
} catch {
return defaults;
}
}
function saveSettings(partial) {
const value = {
...loadSettings(),
...partial
};
localStorage.setItem(
CONFIG.settingsKey,
JSON.stringify(value)
);
return value;
}
/* =========================================================
* 断点
* ========================================================= */
function getFileIdentity(file) {
return [
file?.name || '',
file?.size || 0,
file?.lastModified || 0
].join('::');
}
function loadCheckpoint() {
try {
return JSON.parse(
localStorage.getItem(CONFIG.checkpointKey) || 'null'
);
} catch {
return null;
}
}
function saveCheckpoint(data) {
localStorage.setItem(
CONFIG.checkpointKey,
JSON.stringify({
...data,
updatedAt: Date.now()
})
);
}
function clearCheckpoint() {
localStorage.removeItem(CONFIG.checkpointKey);
}
function getSuggestedStart(file, providerCount) {
const checkpoint = loadCheckpoint();
if (!checkpoint) {
return 1;
}
if (checkpoint.fileIdentity !== getFileIdentity(file)) {
return 1;
}
const nextIndex = Number(checkpoint.nextIndex);
if (!Number.isInteger(nextIndex)) {
return 1;
}
/*
* 允许总数 + 1,代表此文件上次已经全部完成。
*/
return Math.min(
Math.max(nextIndex, 1),
Math.max(providerCount + 1, 1)
);
}
/* =========================================================
* YAML 解析
* ========================================================= */
function readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(
new Error('无法读取 YAML 文件')
);
reader.readAsText(file, 'UTF-8');
});
}
function parseYamlProviders(text) {
if (typeof jsyaml === 'undefined') {
throw new Error(
'js-yaml 未加载,请检查脚本管理器的 @require 权限'
);
}
const root = jsyaml.load(text);
if (!root || typeof root !== 'object') {
throw new Error('YAML 根节点格式无效');
}
const rawProviders = root['openai-compatibility'];
if (!Array.isArray(rawProviders)) {
throw new Error(
'YAML 中没有找到 openai-compatibility 数组'
);
}
const providers = [];
rawProviders.forEach((raw, yamlIndex) => {
if (!raw || typeof raw !== 'object') return;
const name = String(raw.name || '').trim();
const baseUrl = normalizeBaseUrl(raw['base-url']);
const rawKeyEntries = Array.isArray(raw['api-key-entries'])
? raw['api-key-entries']
: [];
const apiKeys = rawKeyEntries
.map(entry => {
if (typeof entry === 'string') {
return entry.trim();
}
return String(entry?.['api-key'] || '').trim();
})
.filter(Boolean);
const rawModels = Array.isArray(raw.models)
? raw.models
: [];
const models = rawModels
.map(model => {
if (typeof model === 'string') {
return model.trim();
}
/*
* 实际请求优先使用 alias。
* alias 为空时使用模型 name。
*/
const alias = String(model?.alias || '').trim();
const modelName = String(model?.name || '').trim();
return alias || modelName;
})
.filter(Boolean);
providers.push({
yamlIndex: yamlIndex + 1,
name,
baseUrl,
apiKeys,
models,
yamlDisabled: raw.disabled === true,
raw
});
});
if (providers.length === 0) {
throw new Error(
'openai-compatibility 中没有可识别的提供商'
);
}
return providers;
}
async function selectAndParseYaml() {
return new Promise((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = [
'.yaml',
'.yml',
'application/yaml',
'text/yaml',
'text/x-yaml'
].join(',');
input.style.display = 'none';
document.body.appendChild(input);
let settled = false;
const finish = () => {
setTimeout(() => input.remove(), 0);
};
input.addEventListener(
'change',
async () => {
if (settled) return;
settled = true;
try {
const file = input.files?.[0];
if (!file) {
resolve(null);
finish();
return;
}
const text = await readFileAsText(file);
const providers = parseYamlProviders(text);
resolve({
file,
providers
});
} catch (error) {
reject(error);
} finally {
finish();
}
},
{ once: true }
);
/*
* 用户取消选择文件时,部分浏览器不会触发 change。
*/
window.addEventListener(
'focus',
() => {
setTimeout(() => {
if (!settled && !input.files?.length) {
settled = true;
resolve(null);
finish();
}
}, 500);
},
{ once: true }
);
input.click();
});
}
/* =========================================================
* HTTP 请求
* ========================================================= */
function gmRequest(options) {
return new Promise(resolve => {
let completed = false;
const finish = result => {
if (completed) return;
completed = true;
resolve(result);
};
try {
GM_xmlhttpRequest({
method: options.method || 'GET',
url: options.url,
headers: options.headers || {},
data: options.data,
timeout: options.timeout || CONFIG.requestTimeout,
onload(response) {
finish({
transport: 'load',
status: Number(response.status || 0),
statusText: response.statusText || '',
text: response.responseText || '',
finalUrl: response.finalUrl || options.url
});
},
ontimeout() {
finish({
transport: 'timeout',
status: 0,
statusText: '请求超时',
text: '',
finalUrl: options.url
});
},
onerror(response) {
finish({
transport: 'error',
status: Number(response?.status || 0),
statusText:
response?.statusText ||
'网络连接失败',
text: response?.responseText || '',
finalUrl: options.url
});
},
onabort() {
finish({
transport: 'abort',
status: 0,
statusText: '请求已中止',
text: '',
finalUrl: options.url
});
}
});
} catch (error) {
finish({
transport: 'exception',
status: 0,
statusText:
error?.message ||
'GM_xmlhttpRequest 调用失败',
text: '',
finalUrl: options.url
});
}
});
}
function classifyHttpResponse(response) {
if (response.transport === 'timeout') {
return {
result: Result.ABNORMAL,
reason: '请求超时'
};
}
if (
response.transport === 'error' ||
response.transport === 'exception' ||
response.transport === 'abort'
) {
return {
result: Result.ABNORMAL,
reason: response.statusText || '网络连接异常'
};
}
const status = response.status;
if (status >= 200 && status < 300) {
return {
result: Result.OK,
reason: `HTTP ${status}`
};
}
/*
* 401 和 403 表示密钥无效或无权限,
* 视为明确失败。
*/
if (status === 401 || status === 403) {
return {
result: Result.FAILED,
reason:
`HTTP ${status}:` +
getResponseErrorMessage(
response.text,
'密钥无效或无权限'
)
};
}
if (status === 402) {
return {
result: CONFIG.paymentRequiredIsFailure
? Result.FAILED
: Result.ABNORMAL,
reason:
'HTTP 402:' +
getResponseErrorMessage(
response.text,
'余额不足或需要付费'
)
};
}
if (status === 429) {
return {
result: Result.ABNORMAL,
reason:
'HTTP 429:' +
getResponseErrorMessage(
response.text,
'请求过于频繁'
)
};
}
if (status >= 500) {
return {
result: Result.ABNORMAL,
reason:
`HTTP ${status}:` +
getResponseErrorMessage(
response.text,
'服务端暂时异常'
)
};
}
/*
* 404/405/501 对 /models 而言可能只是接口不支持,
* 是否失败由上层决定是否回退聊天检测。
*/
if ([404, 405, 501].includes(status)) {
return {
result: Result.ABNORMAL,
unsupported: true,
reason:
`HTTP ${status}:` +
getResponseErrorMessage(
response.text,
'当前接口可能不受支持'
)
};
}
/*
* 400、422 等也可能是兼容性差异,
* 不直接当成密钥确定失效。
*/
return {
result: Result.ABNORMAL,
reason:
`HTTP ${status || 0}:` +
getResponseErrorMessage(
response.text,
response.statusText || '未知响应'
)
};
}
async function requestModels(provider, apiKey) {
const url = joinUrl(provider.baseUrl, 'models');
const response = await gmRequest({
method: 'GET',
url,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${apiKey}`
}
});
const classification = classifyHttpResponse(response);
if (classification.result !== Result.OK) {
return {
...classification,
endpoint: '/models',
status: response.status
};
}
const body = safeJsonParse(response.text);
if (!body) {
return {
result: Result.ABNORMAL,
reason: '/models 返回的内容不是有效 JSON',
endpoint: '/models',
status: response.status
};
}
const modelIds = Array.isArray(body.data)
? body.data
.map(item => String(item?.id || '').trim())
.filter(Boolean)
: [];
return {
result: Result.OK,
reason:
modelIds.length > 0
? `/models 成功,返回 ${modelIds.length} 个模型`
: '/models 请求成功',
endpoint: '/models',
status: response.status,
modelIds
};
}
function chooseChatModel(provider, modelIds = []) {
if (provider.models.length > 0) {
return provider.models[0];
}
if (modelIds.length > 0) {
return modelIds[0];
}
return '';
}
async function requestChat(provider, apiKey, model) {
if (!model) {
return {
result: Result.ABNORMAL,
reason: '没有可用于聊天检测的模型名称',
endpoint: '/chat/completions'
};
}
const url = joinUrl(
provider.baseUrl,
'chat/completions'
);
const body = {
model,
messages: [
{
role: 'user',
content: 'Reply with OK.'
}
],
max_tokens: 1,
temperature: 0,
stream: false
};
const response = await gmRequest({
method: 'POST',
url,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`
},
data: JSON.stringify(body)
});
const classification = classifyHttpResponse(response);
if (classification.result !== Result.OK) {
return {
...classification,
endpoint: '/chat/completions',
status: response.status,
model
};
}
const responseBody = safeJsonParse(response.text);
if (!responseBody) {
return {
result: Result.ABNORMAL,
reason:
'/chat/completions 返回的内容不是有效 JSON',
endpoint: '/chat/completions',
status: response.status,
model
};
}
/*
* 一些兼容服务返回结构可能稍有不同。
* HTTP 成功且没有 error 字段,就认为调用成功。
*/
if (responseBody.error) {
return {
result: Result.ABNORMAL,
reason: getResponseErrorMessage(
response.text,
'聊天接口返回错误'
),
endpoint: '/chat/completions',
status: response.status,
model
};
}
return {
result: Result.OK,
reason: `聊天请求成功,模型 ${model}`,
endpoint: '/chat/completions',
status: response.status,
model
};
}
async function checkKeyOnce(provider, apiKey) {
const modelsResult = await requestModels(
provider,
apiKey
);
if (stopRequested) {
return {
result: Result.STOPPED,
reason: '用户停止'
};
}
if (modelsResult.result === Result.OK) {
if (!CONFIG.requireChatTest) {
return modelsResult;
}
const model = chooseChatModel(
provider,
modelsResult.modelIds
);
return requestChat(provider, apiKey, model);
}
if (
modelsResult.unsupported &&
CONFIG.fallbackToChat
) {
const model = chooseChatModel(provider);
return requestChat(provider, apiKey, model);
}
return modelsResult;
}
async function checkKey(provider, apiKey) {
let lastResult = null;
for (
let attempt = 1;
attempt <= CONFIG.requestAttempts;
attempt++
) {
if (stopRequested) {
return {
result: Result.STOPPED,
reason: '用户停止'
};
}
lastResult = await checkKeyOnce(
provider,
apiKey
);
if (
lastResult.result === Result.OK ||
lastResult.result === Result.FAILED ||
lastResult.result === Result.STOPPED
) {
return {
...lastResult,
attempts: attempt
};
}
if (attempt < CONFIG.requestAttempts) {
await sleep(CONFIG.requestRetryDelay);
}
}
return {
...(lastResult || {
result: Result.ABNORMAL,
reason: '没有获得检测结果'
}),
attempts: CONFIG.requestAttempts
};
}
async function checkProvider(provider) {
if (!provider.name) {
return {
result: Result.ABNORMAL,
reason: 'YAML 中缺少提供商 name',
keyResults: []
};
}
if (!provider.baseUrl) {
return {
result: Result.ABNORMAL,
reason: 'YAML 中缺少 base-url',
keyResults: []
};
}
if (provider.apiKeys.length === 0) {
return {
result: Result.ABNORMAL,
reason: 'YAML 中没有 API Key',
keyResults: []
};
}
const keyResults = [];
for (
let keyIndex = 0;
keyIndex < provider.apiKeys.length;
keyIndex++
) {
if (stopRequested) {
return {
result: Result.STOPPED,
reason: '用户停止',
keyResults
};
}
const apiKey = provider.apiKeys[keyIndex];
const keyResult = await checkKey(
provider,
apiKey
);
keyResults.push({
keyIndex: keyIndex + 1,
maskedKey: maskKey(apiKey),
...keyResult
});
/*
* 任意一个 Key 可用,该提供商即判定成功,
* 无须继续测试剩余 Key。
*/
if (keyResult.result === Result.OK) {
return {
result: Result.OK,
reason:
`第 ${keyIndex + 1}/${provider.apiKeys.length} ` +
`个密钥可用:${keyResult.reason}`,
keyResults
};
}
if (keyResult.result === Result.STOPPED) {
return {
result: Result.STOPPED,
reason: '用户停止',
keyResults
};
}
if (keyIndex < provider.apiKeys.length - 1) {
await sleep(CONFIG.keyDelay);
}
}
const allFailed =
keyResults.length === provider.apiKeys.length &&
keyResults.every(
item => item.result === Result.FAILED
);
if (allFailed) {
return {
result: Result.FAILED,
reason:
`${provider.apiKeys.length}/${provider.apiKeys.length} ` +
'个密钥均明确无效',
keyResults
};
}
const abnormalReasons = keyResults
.filter(item => item.result === Result.ABNORMAL)
.map(item => item.reason)
.filter(Boolean);
return {
result: Result.ABNORMAL,
reason:
abnormalReasons[0] ||
'检测过程异常,无法确认是否可用',
keyResults
};
}
/* =========================================================
* 页面开关同步
* ========================================================= */
function getProviderRows() {
return [
...document.querySelectorAll('tbody tr')
];
}
function getRowProviderName(row) {
const firstCell = row?.querySelector('td');
if (!firstCell) return '';
/*
* 第一列通常有名称和掩码密钥。
* 优先查找名称类元素,失败时使用第一行文本。
*/
const namedElement = firstCell.querySelector(
'[class*="primaryName"]'
);
if (namedElement?.textContent?.trim()) {
return namedElement.textContent.trim();
}
return String(firstCell.innerText || '')
.split('\n')
.map(item => item.trim())
.filter(Boolean)[0] || '';
}
function findProviderRow(providerName) {
const targetName = normalizeName(providerName);
return getProviderRows().find(row => {
return normalizeName(
getRowProviderName(row)
) === targetName;
}) || null;
}
function getRowSwitch(row) {
return row?.querySelector(
'input[type="checkbox"][aria-label="启用"],' +
'input[type="checkbox"][aria-label="停用"]'
) || null;
}
function getSwitchEnabled(input) {
if (!input) return null;
/*
* 当前页面表现:
* checked=true、aria-label=停用:当前已启用;
* checked=false、aria-label=启用:当前已停用。
*/
if (input.getAttribute('aria-label') === '停用') {
return true;
}
if (input.getAttribute('aria-label') === '启用') {
return false;
}
return Boolean(input.checked);
}
async function waitForSwitchState(
providerName,
targetEnabled
) {
const deadline =
Date.now() + CONFIG.switchTimeout;
while (Date.now() < deadline) {
if (stopRequested) {
return {
ok: false,
stopped: true,
reason: '用户停止'
};
}
const currentRow =
findProviderRow(providerName);
const currentInput =
getRowSwitch(currentRow);
if (
currentInput &&
getSwitchEnabled(currentInput) === targetEnabled
) {
return {
ok: true,
changed: true
};
}
await sleep(CONFIG.switchPollInterval);
}
return {
ok: false,
reason:
`等待页面开关变为` +
`${targetEnabled ? '开启' : '关闭'}超时`
};
}
async function setProviderEnabled(
providerName,
targetEnabled
) {
const row = findProviderRow(providerName);
if (!row) {
return {
ok: false,
abnormal: true,
action: '未修改',
reason: '页面列表中未找到同名提供商'
};
}
const input = getRowSwitch(row);
if (!input) {
return {
ok: false,
abnormal: true,
action: '未修改',
reason: '页面提供商行中未找到开关'
};
}
const currentEnabled = getSwitchEnabled(input);
if (currentEnabled === targetEnabled) {
return {
ok: true,
changed: false,
action: targetEnabled
? '已是开启'
: '已是关闭',
reason: '开关已经处于目标状态'
};
}
/*
* 使用真实 click,让 React 事件和后端保存逻辑正常执行。
*/
input.click();
const waitResult = await waitForSwitchState(
providerName,
targetEnabled
);
if (!waitResult.ok) {
return {
ok: false,
abnormal: true,
stopped: waitResult.stopped,
action: '修改失败',
reason: waitResult.reason
};
}
await sleep(CONFIG.afterSwitchDelay);
/*
* 再次复核,避免短暂改变后被服务端回滚。
*/
const finalRow = findProviderRow(providerName);
const finalInput = getRowSwitch(finalRow);
const finalEnabled = getSwitchEnabled(finalInput);
if (finalEnabled !== targetEnabled) {
return {
ok: false,
abnormal: true,
action: '修改失败',
reason: '开关状态被页面或服务端回滚'
};
}
return {
ok: true,
changed: true,
action: targetEnabled
? '已开启'
: '已关闭',
reason: '页面开关状态已确认'
};
}
/* =========================================================
* 日志
* ========================================================= */
function getPanel() {
return document.getElementById(CONFIG.panelId);
}
function addSystemLog(message) {
const list = getPanel()?.querySelector(
'.tm-log-list'
);
if (!list) return;
const item = document.createElement('div');
item.className =
'tm-log-item tm-system-log';
item.innerHTML = `
${escapeHtml(new Date().toLocaleTimeString())}
${escapeHtml(message)}
`;
list.appendChild(item);
list.scrollTop = list.scrollHeight;
}
function addResultLog({
index,
name,
result,
action = '',
detail = ''
}) {
const list = getPanel()?.querySelector(
'.tm-log-list'
);
if (!list) return;
const item = document.createElement('div');
const time = new Date();
item.className = 'tm-log-item';
item.dataset.result = result;
item.dataset.index = String(index ?? '');
item.dataset.name = String(name ?? '');
item.dataset.action = String(action ?? '');
item.dataset.detail = String(detail ?? '');
item.dataset.time = time.toISOString();
item.innerHTML = `
${escapeHtml(getResultText(result))}
#${escapeHtml(index)} ${escapeHtml(name)}
${escapeHtml(detail)}
操作:${escapeHtml(action || '保持原状态')}
· ${escapeHtml(time.toLocaleTimeString())}
`;
list.appendChild(item);
applyLogFilter();
updateLogFilterCounts();
list.scrollTop = list.scrollHeight;
}
function getLogCounts() {
const items = [
...(getPanel()?.querySelectorAll(
'.tm-log-list ' +
'.tm-log-item[data-result]'
) || [])
];
return {
all: items.length,
success: items.filter(
item => item.dataset.result === 'success'
).length,
failed: items.filter(
item => item.dataset.result === 'failed'
).length,
abnormal: items.filter(
item => item.dataset.result === 'abnormal'
).length
};
}
function updateLogFilterCounts() {
const panel = getPanel();
if (!panel) return;
const counts = getLogCounts();
for (const [type, count] of Object.entries(counts)) {
const target = panel.querySelector(
`.tm-log-filter [data-count="${type}"]`
);
if (target) {
target.textContent = String(count);
}
}
}
function applyLogFilter() {
const panel = getPanel();
if (!panel) return;
const items = panel.querySelectorAll(
'.tm-log-list .tm-log-item'
);
items.forEach(item => {
/*
* 系统日志仅在“全部”筛选下显示。
*/
if (!item.dataset.result) {
item.classList.toggle(
'tm-filter-hidden',
activeLogFilter !== 'all'
);
return;
}
const visible =
activeLogFilter === 'all' ||
item.dataset.result === activeLogFilter;
item.classList.toggle(
'tm-filter-hidden',
!visible
);
});
panel.querySelectorAll(
'.tm-log-filter button[data-filter]'
).forEach(button => {
button.classList.toggle(
'active',
button.dataset.filter === activeLogFilter
);
});
}
function setLogFilter(filter) {
if (
!['all', 'success', 'failed', 'abnormal']
.includes(filter)
) {
filter = 'all';
}
activeLogFilter = filter;
applyLogFilter();
}
function clearLogs() {
const list = getPanel()?.querySelector(
'.tm-log-list'
);
if (list) {
list.innerHTML = '';
}
updateLogFilterCounts();
}
/* =========================================================
* CSV 导出
* ========================================================= */
function csvEscape(value) {
return `"${String(value ?? '')
.replace(/"/g, '""')}"`;
}
function formatExportTime(isoString) {
if (!isoString) return '';
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) {
return isoString;
}
return date.toLocaleString();
}
function getExportLogItems() {
const items = [
...(getPanel()?.querySelectorAll(
'.tm-log-list ' +
'.tm-log-item[data-result]'
) || [])
];
if (activeLogFilter === 'all') {
return items;
}
return items.filter(
item =>
item.dataset.result === activeLogFilter
);
}
function makeExportFilename() {
const now = new Date();
const pad = value =>
String(value).padStart(2, '0');
const timestamp =
[
now.getFullYear(),
pad(now.getMonth() + 1),
pad(now.getDate())
].join('') +
'-' +
[
pad(now.getHours()),
pad(now.getMinutes()),
pad(now.getSeconds())
].join('');
return (
`提供商检测结果-` +
`${getResultText(activeLogFilter)}-` +
`${timestamp}.csv`
);
}
function exportLogs() {
const items = getExportLogItems();
if (items.length === 0) {
alert(
'当前筛选条件下没有可以导出的检测结果。'
);
return;
}
const rows = [
[
'YAML序号',
'提供商名称',
'检测结果',
'开关操作',
'详细信息',
'检测时间'
]
];
items.forEach(item => {
rows.push([
item.dataset.index || '',
item.dataset.name || '',
getResultText(item.dataset.result),
item.dataset.action || '',
item.dataset.detail || '',
formatExportTime(item.dataset.time)
]);
});
const csv = rows
.map(row => row.map(csvEscape).join(','))
.join('\r\n');
/*
* UTF-8 BOM 避免 Excel 打开时中文乱码。
*/
const blob = new Blob(
['\uFEFF', csv],
{ type: 'text/csv;charset=utf-8' }
);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = makeExportFilename();
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => {
URL.revokeObjectURL(url);
}, 1000);
addSystemLog(
`已导出当前筛选下的 ${items.length} 条结果`
);
}
/* =========================================================
* 状态与运行控制
* ========================================================= */
function setStatus(message, type = '') {
const element = getPanel()?.querySelector(
'.tm-status'
);
if (!element) return;
element.textContent = message;
element.dataset.type = type;
}
function setProgress(current, total) {
const element = getPanel()?.querySelector(
'.tm-progress'
);
if (!element) return;
if (!total) {
element.style.width = '0%';
return;
}
const percent = Math.min(
100,
Math.max(0, current / total * 100)
);
element.style.width = `${percent}%`;
}
function setRunningState(value) {
running = value;
const panel = getPanel();
if (!panel) return;
panel.querySelector(
'.tm-start'
).disabled = value;
panel.querySelector(
'.tm-file'
).disabled = value;
panel.querySelector(
'.tm-use-checkpoint'
).disabled = value;
panel.querySelector(
'.tm-start-index'
).disabled = value;
panel.querySelector(
'.tm-stop'
).disabled = !value;
}
function updateFileInformation() {
const panel = getPanel();
if (!panel) return;
const fileText = panel.querySelector(
'.tm-file-info'
);
const startInput = panel.querySelector(
'.tm-start-index'
);
if (!currentFile || currentProviders.length === 0) {
fileText.textContent = '尚未选择 YAML';
return;
}
const suggestedStart = getSuggestedStart(
currentFile,
currentProviders.length
);
fileText.textContent =
`${currentFile.name} · ` +
`${currentProviders.length} 个提供商 · ` +
`建议从第 ${suggestedStart} 项开始`;
/*
* 选择新文件时自动填入建议断点,
* 用户仍然可以手动修改。
*/
startInput.value = String(suggestedStart);
startInput.max = String(
currentProviders.length
);
}
async function chooseYaml() {
if (running) return;
try {
setStatus('请选择 YAML 文件…');
const selection =
await selectAndParseYaml();
if (!selection) {
setStatus('已取消选择文件');
return;
}
currentFile = selection.file;
currentProviders = selection.providers;
updateFileInformation();
setStatus(
`已解析 ${currentProviders.length} 个提供商`,
'success'
);
addSystemLog(
`已载入 ${currentFile.name},` +
`共 ${currentProviders.length} 个提供商`
);
} catch (error) {
currentFile = null;
currentProviders = [];
updateFileInformation();
setStatus(
`YAML 解析失败:${error?.message || error}`,
'error'
);
addSystemLog(
`YAML 解析失败:${error?.message || error}`
);
alert(
`YAML 解析失败:\n` +
`${error?.message || error}`
);
}
}
function useCheckpoint() {
if (!currentFile || currentProviders.length === 0) {
alert('请先选择并解析 YAML 文件。');
return;
}
const checkpoint = loadCheckpoint();
if (
!checkpoint ||
checkpoint.fileIdentity !==
getFileIdentity(currentFile)
) {
alert(
'没有找到当前 YAML 文件的有效断点。\n' +
'文件名、大小和最后修改时间必须与上次一致。'
);
return;
}
const suggestedStart = getSuggestedStart(
currentFile,
currentProviders.length
);
const input = getPanel()?.querySelector(
'.tm-start-index'
);
if (input) {
input.value = String(suggestedStart);
}
if (suggestedStart > currentProviders.length) {
setStatus('该 YAML 上次已经全部检测完成');
} else {
setStatus(
`已获取断点,将从第 ${suggestedStart} 项开始`
);
}
}
async function startDetection() {
if (running) return;
const confirmed1 = window.confirm(
'开始检测前,请确保当前已打开“AI 提供商”列表页面。\n\n' +
'检测期间请不要切换管理中心页面、刷新或关闭当前页面,' +
'否则脚本可能无法同步提供商的开启/关闭状态。\n\n' +
'点击“确定”继续检测;点击“取消”不执行检测。'
);
if (!confirmed1) {
setStatus('已取消检测:请先打开 AI 提供商列表');
return;
}
if (!currentFile || currentProviders.length === 0) {
await chooseYaml();
if (
!currentFile ||
currentProviders.length === 0
) {
return;
}
}
const startInput = getPanel()?.querySelector(
'.tm-start-index'
);
let startIndex = Number(startInput?.value);
if (
!Number.isInteger(startIndex) ||
startIndex < 1 ||
startIndex > currentProviders.length
) {
alert(
`开始位置必须是 1 到 ` +
`${currentProviders.length} 之间的整数。`
);
return;
}
const confirmed = confirm(
`即将从 YAML 第 ${startIndex} 项开始检测。\n\n` +
`成功:开启提供商。\n` +
`失败:关闭提供商。\n` +
`异常:保持原开关状态。\n\n` +
`是否继续?`
);
if (!confirmed) return;
stopRequested = false;
setRunningState(true);
const total = currentProviders.length;
let processed = 0;
let successCount = 0;
let failedCount = 0;
let abnormalCount = 0;
setProgress(startIndex - 1, total);
addSystemLog(
`开始检测:第 ${startIndex} 至 ${total} 项`
);
try {
for (
let arrayIndex = startIndex - 1;
arrayIndex < total;
arrayIndex++
) {
if (stopRequested) break;
const provider =
currentProviders[arrayIndex];
const displayIndex = arrayIndex + 1;
setStatus(
`正在检测 ${displayIndex}/${total}:` +
`${provider.name || '未命名'}`
);
let checkResult;
try {
checkResult =
await checkProvider(provider);
} catch (error) {
checkResult = {
result: Result.ABNORMAL,
reason:
`检测代码异常:` +
`${error?.message || error}`,
keyResults: []
};
}
if (
stopRequested ||
checkResult.result === Result.STOPPED
) {
break;
}
let logType;
let action;
let detail = checkResult.reason;
if (checkResult.result === Result.OK) {
let switchResult;
try {
switchResult =
await setProviderEnabled(
provider.name,
true
);
} catch (error) {
switchResult = {
ok: false,
abnormal: true,
action: '修改失败',
reason:
`开关操作异常:` +
`${error?.message || error}`
};
}
if (stopRequested || switchResult.stopped) {
break;
}
if (switchResult.ok) {
logType = LogType.SUCCESS;
successCount++;
action = switchResult.action;
detail =
`${checkResult.reason};` +
`${switchResult.reason}`;
} else {
logType = LogType.ABNORMAL;
abnormalCount++;
action =
switchResult.action ||
'保持原状态';
detail =
`${checkResult.reason};` +
`${switchResult.reason}`;
}
} else if (
checkResult.result === Result.FAILED
) {
let switchResult;
try {
switchResult =
await setProviderEnabled(
provider.name,
false
);
} catch (error) {
switchResult = {
ok: false,
abnormal: true,
action: '修改失败',
reason:
`开关操作异常:` +
`${error?.message || error}`
};
}
if (stopRequested || switchResult.stopped) {
break;
}
if (switchResult.ok) {
logType = LogType.FAILED;
failedCount++;
action = switchResult.action;
detail =
`${checkResult.reason};` +
`${switchResult.reason}`;
} else {
/*
* API 检测明确失败,但页面关闭未完成,
* 整体归为异常,避免误认为已经关闭。
*/
logType = LogType.ABNORMAL;
abnormalCount++;
action =
switchResult.action ||
'关闭失败';
detail =
`${checkResult.reason};` +
`${switchResult.reason}`;
}
} else {
logType = LogType.ABNORMAL;
abnormalCount++;
action = '保持原状态';
}
addResultLog({
index: displayIndex,
name:
provider.name ||
`未命名-${displayIndex}`,
result: logType,
action,
detail
});
processed++;
/*
* 当前项完整处理结束后才保存下一项断点。
*/
saveCheckpoint({
fileIdentity:
getFileIdentity(currentFile),
fileName: currentFile.name,
total,
nextIndex: displayIndex + 1,
lastCompletedIndex: displayIndex
});
setProgress(displayIndex, total);
setStatus(
`完成 ${displayIndex}/${total};` +
`成功 ${successCount},` +
`失败 ${failedCount},` +
`异常 ${abnormalCount}`
);
if (displayIndex < total) {
await sleep(CONFIG.providerDelay);
}
}
const checkpoint = loadCheckpoint();
const lastCompletedIndex =
checkpoint?.fileIdentity ===
getFileIdentity(currentFile)
? Number(
checkpoint.lastCompletedIndex || 0
)
: startIndex - 1;
if (stopRequested) {
const nextIndex = Math.min(
lastCompletedIndex + 1,
total
);
setStatus(
`已停止;下次可从第 ${nextIndex} 项开始。` +
`本次成功 ${successCount},` +
`失败 ${failedCount},` +
`异常 ${abnormalCount}`,
'warning'
);
addSystemLog(
`用户停止。下次可从第 ${nextIndex} 项开始`
);
} else {
saveCheckpoint({
fileIdentity:
getFileIdentity(currentFile),
fileName: currentFile.name,
total,
nextIndex: total + 1,
lastCompletedIndex: total,
completed: true
});
setProgress(total, total);
setStatus(
`检测完成;成功 ${successCount},` +
`失败 ${failedCount},` +
`异常 ${abnormalCount}`,
'success'
);
addSystemLog(
`检测完成:成功 ${successCount},` +
`失败 ${failedCount},` +
`异常 ${abnormalCount}`
);
}
} catch (error) {
setStatus(
`任务异常终止:${error?.message || error}`,
'error'
);
addSystemLog(
`任务异常终止:${error?.message || error}`
);
console.error(
'[YAML Provider Checker]',
error
);
} finally {
setRunningState(false);
stopRequested = false;
}
}
function requestStop() {
if (!running) return;
const confirmed = confirm(
'确定停止当前检测任务吗?\n\n' +
'当前提供商处理完成后会保存断点,' +
'下次可从断点继续。'
);
if (!confirmed) return;
stopRequested = true;
setStatus('正在停止,请等待当前请求结束…', 'warning');
}
/* =========================================================
* 样式
* ========================================================= */
function addStyle() {
if (document.getElementById(CONFIG.styleId)) {
return;
}
const style = document.createElement('style');
style.id = CONFIG.styleId;
style.textContent = `
#${CONFIG.panelId} {
position: fixed;
z-index: 2147483647;
width: 390px;
max-width: calc(100vw - 20px);
color: #222;
background: rgba(255, 255, 255, .97);
border: 1px solid #d5d5d5;
border-radius: 12px;
box-shadow: 0 10px 36px rgba(0, 0, 0, .24);
overflow: hidden;
font-family:
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"PingFang SC",
"Microsoft YaHei",
sans-serif;
font-size: 13px;
backdrop-filter: blur(10px);
}
#${CONFIG.panelId} * {
box-sizing: border-box;
}
#${CONFIG.panelId} button,
#${CONFIG.panelId} input {
font: inherit;
}
#${CONFIG.panelId} .tm-header {
height: 42px;
padding: 0 8px 0 13px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
background: #f4f4f4;
border-bottom: 1px solid #ddd;
cursor: move;
user-select: none;
}
#${CONFIG.panelId} .tm-title {
flex: 1;
min-width: 0;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#${CONFIG.panelId} .tm-collapse {
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 6px;
color: #555;
background: transparent;
cursor: pointer;
font-size: 20px;
line-height: 26px;
}
#${CONFIG.panelId} .tm-collapse:hover {
background: rgba(0, 0, 0, .08);
}
#${CONFIG.panelId} .tm-body {
padding: 12px;
}
#${CONFIG.panelId}.tm-collapsed {
width: 220px;
}
#${CONFIG.panelId}.tm-collapsed .tm-body {
display: none;
}
#${CONFIG.panelId}.tm-collapsed .tm-header {
border-bottom: 0;
}
#${CONFIG.panelId} .tm-file-row {
display: flex;
gap: 7px;
margin-bottom: 7px;
}
#${CONFIG.panelId} .tm-file {
flex: 1;
min-height: 34px;
padding: 6px 10px;
color: #fff;
background: #2463eb;
border: 1px solid #2463eb;
border-radius: 7px;
cursor: pointer;
}
#${CONFIG.panelId} .tm-file:hover:not(:disabled) {
background: #1d4ed8;
}
#${CONFIG.panelId} .tm-file-info {
min-height: 34px;
margin-bottom: 8px;
padding: 7px 9px;
color: #555;
background: #f5f5f5;
border-radius: 7px;
line-height: 1.5;
overflow-wrap: anywhere;
}
#${CONFIG.panelId} .tm-start-row {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 8px;
}
#${CONFIG.panelId} .tm-start-label {
display: flex;
flex: 1;
align-items: center;
gap: 5px;
white-space: nowrap;
}
#${CONFIG.panelId} .tm-start-index {
width: 62px;
height: 32px;
padding: 4px 7px;
color: #222;
background: #fff;
border: 1px solid #bbb;
border-radius: 6px;
}
#${CONFIG.panelId} .tm-use-checkpoint {
flex: none;
height: 32px;
padding: 4px 9px;
white-space: nowrap;
color: #333;
background: #fff;
border: 1px solid #bbb;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
#${CONFIG.panelId} .tm-main-actions {
display: grid;
grid-template-columns: 1fr 88px;
gap: 7px;
margin-bottom: 8px;
}
#${CONFIG.panelId} .tm-start,
#${CONFIG.panelId} .tm-stop {
min-height: 34px;
padding: 6px 10px;
border-radius: 7px;
cursor: pointer;
}
#${CONFIG.panelId} .tm-start {
color: #fff;
background: #16803d;
border: 1px solid #16803d;
}
#${CONFIG.panelId} .tm-start:hover:not(:disabled) {
background: #116b33;
}
#${CONFIG.panelId} .tm-stop {
color: #fff;
background: #c7392f;
border: 1px solid #c7392f;
}
#${CONFIG.panelId} .tm-stop:hover:not(:disabled) {
background: #aa2f27;
}
#${CONFIG.panelId} button:disabled,
#${CONFIG.panelId} input:disabled {
opacity: .5;
cursor: not-allowed;
}
#${CONFIG.panelId} .tm-status {
min-height: 40px;
padding: 8px 9px;
color: #555;
background: #f5f5f5;
border-radius: 7px;
line-height: 1.5;
overflow-wrap: anywhere;
}
#${CONFIG.panelId} .tm-status[data-type="success"] {
color: #116b33;
background: #eaf8ef;
}
#${CONFIG.panelId} .tm-status[data-type="warning"] {
color: #8a5c00;
background: #fff6dc;
}
#${CONFIG.panelId} .tm-status[data-type="error"] {
color: #a32820;
background: #fdeceb;
}
#${CONFIG.panelId} .tm-progress-track {
height: 5px;
margin: 8px 0 10px;
background: #e4e4e4;
border-radius: 99px;
overflow: hidden;
}
#${CONFIG.panelId} .tm-progress {
width: 0;
height: 100%;
background: #2463eb;
transition: width .2s ease;
}
#${CONFIG.panelId} .tm-log-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
#${CONFIG.panelId} .tm-log-title {
font-weight: 700;
}
#${CONFIG.panelId} .tm-log-actions {
display: flex;
align-items: center;
gap: 6px;
}
#${CONFIG.panelId} .tm-log-actions button {
min-width: auto;
padding: 4px 9px;
color: #333;
background: #fff;
border: 1px solid #bbb;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
#${CONFIG.panelId} .tm-export-log {
color: #fff !important;
background: #2463eb !important;
border-color: #2463eb !important;
}
#${CONFIG.panelId} .tm-export-log:hover {
background: #1d4ed8 !important;
}
#${CONFIG.panelId} .tm-log-filter {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 6px;
padding-bottom: 2px;
overflow-x: auto;
white-space: nowrap;
}
#${CONFIG.panelId} .tm-log-filter-label {
flex: none;
color: #666;
}
#${CONFIG.panelId} .tm-log-filter button {
flex: none;
min-width: auto;
padding: 4px 7px;
color: #444;
background: #fff;
border: 1px solid #bbb;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
#${CONFIG.panelId} .tm-log-filter button.active {
color: #fff;
background: #2463eb;
border-color: #2463eb;
}
#${CONFIG.panelId} .tm-log-list {
max-height: 230px;
padding: 4px;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 7px;
overflow: auto;
}
#${CONFIG.panelId} .tm-log-item {
margin-bottom: 5px;
padding: 7px 8px;
background: #fff;
border-left: 3px solid #aaa;
border-radius: 5px;
line-height: 1.45;
overflow-wrap: anywhere;
}
#${CONFIG.panelId} .tm-log-item:last-child {
margin-bottom: 0;
}
#${CONFIG.panelId}
.tm-log-item[data-result="success"] {
border-left-color: #16803d;
}
#${CONFIG.panelId}
.tm-log-item[data-result="failed"] {
border-left-color: #c7392f;
}
#${CONFIG.panelId}
.tm-log-item[data-result="abnormal"] {
border-left-color: #d18a00;
}
#${CONFIG.panelId} .tm-filter-hidden {
display: none;
}
#${CONFIG.panelId} .tm-log-item-title {
display: flex;
align-items: flex-start;
gap: 6px;
font-weight: 700;
}
#${CONFIG.panelId} .tm-log-result {
flex: none;
}
#${CONFIG.panelId} .tm-log-provider {
min-width: 0;
}
#${CONFIG.panelId}
.tm-log-item[data-result="success"]
.tm-log-result {
color: #16803d;
}
#${CONFIG.panelId}
.tm-log-item[data-result="failed"]
.tm-log-result {
color: #c7392f;
}
#${CONFIG.panelId}
.tm-log-item[data-result="abnormal"]
.tm-log-result {
color: #b47400;
}
#${CONFIG.panelId} .tm-log-detail {
margin-top: 3px;
color: #444;
}
#${CONFIG.panelId} .tm-log-meta {
margin-top: 3px;
color: #888;
font-size: 11px;
}
#${CONFIG.panelId} .tm-system-log {
color: #666;
background: #fafafa;
border-left-color: #888;
}
#${CONFIG.panelId} .tm-log-time {
margin-right: 5px;
color: #999;
font-size: 11px;
}
@media (prefers-color-scheme: dark) {
#${CONFIG.panelId} {
color: #eee;
background: rgba(30, 30, 30, .97);
border-color: #555;
}
#${CONFIG.panelId} .tm-header {
background: #292929;
border-color: #555;
}
#${CONFIG.panelId} .tm-collapse {
color: #ddd;
}
#${CONFIG.panelId} .tm-file-info,
#${CONFIG.panelId} .tm-status,
#${CONFIG.panelId} .tm-log-list {
color: #ddd;
background: #252525;
border-color: #555;
}
#${CONFIG.panelId} .tm-start-index,
#${CONFIG.panelId} .tm-use-checkpoint,
#${CONFIG.panelId} .tm-log-actions button,
#${CONFIG.panelId} .tm-log-filter button {
color: #eee;
background: #333;
border-color: #666;
}
#${CONFIG.panelId} .tm-log-item {
color: #eee;
background: #303030;
}
#${CONFIG.panelId} .tm-log-detail {
color: #ddd;
}
#${CONFIG.panelId} .tm-system-log {
color: #ccc;
background: #2b2b2b;
}
}
`;
document.head.appendChild(style);
}
/* =========================================================
* 拖动和折叠
* ========================================================= */
function keepPanelInViewport(panel) {
const rect = panel.getBoundingClientRect();
const maxLeft = Math.max(
0,
window.innerWidth - rect.width
);
const maxTop = Math.max(
0,
window.innerHeight - rect.height
);
const left = Math.min(
Math.max(rect.left, 0),
maxLeft
);
const top = Math.min(
Math.max(rect.top, 0),
maxTop
);
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
saveSettings({ left, top });
}
function setupDragging(panel) {
const header = panel.querySelector('.tm-header');
let dragging = false;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
header.addEventListener('mousedown', event => {
if (
event.button !== 0 ||
event.target.closest('button')
) {
return;
}
dragging = true;
startX = event.clientX;
startY = event.clientY;
const rect = panel.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
document.body.style.userSelect = 'none';
event.preventDefault();
});
document.addEventListener('mousemove', event => {
if (!dragging) return;
const rect = panel.getBoundingClientRect();
const maxLeft = Math.max(
0,
window.innerWidth - rect.width
);
const maxTop = Math.max(
0,
window.innerHeight - rect.height
);
const left = Math.min(
Math.max(
startLeft + event.clientX - startX,
0
),
maxLeft
);
const top = Math.min(
Math.max(
startTop + event.clientY - startY,
0
),
maxTop
);
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
document.body.style.userSelect = '';
const rect = panel.getBoundingClientRect();
saveSettings({
left: rect.left,
top: rect.top
});
});
window.addEventListener('resize', () => {
keepPanelInViewport(panel);
});
}
function toggleCollapsed() {
const panel = getPanel();
if (!panel) return;
const collapsed = !panel.classList.contains(
'tm-collapsed'
);
panel.classList.toggle(
'tm-collapsed',
collapsed
);
const button = panel.querySelector(
'.tm-collapse'
);
button.textContent = collapsed ? '+' : '−';
button.title = collapsed
? '展开检测面板'
: '折叠检测面板';
saveSettings({ collapsed });
setTimeout(() => {
keepPanelInViewport(panel);
}, 0);
}
/* =========================================================
* 面板
* ========================================================= */
function createPanel() {
if (getPanel()) return;
addStyle();
const settings = loadSettings();
const panel = document.createElement('section');
panel.id = CONFIG.panelId;
panel.style.left = `${settings.left}px`;
panel.style.top = `${settings.top}px`;
panel.classList.toggle(
'tm-collapsed',
Boolean(settings.collapsed)
);
panel.innerHTML = `
尚未选择 YAML
请选择 YAML 文件
筛选:
`;
document.body.appendChild(panel);
panel
.querySelector('.tm-collapse')
.addEventListener(
'click',
event => {
event.stopPropagation();
toggleCollapsed();
}
);
panel
.querySelector('.tm-file')
.addEventListener(
'click',
chooseYaml
);
panel
.querySelector('.tm-use-checkpoint')
.addEventListener(
'click',
useCheckpoint
);
panel
.querySelector('.tm-start')
.addEventListener(
'click',
startDetection
);
panel
.querySelector('.tm-stop')
.addEventListener(
'click',
requestStop
);
panel
.querySelector('.tm-clear-log')
.addEventListener(
'click',
event => {
event.stopPropagation();
clearLogs();
}
);
panel
.querySelector('.tm-export-log')
.addEventListener(
'click',
event => {
event.stopPropagation();
exportLogs();
}
);
panel
.querySelectorAll(
'.tm-log-filter button[data-filter]'
)
.forEach(button => {
button.addEventListener(
'click',
() => {
setLogFilter(
button.dataset.filter
);
}
);
});
setupDragging(panel);
setTimeout(() => {
keepPanelInViewport(panel);
}, 0);
}
/* =========================================================
* 初始化及 SPA 兼容
* ========================================================= */
function initialize() {
createPanel();
}
if (document.readyState === 'loading') {
document.addEventListener(
'DOMContentLoaded',
initialize,
{ once: true }
);
} else {
initialize();
}
/*
* 页面是 Hash SPA。即使页面内部切换路由,
* 面板也会继续存在;若被页面重绘意外删除则重新创建。
*/
const observer = new MutationObserver(() => {
if (!getPanel()) {
createPanel();
}
});
observer.observe(
document.documentElement,
{
childList: true,
subtree: true
}
);
})();