// ==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 `
WebDAV 同步 ${settings.enabled ? '已启用' : '未启用'}

同一个 WebDAV JSON 可跨浏览器共用,根地址请填写 WebDAV 根路径。

实际同步文件 ${escapeHtml(target.fileUrl || '未设置')}
状态 ${escapeHtml(statusMessage)} ${escapeHtml(statusTime ? `时间:${statusTime}` : '')}
`; } 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 = `
${escapeHtml(title)}

${escapeHtml(text)}

`; 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); }; } })();