// ==UserScript==
// @name 自动登录助手
// @namespace https://local.autologin.helper
// @version 0.3.0
// @description 维护站点登录配置,并在检测到登录表单时自动登录。
// @match *://*/*
// @run-at document-idle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect *
// @license MIT
// @downloadURL none
// ==/UserScript==
(function () {
'use strict';
if (window.top !== window.self) {
return;
}
const STORAGE_KEY = 'siteConfigs';
const CONFIG_META_KEY = 'siteConfigsMeta';
const LOG_STORAGE_KEY = 'runtimeLogs';
const LOG_ENABLED_KEY = 'runtimeLogsEnabled';
const SYNC_SETTINGS_KEY = 'syncSettings';
const STYLE_ID = 'alh-style';
const PANEL_ID = 'alh-panel-root';
const PROMPT_ID = 'alh-prompt-root';
const LOG_ID = 'alh-log-root';
const INTERNAL_UI_SELECTORS = `#${PANEL_ID}, #${PROMPT_ID}, #${LOG_ID}`;
const DEFAULT_MODE = 'auto';
const DEFAULT_WEBDAV_DIR = 'auto-login-helper';
const DEFAULT_WEBDAV_FILENAME = 'auto-login-helper.json';
const MAX_LOGS = 200;
const SYNC_VERSION = 1;
const AUTO_SYNC_COOLDOWN_MS = 5 * 60 * 1000;
const state = {
configs: loadConfigs(),
configMeta: loadConfigMeta(),
activeConfigId: null,
autoAttempted: false,
logs: loadLogs(),
drag: null,
picker: null,
actionLocks: {},
attemptCounters: {},
logEnabled: loadLogEnabled(),
missingFieldWarnings: {},
managerTab: 'configs',
managerEditingConfigId: null,
managerFollowMatchedConfig: true,
selectedConfigIds: [],
syncSettings: loadSyncSettings(),
syncInProgress: false,
syncStatus: {
level: 'idle',
message: '尚未执行同步。',
time: '',
},
menuExpanded: false,
menuCommandIds: [],
};
state.syncStatus = buildInitialSyncStatus(state.syncSettings);
bootstrap();
function bootstrap() {
injectStyles();
registerMenu();
waitForPageReady(() => {
if (state.logEnabled) {
renderLogPanel();
log('info', '页面已加载,开始检测登录表单。');
}
maybeAutoSyncOnLoad();
runAutoLoginFlow();
observePageChanges();
});
}
function registerMenu() {
if (state.menuCommandIds && state.menuCommandIds.length) {
state.menuCommandIds.forEach(id => GM_unregisterMenuCommand(id));
}
state.menuCommandIds = [];
state.menuCommandIds.push(GM_registerMenuCommand('打开自动登录管理器', openManager));
state.menuCommandIds.push(GM_registerMenuCommand('快速添加当前站点(自动提交-Bitwarden)', () => quickAddCurrentSite('auto_bitwarden')));
state.menuCommandIds.push(GM_registerMenuCommand('快速添加当前站点(全自动-脚本账号密码)', () => quickAddCurrentSite('auto_script')));
if (state.menuExpanded) {
state.menuCommandIds.push(GM_registerMenuCommand('折叠更多选项 ▲', toggleAdvancedMenu, { autoClose: false }));
state.menuCommandIds.push(GM_registerMenuCommand('立即同步 WebDAV', () => {
syncWithWebDav('two-way', { manual: true });
}));
state.menuCommandIds.push(GM_registerMenuCommand('打开运行日志', toggleLogPanel));
state.menuCommandIds.push(GM_registerMenuCommand('关闭运行日志', disableLogPanel));
} else {
state.menuCommandIds.push(GM_registerMenuCommand('展开更多选项 ▼', toggleAdvancedMenu, { autoClose: false }));
}
}
function toggleAdvancedMenu() {
state.menuExpanded = !state.menuExpanded;
registerMenu();
}
function loadConfigs() {
const raw = GM_getValue(STORAGE_KEY, '[]');
try {
const parsed = JSON.parse(raw);
const { configs, changed } = sanitizeConfigCollection(Array.isArray(parsed) ? parsed : []);
if (changed) {
GM_setValue(STORAGE_KEY, JSON.stringify(configs));
}
return configs;
} catch (error) {
console.warn('[自动登录]', '解析配置失败:', error);
return [];
}
}
function saveConfigs() {
saveConfigsWithOptions();
}
function saveConfigsWithOptions(options = {}) {
const { configs, changed } = sanitizeConfigCollection(state.configs);
if (changed) {
state.configs = configs;
if (!state.configs.some((item) => item.id === state.activeConfigId)) {
state.activeConfigId = state.configs[0] ? state.configs[0].id : null;
}
if (!state.configs.some((item) => item.id === state.managerEditingConfigId)) {
state.managerEditingConfigId = state.activeConfigId;
}
state.selectedConfigIds = state.selectedConfigIds.filter((id) => state.configs.some((item) => item.id === id));
if (!state.selectedConfigIds.length && state.activeConfigId) {
state.selectedConfigIds = [state.activeConfigId];
}
}
GM_setValue(STORAGE_KEY, JSON.stringify(state.configs));
if (options.markUpdatedAt !== false) {
state.configMeta.updatedAt = options.updatedAt || new Date().toISOString();
} else if (options.updatedAt) {
state.configMeta.updatedAt = options.updatedAt;
}
saveConfigMeta();
if (options.scheduleSync !== false) {
scheduleAutoSyncOnSave();
}
}
function sanitizeConfigCollection(configs) {
const source = Array.isArray(configs) ? configs : [];
const seen = new Set();
let changed = false;
const next = source.map((item) => {
const config = item && typeof item === 'object' ? { ...item } : {};
const rawId = typeof config.id === 'string' ? config.id.trim() : '';
const id = rawId && !seen.has(rawId) ? rawId : createId();
if (id !== rawId) changed = true;
seen.add(id);
config.id = id;
return config;
});
return { configs: next, changed };
}
function loadLogs() {
const raw = GM_getValue(LOG_STORAGE_KEY, '[]');
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
}
function loadLogEnabled() {
return GM_getValue(LOG_ENABLED_KEY, false) === true;
}
function saveLogEnabled() {
GM_setValue(LOG_ENABLED_KEY, state.logEnabled === true);
}
function saveLogs() {
GM_setValue(LOG_STORAGE_KEY, JSON.stringify(state.logs.slice(-MAX_LOGS)));
}
function loadConfigMeta() {
const raw = GM_getValue(CONFIG_META_KEY, '{}');
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object'
? {
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '',
}
: { updatedAt: '' };
} catch (error) {
return { updatedAt: '' };
}
}
function saveConfigMeta() {
GM_setValue(CONFIG_META_KEY, JSON.stringify(state.configMeta || { updatedAt: '' }));
}
function loadSyncSettings() {
const raw = GM_getValue(SYNC_SETTINGS_KEY, '{}');
try {
const parsed = JSON.parse(raw);
return normalizeSyncSettings(parsed);
} catch (error) {
return normalizeSyncSettings({});
}
}
function saveSyncSettings() {
GM_setValue(SYNC_SETTINGS_KEY, JSON.stringify(state.syncSettings));
}
function normalizeSyncSettings(value) {
const source = value && typeof value === 'object' ? value : {};
return {
enabled: source.enabled === true,
webdavUrl: String(source.webdavUrl || '').trim(),
syncDirectory: String(source.syncDirectory || DEFAULT_WEBDAV_DIR).trim() || DEFAULT_WEBDAV_DIR,
fileName: String(source.fileName || DEFAULT_WEBDAV_FILENAME).trim() || DEFAULT_WEBDAV_FILENAME,
username: String(source.username || ''),
password: String(source.password || ''),
autoSyncOnLoad: source.autoSyncOnLoad !== false,
autoSyncOnSave: source.autoSyncOnSave !== false,
lastAutoSyncAt: String(source.lastAutoSyncAt || ''),
lastSyncAt: String(source.lastSyncAt || ''),
lastError: String(source.lastError || ''),
};
}
function maybeAutoSyncOnLoad() {
if (!state.syncSettings.enabled || !state.syncSettings.autoSyncOnLoad || !state.syncSettings.webdavUrl) {
return;
}
const lastAuto = Date.parse(state.syncSettings.lastAutoSyncAt || '');
if (Number.isFinite(lastAuto) && Date.now() - lastAuto < AUTO_SYNC_COOLDOWN_MS) {
return;
}
state.syncSettings.lastAutoSyncAt = new Date().toISOString();
saveSyncSettings();
syncWithWebDav('two-way', { manual: false });
}
function scheduleAutoSyncOnSave() {
if (!state.syncSettings.enabled || !state.syncSettings.autoSyncOnSave || !state.syncSettings.webdavUrl) {
return;
}
window.setTimeout(() => {
syncWithWebDav('push', { manual: false });
}, 0);
}
function waitForPageReady(callback) {
if (document.readyState === 'complete' || document.readyState === 'interactive') {
callback();
return;
}
window.addEventListener('DOMContentLoaded', callback, { once: true });
}
function observePageChanges() {
const observer = new MutationObserver(debounce(() => {
if (!document.hidden) {
log('debug', '页面发生变化,重新检测登录表单。');
runAutoLoginFlow();
}
}, 600));
observer.observe(document.documentElement || document.body, {
childList: true,
subtree: true,
});
}
function runAutoLoginFlow() {
const matches = getMatchingConfigs();
log('debug', `当前匹配到 ${matches.length} 个配置。`);
if (matches.length) {
const config = normalizeConfig(matches[0]);
if (isLoggedIn(config)) {
log('info', `站点 ${config.name || config.id} 已判定为登录状态,跳过自动登录。`);
removePrompt();
return;
}
const activeStep = resolveActiveStep(config);
if (!activeStep) {
warnMissingFields(config);
debugCandidateElements(config);
removePrompt();
return;
}
if (config.mode === 'prompt') {
const promptText = config.credentialSource === 'bitwarden'
? `检测到${getStepTypeLabel(activeStep.step.type)},等待 Bitwarden 或手动填充后执行。`
: '已检测到登录表单,点击即可自动填充并提交。';
showPrompt({
title: `准备登录:${config.name || location.host}`,
text: promptText,
actions: [
{ label: '执行', onClick: () => executeStep(config, activeStep, true) },
{ label: '编辑', onClick: openManager },
],
});
return;
}
if (config.mode === 'auto' && !state.autoAttempted) {
state.autoAttempted = true;
log('info', `站点 ${config.name || config.id} 进入自动执行流程,当前页面类型:${getStepTypeLabel(activeStep.step.type)}。`);
executeStep(config, activeStep, false);
}
return;
}
state.autoAttempted = false;
const guess = guessLoginElements();
if (!guess.password) {
log('debug', '当前页面未检测到密码框。');
removePrompt();
return;
}
log('info', '检测到疑似登录页面,但还没有保存配置。');
showPrompt({
title: `检测到登录页:${location.host}`,
text: '当前站点还没有保存配置。请选择该站点的登录执行方式。',
actions: [
{ label: '全自动(脚本账号密码)', onClick: () => quickAddCurrentSite('auto_script') },
{ label: '自动提交(Bitwarden)', onClick: () => quickAddCurrentSite('auto_bitwarden') },
{ label: '忽略', onClick: removePrompt },
],
});
}
function getMatchingConfigs() {
const matches = state.configs.filter((config) => config.enabled !== false && isMatch(config));
if (!matches.length && state.configs.length) {
debugConfigMismatchReasons();
}
return matches;
}
function debugConfigMismatchReasons() {
state.configs.forEach((config) => {
if (config.enabled === false) {
log('debug', `未匹配配置 ${config.name || config.id}:该配置已禁用。`);
return;
}
log('debug', `未匹配配置 ${config.name || config.id}:${getMismatchReason(config)}`);
});
}
function getMismatchReason(config) {
const url = location.href;
const host = location.host;
const path = `${location.host}${location.pathname}`;
if (config.matchType === 'regex') {
try {
return new RegExp(config.matchValue).test(url)
? '正则已匹配,但被其他条件过滤。'
: `当前 URL ${url} 不匹配正则 ${config.matchValue}`;
} catch (error) {
return `正则无效:${error.message}`;
}
}
if (config.matchType === 'url') {
return path.includes(config.matchValue || '')
? 'URL 已匹配,但被其他条件过滤。'
: `当前路径 ${path} 不包含 ${config.matchValue || '(空)'}`;
}
return host === config.matchValue || host.endsWith(`.${config.matchValue}`)
? '域名已匹配,但被其他条件过滤。'
: `当前域名 ${host} 不匹配 ${config.matchValue || '(空)'}`;
}
function isMatch(config) {
const url = location.href;
const host = location.host;
const path = `${location.host}${location.pathname}`;
if (config.matchType === 'regex') {
try {
const matched = new RegExp(config.matchValue).test(url);
if (matched) log('debug', `正则匹配成功:${config.matchValue}`);
return matched;
} catch (error) {
log('error', `正则匹配失败:${error.message}`);
return false;
}
}
if (config.matchType === 'url') {
const matched = path.includes(config.matchValue || '');
if (matched) log('debug', `URL 匹配成功:${config.matchValue}`);
return matched;
}
const matched = host === config.matchValue || host.endsWith(`.${config.matchValue}`);
if (matched) log('debug', `域名匹配成功:${config.matchValue}`);
return matched;
}
function isLoggedIn(config) {
return false;
}
function normalizeConfig(config) {
const normalized = { ...config };
normalized.mode = normalizeMode(normalized.mode);
normalized.credentialSource = normalized.credentialSource || 'bitwarden';
normalized.steps = Array.isArray(normalized.steps) && normalized.steps.length
? normalized.steps.map((step, index) => normalizeStep(step, index))
: [normalizeLegacyStep(normalized)];
return normalized;
}
function warnMissingFields(config) {
const key = `${config.id || config.matchValue}:${location.pathname}`;
const now = Date.now();
const last = state.missingFieldWarnings[key] || 0;
if (now - last < 5000) return;
state.missingFieldWarnings[key] = now;
log('warn', `配置 ${config.name || config.id} 已匹配,但未找到当前阶段需要的字段。`);
}
function debugCandidateElements(config) {
const key = `${config.id || config.matchValue}:${location.pathname}:debug`;
const now = Date.now();
const last = state.missingFieldWarnings[key] || 0;
if (now - last < 5000) return;
state.missingFieldWarnings[key] = now;
const docs = getSearchableDocuments();
docs.forEach((doc, index) => {
const inputs = collectCandidateElements(doc, 'input, textarea').slice(0, 8);
const buttons = collectCandidateElements(doc, 'button, input[type="submit"], [role="button"]').slice(0, 8);
log('debug', `候选调试 文档${index + 1} 输入框=${inputs.length} 按钮=${buttons.length}`);
inputs.forEach((element, itemIndex) => {
log('debug', `输入候选${itemIndex + 1}: ${describeElement(element)}`);
});
buttons.forEach((element, itemIndex) => {
log('debug', `按钮候选${itemIndex + 1}: ${describeElement(element)}`);
});
});
}
function normalizeStep(step, index) {
return {
id: step.id || `step_${index + 1}`,
name: step.name || '',
type: step.type || inferStepType(step),
usernameSelector: step.usernameSelector || '',
passwordSelector: step.passwordSelector || '',
otpSelector: step.otpSelector || '',
submitSelector: step.submitSelector || '',
waitUntil: step.waitUntil || inferWaitUntil(step),
autoSubmit: step.autoSubmit !== false,
fillDelay: Number(step.fillDelay || 400),
maxAttempts: Number(step.maxAttempts || 2),
autoDetectSubmit: step.autoDetectSubmit !== false,
};
}
function normalizeLegacyStep(config) {
return normalizeStep({
id: 'legacy',
name: '',
type: config.passwordSelector && config.usernameSelector ? 'credentials' : config.passwordSelector ? 'password' : 'username',
usernameSelector: config.usernameSelector || '',
passwordSelector: config.passwordSelector || '',
submitSelector: config.submitSelector || '',
waitUntil: config.credentialSource === 'bitwarden' ? inferWaitUntil(config) : 'readyNow',
autoSubmit: true,
fillDelay: 400,
maxAttempts: 2,
autoDetectSubmit: true,
}, 0);
}
function inferStepType(step) {
if (step.otpSelector) return 'otp';
if (step.passwordSelector && step.usernameSelector) return 'credentials';
if (step.passwordSelector) return 'password';
return 'username';
}
function inferWaitUntil(step) {
if (step.type === 'credentials') return 'usernameAndPasswordFilled';
if (step.type === 'password') return 'passwordFilled';
if (step.type === 'username') return 'usernameFilled';
if (step.type === 'otp') return 'otpFilled';
if (step.otpSelector) return 'otpFilled';
if (step.passwordSelector && step.usernameSelector) return 'usernameAndPasswordFilled';
if (step.passwordSelector) return 'passwordFilled';
return 'usernameFilled';
}
function resolveActiveStep(config) {
for (const step of config.steps) {
const stepContext = resolveStepContext(step);
const visible = stepContext.username || stepContext.password || stepContext.otp;
if (stepContext.ready || visible) {
log('debug', `已定位到阶段:${getStepLabel(step)}。`);
return { step, stepContext };
}
}
return null;
}
function resolveStepContext(step) {
const context = {
username: null,
password: null,
otp: null,
submit: null,
ready: false,
sourceDocument: document,
};
try {
context.username = step.usernameSelector ? queryAcrossDocuments(step.usernameSelector) : null;
context.password = step.passwordSelector ? queryAcrossDocuments(step.passwordSelector) : null;
context.otp = step.otpSelector ? queryAcrossDocuments(step.otpSelector) : null;
context.submit = step.submitSelector ? queryAcrossDocuments(step.submitSelector) : null;
} catch (error) {
log('error', `阶段 ${getStepLabel(step)} 选择器解析失败:${error.message}`);
return context;
}
const needsUsername = step.type === 'username' || step.type === 'credentials' || step.waitUntil === 'usernameFilled' || step.waitUntil === 'usernameAndPasswordFilled';
const needsPassword = step.type === 'password' || step.type === 'credentials' || step.waitUntil === 'passwordFilled' || step.waitUntil === 'usernameAndPasswordFilled';
const needsOtp = step.type === 'otp' || step.waitUntil === 'otpFilled';
if ((!context.username && needsUsername) || (!context.password && needsPassword) || (!context.otp && needsOtp) || (!context.submit && step.autoDetectSubmit !== false)) {
const guessed = guessLoginElementsDeep();
if (!context.username && needsUsername) context.username = guessed.username;
if (!context.password && needsPassword) context.password = guessed.password;
if (!context.otp && needsOtp) context.otp = guessed.otp;
if (!context.submit && !step.submitSelector) context.submit = guessed.submit;
}
if (!context.submit && step.autoDetectSubmit !== false) {
context.submit = guessSubmitElement(step, context);
}
context.ready = (!needsUsername || Boolean(context.username))
&& (!needsPassword || Boolean(context.password))
&& (!needsOtp || Boolean(context.otp));
log('debug', `阶段 ${getStepLabel(step)} 字段解析:用户名=${Boolean(context.username)},密码=${Boolean(context.password)},验证码=${Boolean(context.otp)},按钮=${Boolean(context.submit)}`);
return context;
}
function executeStep(config, activeStep, fromPrompt) {
const { step } = activeStep;
if (hasReachedAttemptLimit(config, step)) {
log('warn', `阶段 ${getStepLabel(step)} 已达到最大执行次数 ${step.maxAttempts},停止继续执行。`);
return;
}
const context = resolveStepContext(step);
if (!context.ready) {
notify('当前阶段字段不完整,请检查选择器配置。');
log('warn', `执行失败:阶段 ${getStepLabel(step)} 缺少必要字段。`);
return;
}
if (config.credentialSource === 'bitwarden') {
watchForExternalFill(config, step, context, fromPrompt);
return;
}
fillStepCredentials(config, step, context);
queueStepSubmit(config, step, context, fromPrompt);
}
function fillStepCredentials(config, step, context) {
log('info', `开始填充阶段 ${getStepLabel(step)}。`);
if (step.usernameSelector) fillField(context.username, config.username || '');
if (step.passwordSelector) fillField(context.password, config.password || '');
if (step.otpSelector && config.otpValue) fillField(context.otp, config.otpValue || '');
}
function queueStepSubmit(config, step, context, fromPrompt) {
log('debug', `阶段 ${getStepLabel(step)} 将在 ${step.fillDelay || 300}ms 后提交。`);
window.setTimeout(() => {
submitStep(config, step, context);
}, step.fillDelay || 300);
if (fromPrompt) removePrompt();
}
function watchForExternalFill(config, step, context, fromPrompt) {
const key = buildActionKey(config, step);
if (state.actionLocks[key]) {
log('debug', `阶段 ${getStepLabel(step)} 已在等待外部填充,跳过重复监听。`);
return;
}
state.actionLocks[key] = { waiting: true, submitted: false };
log('info', `阶段 ${getStepLabel(step)} 等待 Bitwarden 或手动填充。`);
const startedAt = Date.now();
const tick = () => {
const latestContext = resolveStepContext(step);
if (!latestContext.ready) {
if (Date.now() - startedAt < 180000) {
window.setTimeout(tick, 600);
} else {
log('warn', `阶段 ${getStepLabel(step)} 等待超时。`);
delete state.actionLocks[key];
}
return;
}
if (!isStepFilled(step, latestContext)) {
if (Date.now() - startedAt < 180000) {
window.setTimeout(tick, 600);
} else {
log('warn', `阶段 ${getStepLabel(step)} 在超时时间内未检测到填充值。`);
delete state.actionLocks[key];
}
return;
}
if (state.actionLocks[key] && !state.actionLocks[key].submitted) {
state.actionLocks[key].submitted = true;
log('info', `阶段 ${getStepLabel(step)} 已检测到外部填充,准备自动提交。`);
queueStepSubmit(config, step, latestContext, fromPrompt);
window.setTimeout(() => {
delete state.actionLocks[key];
}, 1200);
}
};
tick();
}
function isStepFilled(step, context) {
if (step.waitUntil === 'readyNow') return true;
if (step.waitUntil === 'usernameFilled') return hasValue(context.username);
if (step.waitUntil === 'passwordFilled') return hasValue(context.password);
if (step.waitUntil === 'usernameAndPasswordFilled') return hasValue(context.username) && hasValue(context.password);
if (step.waitUntil === 'otpFilled') return hasValue(context.otp);
return false;
}
function submitStep(config, step, context) {
increaseAttemptCounter(config, step);
if (step.autoSubmit === false) {
log('info', `阶段 ${getStepLabel(step)} 已满足条件,但配置为不自动提交。`);
return;
}
log('info', `开始提交阶段 ${getStepLabel(step)}。`);
if (context.submit) {
log('debug', `${step.submitSelector ? '使用配置' : '使用自动推断'}的提交按钮:${buildSelector(context.submit)}`);
triggerClick(context.submit);
return;
}
const baseField = context.otp || context.password || context.username;
if (baseField && baseField.form) {
log('debug', '使用表单 requestSubmit / submit 提交。');
baseField.form.requestSubmit ? baseField.form.requestSubmit() : baseField.form.submit();
return;
}
log('debug', '使用回车键触发提交。');
triggerEnter(baseField);
}
function buildActionKey(config, step) {
return `${config.id || config.matchValue}:${step.id}:${location.pathname}`;
}
function getAttemptKey(config, step) {
return `${config.id || config.matchValue}:${step.id}:${location.origin}${location.pathname}`;
}
function hasReachedAttemptLimit(config, step) {
const key = getAttemptKey(config, step);
const count = state.attemptCounters[key] || 0;
return count >= (step.maxAttempts || 2);
}
function increaseAttemptCounter(config, step) {
const key = getAttemptKey(config, step);
state.attemptCounters[key] = (state.attemptCounters[key] || 0) + 1;
log('info', `阶段 ${getStepLabel(step)} 第 ${state.attemptCounters[key]} 次执行。`);
}
function getStepLabel(step) {
return step.name || getStepTypeLabel(step.type);
}
function hasValue(element) {
return Boolean(element && String(element.value || '').trim());
}
function getStepTypeLabel(type) {
if (type === 'credentials') return '用户名和密码同页';
if (type === 'username') return '用户名页';
if (type === 'password') return '密码页';
if (type === 'otp') return '验证码页';
return type || '未知阶段';
}
function fillField(element, value) {
if (!element) {
log('debug', '跳过空字段填充。');
return;
}
element.focus();
const nativeSetter = Object.getOwnPropertyDescriptor(element.__proto__, 'value')?.set;
if (nativeSetter) {
nativeSetter.call(element, value);
} else {
element.value = value;
}
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
element.blur();
log('debug', `已填充字段:${buildSelector(element)}`);
}
function guessSubmitElement(step, context) {
const base = context.otp || context.password || context.username;
const scope = resolveScopeElement(base);
const candidate = findLikelySubmit(scope) || findLikelySubmit(document);
if (candidate) {
log('debug', `自动推断提交按钮成功:${buildSelector(candidate)}`);
}
return candidate;
}
function resolveScopeElement(base) {
if (!base) return document;
return base.closest('form') || base.closest('[role="dialog"]') || base.closest('section') || base.parentElement || document;
}
function findLikelySubmit(scope) {
if (!scope || !scope.querySelectorAll) return null;
const selectors = [
'button[type="submit"]',
'input[type="submit"]',
'button',
'[role="button"]',
'a',
'div[onclick]',
'span[onclick]',
'.login',
'.signin',
'.submit',
];
const elements = collectCandidateElements(scope, selectors.join(','))
.filter((element) => isVisible(element) && !isInternalUiElement(element));
if (!elements.length) return null;
const scored = elements.map((element) => ({ element, score: scoreSubmitCandidate(element) }))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score);
return scored.length ? scored[0].element : null;
}
function scoreSubmitCandidate(element) {
if (!(element instanceof Element)) return 0;
if (isInternalUiElement(element)) return 0;
const text = `${element.textContent || ''} ${element.getAttribute('value') || ''} ${element.getAttribute('aria-label') || ''}`.trim().toLowerCase();
const type = (element.getAttribute('type') || '').toLowerCase();
let score = 0;
if (type === 'submit') score += 10;
if (element.matches('button')) score += 4;
if (element.getAttribute('role') === 'button') score += 3;
if (element.matches('a, div[onclick], span[onclick]')) score += 2;
if (/登录|登入|sign in|log in|continue|next|submit|verify|验证/.test(text)) score += 8;
if (/注册|忘记|help|cancel|close/.test(text)) score -= 6;
const rect = element.getBoundingClientRect();
if (rect.width > 30 && rect.height > 20) score += 2;
if (rect.width > 500 || rect.height > 200) score -= 8;
return score;
}
function isInternalUiElement(element) {
return Boolean(element && element.closest && element.closest(INTERNAL_UI_SELECTORS));
}
function triggerClick(element) {
element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
element.click();
}
function triggerEnter(element) {
if (!element) return;
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
element.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }));
}
function guessLoginElements() {
const password = firstVisible('input[type="password"]');
const username = firstVisible([
'input[type="email"]',
'input[name*="user" i]',
'input[name*="login" i]',
'input[name*="account" i]',
'input[type="text"]',
].join(','));
const submit = firstVisible([
'button[type="submit"]',
'input[type="submit"]',
'button',
].join(','));
return { username, password, submit };
}
function guessLoginElementsDeep() {
const documents = getSearchableDocuments();
for (const doc of documents) {
const password = firstVisibleInRoot(doc, 'input[type="password"]');
const otp = firstVisibleInRoot(doc, [
'input[inputmode="numeric"]',
'input[name*="otp" i]',
'input[name*="code" i]',
'input[autocomplete="one-time-code"]',
].join(','));
const username = firstVisibleInRoot(doc, [
'input[type="email"]',
'input[name*="user" i]',
'input[name*="login" i]',
'input[name*="account" i]',
'input[type="text"]',
].join(','));
const submit = findLikelySubmit(doc);
if (username || password || otp || submit) {
return { username, password, otp, submit };
}
}
return { username: null, password: null, otp: null, submit: null };
}
function getSearchableDocuments() {
const docs = [document];
const frames = Array.from(document.querySelectorAll('iframe'));
frames.forEach((frame) => {
try {
if (frame.contentDocument) docs.push(frame.contentDocument);
} catch (error) {
// ignore cross-origin frames
}
});
return docs;
}
function queryAcrossDocuments(selector) {
const docs = getSearchableDocuments();
for (const doc of docs) {
try {
const element = firstVisibleInRoot(doc, selector);
if (element && isVisible(element)) return element;
} catch (error) {
// ignore invalid selector for this document pass
}
}
return null;
}
function firstVisibleInRoot(root, selector) {
const elements = collectCandidateElements(root, selector);
return elements.find((element) => isVisible(element) && !isInternalUiElement(element)) || null;
}
function collectCandidateElements(root, selector) {
const results = [];
const visited = new Set();
const walk = (currentRoot) => {
if (!currentRoot || visited.has(currentRoot)) return;
visited.add(currentRoot);
if (currentRoot.querySelectorAll) {
results.push(...Array.from(currentRoot.querySelectorAll(selector)).filter((element) => !isInternalUiElement(element)));
const allElements = currentRoot.querySelectorAll('*');
allElements.forEach((element) => {
if (element.shadowRoot) {
walk(element.shadowRoot);
}
});
}
};
walk(root);
return results;
}
function firstVisible(selector) {
const elements = Array.from(document.querySelectorAll(selector));
return elements.find((element) => isVisible(element) && !isInternalUiElement(element)) || null;
}
function isVisible(element) {
if (!element) return false;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
}
function quickAddCurrentSite(preset = 'auto_bitwarden') {
const guess = guessLoginElementsDeep();
const presetType = normalizeQuickPreset(preset);
const mode = 'auto';
const credentialSource = presetType === 'auto_script' ? 'script' : 'bitwarden';
const usernameValue = credentialSource === 'script'
? (window.prompt('请输入该站点用户名 / 邮箱', guess.username ? guess.username.value || '' : '') || '')
: '';
const passwordValue = credentialSource === 'script'
? (window.prompt('请输入该站点密码', guess.password ? guess.password.value || '' : '') || '')
: '';
const stepType = guess.otp && !guess.password && !guess.username
? 'otp'
: guess.password && guess.username
? 'credentials'
: guess.password
? 'password'
: guess.username
? 'username'
: 'password';
const waitUntil = credentialSource === 'bitwarden' ? inferWaitUntil({ type: stepType }) : 'readyNow';
const config = {
id: createId(),
name: location.host,
enabled: true,
matchType: 'host',
matchValue: location.host,
mode,
credentialSource: normalizeCredentialSource(credentialSource),
usernameSelector: guess.username ? buildSelector(guess.username) : '',
passwordSelector: guess.password ? buildSelector(guess.password) : (stepType === 'password' || stepType === 'credentials' ? 'input[type="password"]' : ''),
submitSelector: guess.submit ? buildSelector(guess.submit) : '',
username: usernameValue,
password: passwordValue,
otpValue: guess.otp ? String(guess.otp.value || '') : '',
steps: [
{
id: 'step_1',
name: '',
type: stepType,
usernameSelector: guess.username ? buildSelector(guess.username) : '',
passwordSelector: guess.password ? buildSelector(guess.password) : (stepType === 'password' || stepType === 'credentials' ? 'input[type="password"]' : ''),
otpSelector: guess.otp ? buildSelector(guess.otp) : '',
submitSelector: guess.submit ? buildSelector(guess.submit) : '',
waitUntil,
autoSubmit: true,
fillDelay: 400,
maxAttempts: 2,
autoDetectSubmit: true,
},
],
};
const index = state.configs.findIndex((item) => item.matchType === 'host' && item.matchValue === location.host);
if (index >= 0) {
config.id = state.configs[index].id;
state.configs[index] = config;
} else {
state.configs.push(config);
}
saveConfigs();
const modeLabel = '自动执行';
const sourceLabel = credentialSource === 'script' ? '脚本账号密码' : 'Bitwarden/手动填充';
notify(`配置已保存:${modeLabel} + ${sourceLabel}`);
log('info', `已快速保存站点配置:${location.host},模式=${mode},凭据来源=${credentialSource}`);
openManager(config.id);
}
function normalizeQuickPreset(value) {
const preset = String(value || '').trim().toLowerCase();
if (preset === 'manual') return 'auto_bitwarden';
if (preset === 'auto_script') return 'auto_script';
return 'auto_bitwarden';
}
function createId() {
return `cfg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
function normalizeMode(value) {
const mode = String(value || '').trim().toLowerCase();
if (mode === 'auto') return mode;
return 'prompt';
}
function normalizeCredentialSource(value) {
const source = String(value || '').trim().toLowerCase();
return source === 'bitwarden' ? 'bitwarden' : 'script';
}
function buildSelector(element) {
if (!element) return '';
if (element.id) return `#${cssEscape(element.id)}`;
const parts = [element.tagName.toLowerCase()];
if (element.name) parts.push(`[name="${escapeAttribute(element.name)}"]`);
if (element.type) parts.push(`[type="${escapeAttribute(element.type)}"]`);
if (element.classList.length) {
const firstClass = Array.from(element.classList).find(Boolean);
if (firstClass) parts.push(`.${cssEscape(firstClass)}`);
}
return parts.join('');
}
function cssEscape(value) {
return String(value).replace(/([ #;?%&,.+*~\':"!^$\[\]()=>|\/])/g, '\\$1');
}
function escapeAttribute(value) {
return String(value).replace(/"/g, '\\"');
}
function openManager(preselectId) {
removePrompt();
removeManager();
const preferredId = preselectId || findPreferredManagerConfigId() || (state.configs[0] && state.configs[0].id) || null;
state.activeConfigId = preferredId;
state.managerEditingConfigId = preferredId;
state.managerFollowMatchedConfig = !preselectId;
state.selectedConfigIds = preferredId ? [preferredId] : [];
if (preselectId) state.managerTab = 'configs';
const root = document.createElement('div');
root.id = PANEL_ID;
root.innerHTML = managerMarkup();
root.style.display = 'block';
document.body.appendChild(root);
makeDraggable(root.querySelector('.alh-panel'), root.querySelector('.alh-header'));
const panel = root.querySelector('.alh-panel');
if (panel) {
panel.style.position = 'fixed';
panel.style.left = '20px';
panel.style.top = '20px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
panel.style.margin = '0';
}
root.querySelector('.alh-close').addEventListener('click', removeManager);
renderManager();
}
function removeManager() {
const existing = document.getElementById(PANEL_ID);
if (existing) existing.remove();
}
function createEmptyConfig() {
return {
id: createId(),
name: location.host,
enabled: true,
matchType: 'host',
matchValue: location.host,
mode: DEFAULT_MODE,
credentialSource: 'bitwarden',
usernameSelector: '',
passwordSelector: 'input[type="password"]',
submitSelector: '',
username: '',
password: '',
otpValue: '',
steps: [
{
id: 'step_1',
name: '',
type: 'password',
usernameSelector: '',
passwordSelector: 'input[type="password"]',
otpSelector: '',
submitSelector: '',
waitUntil: 'passwordFilled',
autoSubmit: true,
fillDelay: 400,
maxAttempts: 2,
autoDetectSubmit: true,
},
],
};
}
function syncPanelMarkup() {
const settings = normalizeSyncSettings(state.syncSettings);
const target = resolveWebDavTarget(settings);
const statusClass = settings.enabled ? 'is-enabled' : 'is-disabled';
const statusTime = state.syncStatus.time || formatSyncDisplayTime(settings.lastSyncAt);
const statusMessage = state.syncStatus.message || (settings.lastError ? `上次失败:${settings.lastError}` : '尚未执行同步。');
return `
`;
}
function managerMarkup() {
return `
`;
}
function renderManager() {
const root = document.getElementById(PANEL_ID);
if (!root) return;
renderManagerTabs(root);
const contentEl = root.querySelector('.alh-content');
if (!contentEl) return;
const currentMatchedId = findPreferredManagerConfigId();
if (state.managerTab === 'configs' && state.managerFollowMatchedConfig && currentMatchedId) {
state.managerEditingConfigId = currentMatchedId;
state.activeConfigId = currentMatchedId;
state.selectedConfigIds = [currentMatchedId];
}
const editorConfigId = resolveEditorConfigId(currentMatchedId);
if (state.managerTab === 'configs' && editorConfigId && state.activeConfigId !== editorConfigId) {
state.activeConfigId = editorConfigId;
}
if (state.managerTab === 'sync') {
renderManagerToolbar(root, null);
contentEl.innerHTML = `
`;
renderSyncPanel(contentEl);
return;
}
contentEl.innerHTML = `
`;
const listEl = contentEl.querySelector('.alh-list');
const editorEl = contentEl.querySelector('.alh-editor');
const orderedConfigs = getOrderedConfigs(currentMatchedId);
listEl.innerHTML = state.configs.length
? orderedConfigs.map((config) => `
${escapeHtml(config.name || '(Unnamed)')}
${escapeHtml(config.matchType)}: ${escapeHtml(config.matchValue || '')}
${config.id === currentMatchedId ? '
当前页面命中' : ''}
`).join('')
: '当前还没有任何配置。
';
listEl.querySelectorAll('.alh-list-item').forEach((button) => {
button.addEventListener('click', (event) => {
if (event.target.closest('.alh-list-checkbox')) return;
state.managerFollowMatchedConfig = false;
state.managerEditingConfigId = button.dataset.id;
state.selectedConfigIds = [button.dataset.id];
state.activeConfigId = button.dataset.id;
renderManager();
});
});
listEl.querySelectorAll('.alh-list-checkbox').forEach((checkbox) => {
checkbox.addEventListener('click', (event) => {
event.stopPropagation();
});
checkbox.addEventListener('change', () => {
toggleConfigSelection(checkbox.dataset.id, checkbox.checked);
});
});
scrollManagerListToCurrent(listEl, currentMatchedId || editorConfigId || state.activeConfigId);
const active = state.configs.find((item) => item.id === (editorConfigId || state.activeConfigId)) || state.configs[0] || null;
renderManagerToolbar(root, active);
if (!active) {
editorEl.innerHTML = '请先新建一个配置开始使用。
';
return;
}
state.activeConfigId = active.id;
editorEl.innerHTML = editorMarkup(active);
const form = editorEl.querySelector('form');
applyModeVisibility(form);
form.querySelector('[name="mode"]').addEventListener('change', () => applyModeVisibility(form));
form.querySelector('[name="credentialSource"]').addEventListener('change', () => applyModeVisibility(form));
form.querySelector('[name="stepType"]').addEventListener('change', () => applyModeVisibility(form));
form.addEventListener('submit', (event) => {
event.preventDefault();
persistForm(form, active.id);
});
editorEl.querySelector('.alh-pick-user').addEventListener('click', () => {
startElementPicker(active.id, 'usernameSelector');
});
editorEl.querySelector('.alh-pick-pass').addEventListener('click', () => {
startElementPicker(active.id, 'passwordSelector');
});
editorEl.querySelector('.alh-pick-otp').addEventListener('click', () => {
startElementPicker(active.id, 'otpSelector');
});
editorEl.querySelector('.alh-pick-submit').addEventListener('click', () => {
startElementPicker(active.id, 'submitSelector');
});
bindConfigToolbarActions(root, form, active.id);
}
function renderManagerTabs(root) {
const tabsEl = root.querySelector('.alh-tabs');
if (!tabsEl) return;
tabsEl.innerHTML = `
`;
tabsEl.querySelectorAll('.alh-tab').forEach((button) => {
button.addEventListener('click', () => {
state.managerTab = button.dataset.tab === 'sync' ? 'sync' : 'configs';
renderManager();
});
});
}
function renderManagerToolbar(root, activeConfig) {
const toolbar = root.querySelector('.alh-toolbar');
if (!toolbar) return;
if (state.managerTab === 'sync') {
toolbar.innerHTML = `
同步设置
这里单独维护 WebDAV,同步路径和认证信息不会干扰站点配置编辑。
`;
return;
}
const allSelected = areAllConfigsSelected();
toolbar.innerHTML = `
`;
toolbar.querySelector('.alh-new').addEventListener('click', () => {
const fresh = createEmptyConfig();
state.configs.unshift(fresh);
state.managerFollowMatchedConfig = false;
state.managerEditingConfigId = fresh.id;
state.activeConfigId = fresh.id;
state.selectedConfigIds = [fresh.id];
renderManager();
});
toolbar.querySelector('.alh-quick-current').addEventListener('click', quickSetupCurrentSiteFromManager);
toolbar.querySelector('.alh-select-all').addEventListener('click', toggleSelectAllConfigs);
toolbar.querySelector('.alh-batch-delete').addEventListener('click', confirmBatchDeleteConfigs);
toolbar.querySelector('.alh-export').addEventListener('click', exportConfigs);
toolbar.querySelector('.alh-import').addEventListener('click', importConfigs);
}
function bindConfigToolbarActions(root, form, configId) {
const toolbar = root.querySelector('.alh-toolbar');
if (!toolbar || !form || !configId) return;
const saveButton = toolbar.querySelector('.alh-save');
const testButton = toolbar.querySelector('.alh-test');
const deleteButton = toolbar.querySelector('.alh-delete-one');
if (saveButton) {
saveButton.addEventListener('click', () => {
persistForm(form, configId);
});
}
if (testButton) {
testButton.addEventListener('click', () => {
persistForm(form, configId, false);
const config = state.configs.find((item) => item.id === configId);
const normalized = config ? normalizeConfig(config) : null;
const activeStep = normalized ? resolveActiveStep(normalized) : null;
if (normalized && activeStep) executeStep(normalized, activeStep, true);
});
}
if (deleteButton) {
deleteButton.addEventListener('click', () => {
const confirmed = window.confirm('确定删除当前站点配置吗?\n\n这个操作不可撤销。');
if (!confirmed) return;
state.configs = state.configs.filter((item) => item.id !== configId);
state.selectedConfigIds = state.selectedConfigIds.filter((id) => id !== configId);
state.activeConfigId = findPreferredManagerConfigId() || (state.configs[0] ? state.configs[0].id : null);
state.managerEditingConfigId = state.activeConfigId;
saveConfigs();
renderManager();
});
}
}
function renderSyncPanel(root) {
const syncEl = root.querySelector('.alh-sync');
if (!syncEl) return;
syncEl.innerHTML = syncPanelMarkup();
const form = syncEl.querySelector('.alh-sync-form');
if (!form) return;
form.addEventListener('submit', (event) => {
event.preventDefault();
persistSyncSettings(form);
});
syncEl.querySelector('.alh-sync-now').addEventListener('click', () => {
persistSyncSettings(form, false);
syncWithWebDav('two-way', { manual: true });
});
syncEl.querySelector('.alh-sync-pull').addEventListener('click', () => {
persistSyncSettings(form, false);
syncWithWebDav('pull', { manual: true });
});
syncEl.querySelector('.alh-sync-push').addEventListener('click', () => {
persistSyncSettings(form, false);
syncWithWebDav('push', { manual: true });
});
}
function findPreferredManagerConfigId() {
const current = state.configs.find((item) => item.id === state.activeConfigId);
if (current && current.enabled !== false && isMatch(current)) {
return current.id;
}
const matched = state.configs.find((config) => config.enabled !== false && isMatch(config));
return matched ? matched.id : null;
}
function resolveEditorConfigId(currentMatchedId) {
if (state.managerFollowMatchedConfig && currentMatchedId) return currentMatchedId;
if (state.managerEditingConfigId && state.configs.some((item) => item.id === state.managerEditingConfigId)) {
return state.managerEditingConfigId;
}
if (state.selectedConfigIds.length === 1 && state.configs.some((item) => item.id === state.selectedConfigIds[0])) {
return state.selectedConfigIds[0];
}
if (state.activeConfigId && state.configs.some((item) => item.id === state.activeConfigId)) {
return state.activeConfigId;
}
if (currentMatchedId) {
return currentMatchedId;
}
return state.configs[0] ? state.configs[0].id : null;
}
function getOrderedConfigs(currentMatchedId) {
const configs = [...state.configs];
if (!currentMatchedId) return configs;
configs.sort((left, right) => {
if (left.id === currentMatchedId) return -1;
if (right.id === currentMatchedId) return 1;
return 0;
});
return configs;
}
function toggleConfigSelection(configId, checked) {
if (!configId) return;
state.managerFollowMatchedConfig = false;
if (checked) {
state.selectedConfigIds = [configId];
state.activeConfigId = configId;
state.managerEditingConfigId = configId;
} else {
state.selectedConfigIds = state.selectedConfigIds.filter((id) => id !== configId);
if (state.activeConfigId === configId) {
const fallbackId = findPreferredManagerConfigId() || (state.configs[0] ? state.configs[0].id : null);
state.activeConfigId = fallbackId;
state.managerEditingConfigId = fallbackId;
}
}
renderManager();
}
function areAllConfigsSelected() {
return state.configs.length > 0 && state.configs.every((config) => state.selectedConfigIds.includes(config.id));
}
function toggleSelectAllConfigs() {
state.managerFollowMatchedConfig = false;
state.selectedConfigIds = areAllConfigsSelected()
? []
: state.configs.map((config) => config.id);
if (state.selectedConfigIds.length === 1) {
state.activeConfigId = state.selectedConfigIds[0];
state.managerEditingConfigId = state.selectedConfigIds[0];
} else if (!state.selectedConfigIds.length) {
const fallbackId = findPreferredManagerConfigId() || (state.configs[0] ? state.configs[0].id : null);
state.activeConfigId = fallbackId;
state.managerEditingConfigId = fallbackId;
}
renderManager();
}
function confirmBatchDeleteConfigs() {
const targets = state.configs.filter((config) => state.selectedConfigIds.includes(config.id));
if (!targets.length) return;
const confirmed = window.confirm(`将删除 ${targets.length} 条站点配置。\n\n这个操作不可撤销,确认继续吗?`);
if (!confirmed) return;
performBatchDeleteConfigs(targets.map((item) => item.id));
}
function scrollManagerListToCurrent(listEl, targetId) {
if (!listEl || !targetId) return;
const item = listEl.querySelector(`.alh-list-item[data-id="${cssEscape(targetId)}"]`);
if (!item) return;
window.setTimeout(() => {
item.scrollIntoView({ block: 'nearest' });
}, 0);
}
function quickSetupCurrentSiteFromManager() {
showPrompt({
title: `当前站点快速配置:${location.host}`,
text: '系统会自动识别输入框和提交按钮。请选择该站点执行方式。',
actions: [
{ label: '全自动(脚本账号密码)', onClick: () => quickAddCurrentSite('auto_script') },
{ label: '自动提交(Bitwarden)', onClick: () => quickAddCurrentSite('auto_bitwarden') },
{ label: '取消', onClick: removePrompt },
],
});
}
function performBatchDeleteConfigs(configIds) {
const ids = Array.isArray(configIds) ? configIds : [];
if (!ids.length) return;
state.configs = state.configs.filter((item) => !ids.includes(item.id));
state.selectedConfigIds = [];
state.activeConfigId = findPreferredManagerConfigId() || (state.configs[0] ? state.configs[0].id : null);
saveConfigsWithOptions({ scheduleSync: false });
renderManager();
notify(`已删除 ${ids.length} 条配置。`);
log('info', `已批量删除 ${ids.length} 条配置。`);
}
function editorMarkup(config) {
const normalized = normalizeConfig(config);
const step = normalized.steps[0] || normalizeLegacyStep(normalized);
return `
`;
}
function persistForm(form, configId, rerender = true) {
const formData = new FormData(form);
const stepType = String(formData.get('stepType') || 'password').trim();
const mode = normalizeMode(formData.get('mode'));
const effectiveCredentialSource = normalizeCredentialSource(formData.get('credentialSource') || 'bitwarden');
const steps = [normalizeStep({
id: 'step_1',
name: '',
type: stepType,
usernameSelector: String(formData.get('cfgUserSelector') || '').trim(),
passwordSelector: String(formData.get('cfgPassSelector') || '').trim(),
otpSelector: String(formData.get('cfgOtpSelector') || '').trim(),
submitSelector: String(formData.get('cfgSubmitSelector') || '').trim(),
waitUntil: inferWaitUntil({
type: stepType,
usernameSelector: String(formData.get('cfgUserSelector') || '').trim(),
passwordSelector: String(formData.get('cfgPassSelector') || '').trim(),
otpSelector: String(formData.get('cfgOtpSelector') || '').trim(),
}),
autoSubmit: String(formData.get('autoSubmit')) === 'true',
autoDetectSubmit: String(formData.get('autoDetectSubmit')) !== 'false',
fillDelay: Number(formData.get('fillDelay') || 400),
maxAttempts: Number(formData.get('maxAttempts') || 2),
}, 0)];
const next = {
id: configId,
name: String(formData.get('name') || '').trim(),
enabled: String(formData.get('enabled')) === 'true',
matchType: String(formData.get('matchType') || 'host'),
matchValue: String(formData.get('matchValue') || '').trim(),
mode,
credentialSource: effectiveCredentialSource,
usernameSelector: steps[0].usernameSelector,
passwordSelector: steps[0].passwordSelector,
submitSelector: steps[0].submitSelector,
username: String(formData.get('cfgStoredUser') || form.querySelector('[name="cfgStoredUser"]')?.value || ''),
password: String(formData.get('cfgStoredPass') || form.querySelector('[name="cfgStoredPass"]')?.value || ''),
otpValue: String(formData.get('cfgOtpValue') || form.querySelector('[name="cfgOtpValue"]')?.value || ''),
steps,
};
const index = state.configs.findIndex((item) => item.id === configId);
if (index >= 0) state.configs[index] = next;
saveConfigs();
notify('配置已保存。');
log('info', `配置已保存:${next.name || next.id}`);
if (rerender) renderManager();
}
function persistSyncSettings(form, rerender = true) {
const formData = new FormData(form);
state.syncSettings = normalizeSyncSettings({
enabled: String(formData.get('enabled')) === 'true',
autoSyncOnLoad: String(formData.get('autoSyncOnLoad')) !== 'false',
autoSyncOnSave: String(formData.get('autoSyncOnSave')) !== 'false',
webdavUrl: String(formData.get('webdavUrl') || '').trim(),
syncDirectory: String(formData.get('syncDirectory') || DEFAULT_WEBDAV_DIR).trim() || DEFAULT_WEBDAV_DIR,
fileName: String(formData.get('fileName') || DEFAULT_WEBDAV_FILENAME).trim() || DEFAULT_WEBDAV_FILENAME,
username: String(formData.get('username') || ''),
password: String(formData.get('password') || ''),
lastAutoSyncAt: state.syncSettings.lastAutoSyncAt,
lastSyncAt: state.syncSettings.lastSyncAt,
lastError: state.syncSettings.lastError,
});
saveSyncSettings();
state.syncStatus = {
level: 'info',
message: state.syncSettings.enabled ? '同步设置已保存。' : '已保存,当前处于未启用状态。',
time: new Date().toLocaleString(),
};
if (rerender && document.getElementById(PANEL_ID)) {
renderManager();
}
}
async function syncWithWebDav(mode = 'two-way', options = {}) {
if (state.syncInProgress) {
if (options.manual) {
state.syncStatus = {
level: 'warn',
message: '已有同步任务正在执行,请稍候。',
time: new Date().toLocaleString(),
};
if (document.getElementById(PANEL_ID)) renderManager();
}
return false;
}
const settings = normalizeSyncSettings(state.syncSettings);
if (!settings.enabled || !settings.webdavUrl) {
if (options.manual) {
state.syncStatus = {
level: 'warn',
message: '请先启用 WebDAV 同步并填写文件地址。',
time: new Date().toLocaleString(),
};
if (document.getElementById(PANEL_ID)) renderManager();
}
return false;
}
state.syncInProgress = true;
state.syncStatus = {
level: 'info',
message: `正在执行${getSyncModeLabel(mode)}...`,
time: new Date().toLocaleString(),
};
if (document.getElementById(PANEL_ID)) renderManager();
try {
const localPayload = buildSyncPayload();
if (mode === 'push') {
await uploadRemotePayload(localPayload, settings);
finalizeSyncSuccess('已将本地配置推送到 WebDAV。');
return true;
}
const remotePayload = await downloadRemotePayload(settings);
if (mode === 'pull') {
if (!remotePayload) {
throw new Error('WebDAV 远端文件不存在,无法拉取。');
}
applyRemotePayload(remotePayload);
finalizeSyncSuccess('已从 WebDAV 拉取配置。');
return true;
}
if (!remotePayload) {
await uploadRemotePayload(localPayload, settings);
finalizeSyncSuccess('远端暂无配置,已自动创建并推送本地配置。');
return true;
}
const remoteUpdatedAt = parseTimestamp(remotePayload.updatedAt);
const localUpdatedAt = parseTimestamp(localPayload.updatedAt);
if (remoteUpdatedAt > localUpdatedAt) {
applyRemotePayload(remotePayload);
finalizeSyncSuccess('检测到 WebDAV 配置更新较新,已拉取覆盖本地。');
return true;
}
await uploadRemotePayload(localPayload, settings);
finalizeSyncSuccess(remoteUpdatedAt === localUpdatedAt
? '本地与远端时间戳一致,已重新推送确保内容一致。'
: '本地配置较新,已推送到 WebDAV。');
return true;
} catch (error) {
finalizeSyncFailure(error);
return false;
} finally {
state.syncInProgress = false;
if (document.getElementById(PANEL_ID)) renderManager();
}
}
function buildSyncPayload() {
return {
version: SYNC_VERSION,
updatedAt: state.configMeta.updatedAt || new Date().toISOString(),
exportedAt: new Date().toISOString(),
configs: state.configs,
};
}
function applyRemotePayload(payload) {
if (!payload || !Array.isArray(payload.configs)) {
throw new Error('WebDAV 返回的数据格式不正确。');
}
state.configs = payload.configs;
state.activeConfigId = state.configs.find((item) => item.id === state.activeConfigId)
? state.activeConfigId
: (state.configs[0] ? state.configs[0].id : null);
saveConfigsWithOptions({
markUpdatedAt: false,
updatedAt: normalizeIsoTimestamp(payload.updatedAt) || new Date().toISOString(),
scheduleSync: false,
});
log('info', `已从 WebDAV 应用 ${state.configs.length} 条配置。`);
}
async function downloadRemotePayload(settings) {
const target = resolveWebDavTarget(settings);
const response = await webdavRequest({
method: 'GET',
url: target.fileUrl,
username: settings.username,
password: settings.password,
headers: {
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
},
allowNotFound: true,
});
if (response.status === 404 || !response.responseText) {
return null;
}
try {
return JSON.parse(response.responseText);
} catch (error) {
throw new Error(`远端配置 JSON 解析失败:${error.message}`);
}
}
async function uploadRemotePayload(payload, settings) {
const target = resolveWebDavTarget(settings);
await ensureWebDavDirectory(target, settings);
await webdavRequest({
method: 'PUT',
url: target.fileUrl,
username: settings.username,
password: settings.password,
data: JSON.stringify(payload, null, 2),
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
});
}
function finalizeSyncSuccess(message) {
const time = new Date().toLocaleString();
state.syncSettings.lastSyncAt = new Date().toISOString();
state.syncSettings.lastError = '';
saveSyncSettings();
state.syncStatus = {
level: 'success',
message,
time,
};
log('info', message);
}
function finalizeSyncFailure(error) {
const message = error instanceof Error ? error.message : String(error);
state.syncSettings.lastError = message;
saveSyncSettings();
state.syncStatus = {
level: 'error',
message: `同步失败:${message}`,
time: new Date().toLocaleString(),
};
log('error', `WebDAV 同步失败:${message}`);
}
function getSyncModeLabel(mode) {
if (mode === 'pull') return '拉取';
if (mode === 'push') return '推送';
return '智能同步';
}
function buildInitialSyncStatus(settings) {
const normalized = normalizeSyncSettings(settings);
if (normalized.lastError) {
return {
level: 'error',
message: `上次同步失败:${normalized.lastError}`,
time: formatSyncDisplayTime(normalized.lastSyncAt),
};
}
if (normalized.lastSyncAt) {
return {
level: 'idle',
message: '已保存同步设置,等待下一次同步。',
time: formatSyncDisplayTime(normalized.lastSyncAt),
};
}
return {
level: 'idle',
message: '尚未执行同步。',
time: '',
};
}
function parseTimestamp(value) {
const timestamp = Date.parse(normalizeIsoTimestamp(value));
return Number.isFinite(timestamp) ? timestamp : 0;
}
function normalizeIsoTimestamp(value) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function resolveWebDavTarget(settings) {
const rawUrl = String(settings?.webdavUrl || '').trim();
if (!rawUrl) {
return {
baseUrl: '',
directoryUrl: '',
fileUrl: '',
};
}
const normalizedBase = rawUrl.endsWith('/') ? rawUrl : `${rawUrl}/`;
const directory = sanitizeWebDavPathSegment(String(settings?.syncDirectory || DEFAULT_WEBDAV_DIR).trim() || DEFAULT_WEBDAV_DIR);
const fileName = sanitizeWebDavFileName(String(settings?.fileName || DEFAULT_WEBDAV_FILENAME).trim() || DEFAULT_WEBDAV_FILENAME);
const directoryUrl = directory ? `${normalizedBase}${directory}/` : normalizedBase;
return {
baseUrl: normalizedBase,
directoryUrl,
fileUrl: `${directoryUrl}${fileName}`,
};
}
async function ensureWebDavDirectory(target, settings) {
if (!target || !target.baseUrl || !target.directoryUrl || target.directoryUrl === target.baseUrl) {
return;
}
const relativePath = target.directoryUrl.slice(target.baseUrl.length).replace(/\/$/, '');
if (!relativePath) return;
const segments = relativePath.split('/').filter(Boolean);
let currentUrl = target.baseUrl;
for (const segment of segments) {
currentUrl = `${currentUrl}${segment}/`;
await webdavRequest({
method: 'MKCOL',
url: currentUrl,
username: settings.username,
password: settings.password,
allowStatuses: [201, 301, 405],
});
}
}
function sanitizeWebDavPathSegment(value) {
return String(value || '')
.split('/')
.map((part) => part.trim())
.filter(Boolean)
.join('/');
}
function sanitizeWebDavFileName(value) {
const cleaned = String(value || '').trim().replace(/[\\/]/g, '');
return cleaned || DEFAULT_WEBDAV_FILENAME;
}
function formatSyncDisplayTime(value) {
const normalized = normalizeIsoTimestamp(value);
if (!normalized) return '';
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? normalized : date.toLocaleString();
}
function webdavRequest({ method, url, username, password, headers, data, allowNotFound = false, allowStatuses = [] }) {
return new Promise((resolve, reject) => {
const requestHeaders = { ...(headers || {}) };
if (username || password) {
requestHeaders.Authorization = `Basic ${toBase64(`${username || ''}:${password || ''}`)}`;
}
GM_xmlhttpRequest({
method,
url,
headers: requestHeaders,
data,
onload: (response) => {
if ((response.status >= 200 && response.status < 300) || allowStatuses.includes(response.status) || (allowNotFound && response.status === 404)) {
resolve(response);
return;
}
const maybeDirectoryHint = response.status === 403
? ',请确认 WebDAV 账号有读写权限'
: response.status === 404
? ',请确认 WebDAV 根地址正确;脚本会自动创建同步目录,但根地址本身必须存在'
: '';
reject(new Error(`HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}${maybeDirectoryHint}`));
},
onerror: () => {
reject(new Error('网络请求失败,请检查地址、认证信息或跨域权限。'));
},
ontimeout: () => {
reject(new Error('网络请求超时。'));
},
});
});
}
function toBase64(value) {
try {
return window.btoa(unescape(encodeURIComponent(value)));
} catch (error) {
return window.btoa(value);
}
}
function applyModeVisibility(form) {
if (!form) return;
const credentialSource = normalizeCredentialSource(form.querySelector('[name="credentialSource"]')?.value || 'bitwarden');
const stepType = String(form.querySelector('[name="stepType"]')?.value || 'password');
const storedFields = ['cfgStoredUser', 'cfgStoredPass', 'cfgOtpValue'];
const autoOnlyFields = ['autoDetectSubmit', 'fillDelay', 'maxAttempts'];
const selectorEnableMap = {
cfgUserSelector: stepType === 'username' || stepType === 'credentials',
cfgPassSelector: stepType === 'password' || stepType === 'credentials',
cfgOtpSelector: stepType === 'otp',
cfgSubmitSelector: true,
};
const credentialSourceField = form.querySelector('[data-field="credentialSource"]');
setFieldHidden(credentialSourceField, false);
setFieldDisabled(credentialSourceField, false);
storedFields.forEach((field) => {
const fieldEl = form.querySelector(`[data-field="${field}"]`);
setFieldHidden(fieldEl, false);
setFieldDisabled(fieldEl, credentialSource !== 'script');
});
autoOnlyFields.forEach((field) => {
const fieldEl = form.querySelector(`[data-field="${field}"]`);
setFieldHidden(fieldEl, false);
setFieldDisabled(fieldEl, false);
});
Object.keys(selectorEnableMap).forEach((field) => {
const fieldEl = form.querySelector(`[data-field="${field}"]`);
setFieldHidden(fieldEl, false);
setFieldDisabled(fieldEl, selectorEnableMap[field] !== true);
});
}
function setFieldHidden(element, hidden) {
if (!element) return;
element.classList.toggle('is-hidden', hidden);
}
function setFieldDisabled(element, disabled) {
if (!element) return;
element.classList.toggle('is-disabled', disabled);
element.querySelectorAll('input, select, textarea, button').forEach((control) => {
control.disabled = disabled;
});
}
function exportConfigs() {
const payload = buildSyncPayload();
const fileName = `auto-login-helper-export-${formatFileTimestamp(new Date())}.json`;
downloadTextFile(fileName, JSON.stringify(payload, null, 2), 'application/json;charset=utf-8');
state.syncStatus = {
level: 'success',
message: `已导出配置文件:${fileName}`,
time: new Date().toLocaleString(),
};
if (document.getElementById(PANEL_ID)) renderManager();
log('info', `已导出配置文件:${fileName}`);
}
function importConfigs() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.addEventListener('change', () => {
const file = input.files && input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const raw = typeof reader.result === 'string' ? reader.result : '';
const parsed = JSON.parse(raw);
const imported = normalizeImportedConfigBundle(parsed);
state.configs = imported.configs;
state.activeConfigId = state.configs[0] ? state.configs[0].id : null;
saveConfigsWithOptions({
updatedAt: imported.updatedAt || new Date().toISOString(),
});
renderManager();
notify('导入成功。');
log('info', `已导入 ${state.configs.length} 条配置。`);
} catch (error) {
window.alert(`导入失败:${error.message}`);
log('error', `导入失败:${error.message}`);
}
};
reader.onerror = () => {
window.alert('导入失败:文件读取失败。');
log('error', '导入失败:文件读取失败。');
};
reader.readAsText(file, 'utf-8');
}, { once: true });
input.click();
}
function normalizeImportedConfigBundle(value) {
if (Array.isArray(value)) {
return {
configs: value,
updatedAt: new Date().toISOString(),
};
}
if (!value || typeof value !== 'object') {
throw new Error('JSON 格式不正确。');
}
if (Array.isArray(value.siteConfigs)) {
return {
configs: value.siteConfigs,
updatedAt: normalizeIsoTimestamp(value.updatedAt) || new Date().toISOString(),
};
}
if (!Array.isArray(value.configs)) {
throw new Error('导入文件缺少 configs 数组。');
}
return {
configs: value.configs,
updatedAt: normalizeIsoTimestamp(value.updatedAt) || new Date().toISOString(),
};
}
function downloadTextFile(fileName, content, mimeType) {
const blob = new Blob([content], { type: mimeType || 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => {
URL.revokeObjectURL(url);
}, 1000);
}
function formatFileTimestamp(date) {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const mi = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${yyyy}${mm}${dd}-${hh}${mi}${ss}`;
}
function showPrompt({ title, text, actions }) {
removePrompt();
const root = document.createElement('div');
root.id = PROMPT_ID;
root.innerHTML = `
`;
makeDraggable(root.querySelector('.alh-prompt'), root.querySelector('.alh-prompt-header'));
root.querySelector('.alh-prompt-close').addEventListener('click', removePrompt);
const actionsEl = root.querySelector('.alh-prompt-actions');
actions.forEach((action) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = action.label;
button.addEventListener('click', action.onClick);
actionsEl.appendChild(button);
});
document.body.appendChild(root);
}
function removePrompt() {
const existing = document.getElementById(PROMPT_ID);
if (existing) existing.remove();
}
function notify(message) {
if (state.logEnabled) {
console.info('[自动登录]', message);
log('info', message);
}
}
function renderLogPanel() {
if (!state.logEnabled) return;
if (!document.body) return;
let root = document.getElementById(LOG_ID);
if (!root) {
root = document.createElement('div');
root.id = LOG_ID;
root.innerHTML = `
`;
document.body.appendChild(root);
const logPanel = root.querySelector('.alh-log-panel');
makeDraggable(logPanel, root.querySelector('.alh-log-header'));
root.querySelector('.alh-log-toggle').addEventListener('click', () => {
logPanel.classList.toggle('is-collapsed');
root.querySelector('.alh-log-toggle').textContent = logPanel.classList.contains('is-collapsed') ? '展开' : '收起';
});
root.querySelector('.alh-log-close').addEventListener('click', () => {
root.style.display = 'none';
});
logPanel.style.left = '18px';
logPanel.style.bottom = '18px';
}
const body = root.querySelector('.alh-log-body');
body.innerHTML = state.logs.length
? state.logs.slice(-50).reverse().map((entry) => `
${escapeHtml(entry.time)}
${escapeHtml(entry.level.toUpperCase())}
${escapeHtml(entry.message)}
`).join('')
: '暂无日志。
';
}
function toggleLogPanel() {
state.logEnabled = true;
saveLogEnabled();
renderLogPanel();
const root = document.getElementById(LOG_ID);
if (!root) return;
root.style.display = 'block';
const panel = root.querySelector('.alh-log-panel');
if (panel) {
panel.classList.remove('is-collapsed');
const toggle = root.querySelector('.alh-log-toggle');
if (toggle) toggle.textContent = '收起';
}
}
function disableLogPanel() {
state.logEnabled = false;
saveLogEnabled();
clearLogs(false);
const root = document.getElementById(LOG_ID);
if (root) root.remove();
}
function clearLogs(shouldRender = true) {
state.logs = [];
saveLogs();
if (shouldRender && state.logEnabled) renderLogPanel();
}
function log(level, message) {
if (!state.logEnabled) return;
const entry = {
level,
message,
time: new Date().toLocaleString(),
};
state.logs.push(entry);
state.logs = state.logs.slice(-MAX_LOGS);
saveLogs();
renderLogPanel();
}
function startElementPicker(configId, fieldName) {
stopElementPicker();
notify('请选择页面上的目标元素,按 Esc 可取消。');
log('info', `开始选择元素:${fieldName}`);
state.picker = {
configId,
fieldName,
overlay: null,
moveHandler: null,
clickHandler: null,
keyHandler: null,
};
removeManager();
const overlay = document.createElement('div');
overlay.className = 'alh-picker-highlight';
document.body.appendChild(overlay);
state.picker.overlay = overlay;
removePrompt();
state.picker.moveHandler = (event) => {
const rawTarget = event.target;
if (!(rawTarget instanceof Element) || isInternalUiElement(rawTarget)) {
overlay.style.display = 'none';
return;
}
const target = resolvePickerTarget(rawTarget, fieldName);
const rect = target.getBoundingClientRect();
overlay.style.display = 'block';
overlay.style.left = `${rect.left + window.scrollX}px`;
overlay.style.top = `${rect.top + window.scrollY}px`;
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
};
state.picker.clickHandler = (event) => {
const rawTarget = event.target;
if (!(rawTarget instanceof Element)) return;
if (isInternalUiElement(rawTarget)) return;
event.preventDefault();
event.stopPropagation();
const target = resolvePickerTarget(rawTarget, fieldName);
const selector = buildSelector(target);
const config = state.configs.find((item) => item.id === configId);
if (config) {
const normalized = normalizeConfig(config);
if (fieldName === 'usernameSelector' || fieldName === 'passwordSelector' || fieldName === 'submitSelector' || fieldName === 'otpSelector') {
normalized.steps[0][fieldName] = selector;
if (fieldName !== 'otpSelector') {
normalized[fieldName] = selector;
}
Object.assign(config, normalized);
} else {
config[fieldName] = selector;
}
saveConfigs();
log('info', `已为 ${fieldName} 选择元素:${selector}`);
renderManager();
}
stopElementPicker();
};
state.picker.keyHandler = (event) => {
if (event.key === 'Escape') {
log('info', '已取消元素选择。');
stopElementPicker();
}
};
document.addEventListener('mousemove', state.picker.moveHandler, true);
document.addEventListener('click', state.picker.clickHandler, true);
document.addEventListener('keydown', state.picker.keyHandler, true);
}
function stopElementPicker() {
if (!state.picker) return;
const configId = state.picker.configId;
document.removeEventListener('mousemove', state.picker.moveHandler, true);
document.removeEventListener('click', state.picker.clickHandler, true);
document.removeEventListener('keydown', state.picker.keyHandler, true);
if (state.picker.overlay) state.picker.overlay.remove();
state.picker = null;
openManager(configId);
}
function resolvePickerTarget(target, fieldName) {
if (!(target instanceof Element)) return target;
if (fieldName === 'submitSelector') {
return resolveButtonCandidate(target);
}
return target;
}
function resolveButtonCandidate(target) {
const direct = target.closest('button, input[type="submit"], [role="button"], a.button, a.btn');
if (direct && isVisible(direct) && !isOversized(direct)) return direct;
const child = findLikelySubmit(target);
if (child && !isOversized(child)) return child;
let current = target;
for (let index = 0; current && index < 4; index += 1) {
if (!isOversized(current)) {
const nested = findLikelySubmit(current);
if (nested && !isOversized(nested)) return nested;
}
current = current.parentElement;
}
return target;
}
function isOversized(element) {
if (!(element instanceof Element)) return false;
const rect = element.getBoundingClientRect();
return rect.width > Math.max(window.innerWidth * 0.8, 500) || rect.height > Math.max(window.innerHeight * 0.35, 220);
}
function describeElement(element) {
if (!(element instanceof Element)) return 'unknown';
const rect = element.getBoundingClientRect();
const text = `${element.textContent || ''}`.trim().replace(/\s+/g, ' ').slice(0, 40);
const attrs = [
element.tagName.toLowerCase(),
element.id ? `#${element.id}` : '',
element.getAttribute('name') ? `[name="${element.getAttribute('name')}"]` : '',
element.getAttribute('type') ? `[type="${element.getAttribute('type')}"]` : '',
element.getAttribute('role') ? `[role="${element.getAttribute('role')}"]` : '',
].filter(Boolean).join('');
return `${attrs} 文本="${text}" 尺寸=${Math.round(rect.width)}x${Math.round(rect.height)}`;
}
function makeDraggable(panel, handle) {
if (!panel || !handle || handle.dataset.alhDragBound === 'true') return;
handle.dataset.alhDragBound = 'true';
handle.style.cursor = 'move';
handle.addEventListener('mousedown', (event) => {
if (event.target.closest('button, input, select, textarea')) return;
const rect = panel.getBoundingClientRect();
panel.style.margin = '0';
panel.style.left = `${rect.left}px`;
panel.style.top = `${rect.top}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
state.drag = {
panel,
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top,
};
event.preventDefault();
});
}
document.addEventListener('mousemove', (event) => {
if (!state.drag) return;
const left = Math.max(0, Math.min(window.innerWidth - 120, event.clientX - state.drag.offsetX));
const top = Math.max(0, Math.min(window.innerHeight - 60, event.clientY - state.drag.offsetY));
state.drag.panel.style.left = `${left}px`;
state.drag.panel.style.top = `${top}px`;
state.drag.panel.style.position = 'fixed';
});
document.addEventListener('mouseup', () => {
if (state.drag) state.drag = null;
});
function injectStyles() {
if (document.getElementById(STYLE_ID)) return;
const css = `
#${PANEL_ID}, #${PROMPT_ID}, #${LOG_ID} {
font-family: "Segoe UI", sans-serif;
}
#${PANEL_ID}, #${PROMPT_ID}, #${PANEL_ID} *, #${PROMPT_ID} * {
box-sizing: border-box;
font-family: "Segoe UI", sans-serif;
}
#${LOG_ID}, #${LOG_ID} * {
box-sizing: border-box;
font-family: "Segoe UI", sans-serif;
}
#${PANEL_ID} {
position: fixed;
inset: 0;
z-index: 2147483646;
}
#${PANEL_ID} .alh-backdrop {
position: absolute;
inset: 0;
background: rgba(10, 18, 30, 0.45);
}
#${PANEL_ID} .alh-panel {
position: relative;
display: flex;
flex-direction: column;
width: min(1100px, calc(100vw - 40px));
height: min(760px, calc(100vh - 40px));
margin: 20px auto;
background: #f6f2ea;
color: #1f2a30;
border-radius: 18px;
overflow-x: hidden;
overflow-y: auto;
box-shadow: 0 18px 70px rgba(0, 0, 0, 0.28);
}
#${PANEL_ID} .alh-header,
#${PANEL_ID} .alh-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
background: linear-gradient(135deg, #efe3cf, #d7e4dc);
}
#${PANEL_ID} .alh-tabs {
display: flex;
gap: 10px;
padding: 14px 20px 0;
background: linear-gradient(135deg, #efe3cf, #d7e4dc);
border-top: 1px solid rgba(31, 42, 48, 0.08);
}
#${PANEL_ID} .alh-tab {
border: 0;
border-radius: 14px 14px 0 0;
padding: 12px 18px;
background: rgba(36, 64, 74, 0.14);
color: #24404a;
cursor: pointer;
font-weight: 700;
}
#${PANEL_ID} .alh-tab.is-active {
background: #f6f2ea;
color: #1f2a30;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
#${PANEL_ID} h2 {
margin: 0 0 6px;
font-size: 24px;
}
#${PANEL_ID} p {
margin: 0;
color: #43535b;
}
#${PANEL_ID} .alh-toolbar {
justify-content: flex-start;
flex-wrap: wrap;
padding-top: 10px;
padding-bottom: 10px;
border-top: 1px solid rgba(31, 42, 48, 0.08);
}
#${PANEL_ID} .alh-toolbar-note {
display: flex;
flex-direction: column;
gap: 4px;
color: #33434b;
}
#${PANEL_ID} .alh-toolbar-note strong {
color: #1f2a30;
}
#${PANEL_ID} .alh-content {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
#${PANEL_ID} .alh-sync {
padding: 14px;
background: #f8f4ec;
border-radius: 12px;
border: 1px solid rgba(31, 42, 48, 0.08);
box-shadow: 0 6px 18px rgba(31, 42, 48, 0.05);
}
#${PANEL_ID} .alh-sync-page {
flex: 1;
padding: 12px 14px 14px;
overflow: auto;
}
#${PANEL_ID} .alh-sync-form {
display: flex;
flex-direction: column;
gap: 8px;
}
#${PANEL_ID} .alh-sync-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
#${PANEL_ID} .alh-sync-title strong {
display: block;
font-size: 15px;
}
#${PANEL_ID} .alh-sync-title p {
font-size: 12px;
line-height: 1.4;
max-width: 460px;
}
#${PANEL_ID} .alh-sync-state {
display: inline-flex;
align-items: center;
margin-top: 4px;
padding: 3px 8px;
border-radius: 999px;
font-size: 11px;
}
#${PANEL_ID} .alh-sync-state.is-enabled {
background: rgba(36, 64, 74, 0.12);
color: #24404a;
}
#${PANEL_ID} .alh-sync-state.is-disabled {
background: rgba(157, 67, 56, 0.12);
color: #9d4338;
}
#${PANEL_ID} .alh-sync-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px 10px;
}
#${PANEL_ID} .alh-sync-grid label {
display: flex;
flex-direction: column;
gap: 4px;
}
#${PANEL_ID} .alh-sync-grid label span {
font-size: 12px;
color: #37474f;
}
#${PANEL_ID} .alh-sync-grid .alh-sync-switch {
min-width: 0;
}
#${PANEL_ID} .alh-sync-grid .alh-sync-url {
grid-column: 1 / -1;
}
#${PANEL_ID} .alh-sync-grid input,
#${PANEL_ID} .alh-sync-grid select {
width: 100%;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid #bfd0ca;
background: #fff;
color: #1f2a30;
min-height: 34px;
}
#${PANEL_ID} .alh-sync-preview {
display: grid;
grid-template-columns: 90px minmax(0, 1fr);
gap: 8px;
align-items: center;
padding: 8px 10px;
border-radius: 10px;
background: #eef3ef;
color: #233239;
font-size: 12px;
}
#${PANEL_ID} .alh-sync-preview code {
display: block;
padding: 7px 9px;
border-radius: 8px;
background: rgba(36, 64, 74, 0.08);
color: #24404a;
word-break: break-all;
}
#${PANEL_ID} .alh-sync-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
#${PANEL_ID} .alh-sync-actions button {
width: 100%;
padding: 8px 10px;
border-radius: 10px;
font-size: 13px;
}
#${PANEL_ID} .alh-sync-status {
display: grid;
grid-template-columns: 56px 1fr auto;
gap: 8px;
align-items: center;
padding: 8px 10px;
border-radius: 10px;
background: rgba(36, 64, 74, 0.08);
color: #24333b;
font-size: 12px;
}
#${PANEL_ID} .alh-sync-status.is-error {
background: rgba(157, 67, 56, 0.12);
color: #7e3027;
}
#${PANEL_ID} .alh-sync-status.is-success {
background: rgba(34, 107, 62, 0.14);
color: #215236;
}
#${PANEL_ID} .alh-sync-status em {
font-style: normal;
opacity: 0.8;
}
#${PANEL_ID} .alh-body {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 360px;
flex: 1 0 360px;
}
#${PANEL_ID} .alh-list {
padding: 16px;
border-right: 1px solid rgba(31, 42, 48, 0.12);
overflow: auto;
background: #fbf8f2;
min-height: 360px;
}
#${PANEL_ID} .alh-editor {
padding: 20px;
overflow: auto;
min-height: 360px;
}
#${PANEL_ID} .alh-list-item,
#${PANEL_ID} button,
#${PROMPT_ID} button {
cursor: pointer;
border: 0;
border-radius: 12px;
background: #24404a;
color: #fff;
padding: 10px 14px;
}
#${PANEL_ID} .alh-list-item {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
margin-bottom: 10px;
background: #eef3ef;
color: #213039;
border: 1px solid rgba(73, 108, 98, 0.14);
box-shadow: 0 4px 12px rgba(31, 42, 48, 0.04);
}
#${PANEL_ID} .alh-list-item-head {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
#${PANEL_ID} .alh-list-checkbox {
width: 16px;
height: 16px;
accent-color: #537c72;
cursor: pointer;
flex: 0 0 auto;
}
#${PANEL_ID} .alh-list-item.is-active {
background: linear-gradient(135deg, #dfeee8, #edf5f0);
color: #1f2f2d;
border-color: rgba(83, 124, 114, 0.38);
box-shadow: 0 10px 20px rgba(83, 124, 114, 0.12);
}
#${PANEL_ID} .alh-list-item.is-current {
border-left: 4px solid #d58a2f;
padding-left: 10px;
}
#${PANEL_ID} .alh-list-item span {
margin-top: 4px;
font-size: 12px;
opacity: 0.82;
}
#${PANEL_ID} .alh-list-item em {
margin-top: 8px;
font-style: normal;
font-size: 12px;
font-weight: 700;
color: #b06d12;
}
#${PANEL_ID} .alh-list-item.is-active em {
color: #9b6114;
}
#${PANEL_ID} .alh-form {
display: grid;
grid-template-columns: repeat(3, minmax(210px, 250px));
gap: 14px 18px;
justify-content: start;
max-width: 860px;
}
#${PANEL_ID} .alh-form label {
display: flex;
flex-direction: column;
gap: 6px;
color: #213039;
}
#${PANEL_ID} .alh-field-inline {
display: grid;
grid-template-columns: minmax(0, 1fr) 74px;
gap: 8px;
align-items: center;
}
#${PANEL_ID} .alh-field-inline button {
white-space: nowrap;
padding: 10px 8px;
font-size: 12px;
}
#${PANEL_ID} .alh-form .is-hidden {
display: none;
}
#${PANEL_ID} .alh-form .is-disabled {
opacity: 0.45;
}
#${PANEL_ID} .alh-form input,
#${PANEL_ID} .alh-form select {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #bfd0ca;
background: #fff;
color: #1f2a30;
}
#${PANEL_ID} .alh-field-inline input {
min-width: 0;
}
#${PANEL_ID} .alh-actions {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
}
#${PANEL_ID} .alh-delete {
background: #9d4338;
}
#${PANEL_ID} .alh-delete-one {
background: #9d4338;
}
#${PANEL_ID} .alh-empty {
color: #5f6e73;
padding: 16px;
}
#${PROMPT_ID} {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 2147483647;
}
#${PROMPT_ID} .alh-prompt {
position: fixed;
width: min(360px, calc(100vw - 24px));
padding: 16px;
border-radius: 16px;
background: linear-gradient(135deg, #24404a, #355e69);
color: #fff;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.28);
}
#${PROMPT_ID} .alh-prompt-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
#${PROMPT_ID} .alh-prompt-close {
min-width: 36px;
padding: 8px 10px;
}
#${PROMPT_ID} .alh-prompt p {
margin: 8px 0 0;
color: rgba(255, 255, 255, 0.88);
line-height: 1.5;
}
#${PROMPT_ID} .alh-prompt-actions {
display: flex;
gap: 10px;
margin-top: 14px;
flex-wrap: wrap;
}
#${PROMPT_ID} button {
background: #f2d394;
color: #24333b;
font-weight: 600;
}
#${LOG_ID} {
position: fixed;
inset: 0;
z-index: 2147483644;
}
#${LOG_ID} .alh-log-panel {
position: fixed;
width: min(520px, calc(100vw - 24px));
max-height: min(420px, calc(100vh - 40px));
overflow: hidden;
border-radius: 16px;
background: #f7f2e8;
color: #1f2a30;
box-shadow: 0 14px 44px rgba(0, 0, 0, 0.24);
border: 1px solid rgba(36, 64, 74, 0.18);
}
#${LOG_ID} .alh-log-panel.is-collapsed .alh-log-body {
display: none;
}
#${LOG_ID} .alh-log-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
background: linear-gradient(135deg, #d7e4dc, #efe3cf);
}
#${LOG_ID} .alh-log-actions {
display: flex;
gap: 8px;
}
#${LOG_ID} .alh-log-actions button {
cursor: pointer;
border: 0;
border-radius: 10px;
background: #24404a;
color: #fff;
padding: 8px 10px;
}
#${LOG_ID} .alh-log-body {
max-height: 330px;
overflow: auto;
padding: 12px;
}
#${LOG_ID} .alh-log-item {
display: grid;
grid-template-columns: 120px 56px 1fr;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid rgba(31, 42, 48, 0.08);
font-size: 12px;
}
#${LOG_ID} .alh-log-item strong {
color: #24404a;
}
#${LOG_ID} .alh-log-item.is-error strong {
color: #9d4338;
}
#${LOG_ID} .alh-log-item.is-warn strong {
color: #b06d12;
}
#${LOG_ID} .alh-log-item em {
font-style: normal;
color: #33434b;
}
#${LOG_ID} .alh-log-empty {
color: #607076;
padding: 8px 0;
}
.alh-picker-highlight {
position: absolute;
z-index: 2147483647;
border: 2px solid #e08a1e;
background: rgba(224, 138, 30, 0.15);
pointer-events: none;
border-radius: 6px;
}
@media (max-width: 760px) {
#${PANEL_ID} .alh-panel {
width: calc(100vw - 12px);
height: calc(100vh - 12px);
margin: 6px;
border-radius: 14px;
}
#${PANEL_ID} .alh-body {
grid-template-columns: 1fr;
min-height: 420px;
}
#${PANEL_ID} .alh-tabs {
flex-wrap: wrap;
}
#${PANEL_ID} .alh-sync-grid {
grid-template-columns: 1fr;
}
#${PANEL_ID} .alh-sync-preview,
#${PANEL_ID} .alh-sync-actions,
#${PANEL_ID} .alh-sync-status {
grid-template-columns: 1fr;
}
#${PANEL_ID} .alh-sync-actions button {
width: 100%;
}
#${PANEL_ID} .alh-sync-title,
#${PANEL_ID} .alh-sync-status {
display: block;
}
#${PANEL_ID} .alh-form {
grid-template-columns: 1fr;
}
#${PANEL_ID} .alh-list,
#${PANEL_ID} .alh-editor {
min-height: 0;
}
#${LOG_ID} .alh-log-item {
grid-template-columns: 1fr;
}
}
`;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = css;
document.head.appendChild(style);
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function escapeHtmlAttr(value) {
return escapeHtml(value).replace(/`/g, '`');
}
function debounce(fn, delay) {
let timer = null;
return function (...args) {
window.clearTimeout(timer);
timer = window.setTimeout(() => fn.apply(this, args), delay);
};
}
})();