- 删除未使用的 getActiveEnv 解构、sender 参数改为 _sender - types.ts 重命名为 types.d.ts,消除无用的 dist/types.js 产物 - 测试文件引用路径从根目录改为 dist/
1401 lines
57 KiB
JavaScript
1401 lines
57 KiB
JavaScript
import { t, applyI18n } from "./i18n.js";
|
||
import { encryptConfigs, decryptConfigs } from "./crypto-file.js";
|
||
import { estimateStrength, formatTime } from "./strength.js";
|
||
import { sendMessage } from "./messaging.js";
|
||
import { openConfigUrl, fetchTitleByDomain, closeAllMenus } from "./ui-utils.js";
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
const $ = (id) => document.getElementById(id);
|
||
applyI18n();
|
||
const versionLabel = $("versionLabel");
|
||
if (versionLabel)
|
||
versionLabel.textContent = "v" + chrome.runtime.getManifest().version;
|
||
const domainInput = $("domain");
|
||
const aliasInput = $("alias");
|
||
const usernameInput = $("username");
|
||
const passwordInput = $("password");
|
||
const autoSubmitSelect = $("autoSubmit");
|
||
const enabledCheckbox = $("enabled");
|
||
const delayMsInput = $("delayMs");
|
||
const usernameSelectorInput = $("usernameSelector");
|
||
const passwordSelectorInput = $("passwordSelector");
|
||
const submitSelectorInput = $("submitSelector");
|
||
const addBtn = $("addBtn");
|
||
const configList = $("configList");
|
||
const addSection = $("addSection");
|
||
const listSection = $("listSection");
|
||
const fillCurrentBtn = $("fillCurrent");
|
||
const fillDomainBtn = $("fillDomain");
|
||
const exportBtn = $("exportBtn");
|
||
const importBtn = $("importBtn");
|
||
const importFile = $("importFile");
|
||
const importBookmarkBtn = $("importBookmarkBtn");
|
||
const bookmarkModal = $("bookmarkModal");
|
||
const bookmarkList = $("bookmarkList");
|
||
const bookmarkSelectAll = $("bookmarkSelectAll");
|
||
const bookmarkSearch = $("bookmarkSearch");
|
||
const bookmarkCancel = $("bookmarkCancel");
|
||
const bookmarkConfirm = $("bookmarkConfirm");
|
||
const toast = $("toast");
|
||
// 编辑配置弹窗
|
||
const editConfigModal = $("editConfigModal");
|
||
const editConfigModalTitle = $("editConfigModalTitle");
|
||
const editDomainInput = $("editDomain");
|
||
const editUsernameInput = $("editUsername");
|
||
const editPasswordInput = $("editPassword");
|
||
const editAliasInput = $("editAlias");
|
||
const editAutoSubmitSelect = $("editAutoSubmit");
|
||
const editEnabledCheckbox = $("editEnabled");
|
||
const editDelayMsInput = $("editDelayMs");
|
||
const editUsernameSelectorInput = $("editUsernameSelector");
|
||
const editPasswordSelectorInput = $("editPasswordSelector");
|
||
const editSubmitSelectorInput = $("editSubmitSelector");
|
||
const editConfigCancel = $("editConfigCancel");
|
||
const editConfigSave = $("editConfigSave");
|
||
const editFillDomainBtn = $("editFillDomain");
|
||
let editModalEditingId = null;
|
||
const addEntryBtn = $("addEntryBtn");
|
||
const searchBtn = $("searchBtn");
|
||
const searchBox = $("searchBox");
|
||
const searchInput = $("searchInput");
|
||
const searchClear = $("searchClear");
|
||
const searchToggleBtn = $("searchToggleBtn");
|
||
let searchKeyword = "";
|
||
// 锁定界面
|
||
const lockScreen = $("lockScreen");
|
||
const mainContainer = $("mainContainer");
|
||
const lockTitle = $("lockTitle");
|
||
const lockHint = $("lockHint");
|
||
const lockPassword = $("lockPassword");
|
||
const lockPasswordConfirm = $("lockPasswordConfirm");
|
||
const lockConfirmGroup = $("lockConfirmGroup");
|
||
const lockSubmitBtn = $("lockSubmitBtn");
|
||
const lockWarn = $("lockWarn");
|
||
const lockBtn = $("lockBtn");
|
||
// 环境栏
|
||
const envSelect = $("envSelect");
|
||
const envManageBtn = $("envManageBtn");
|
||
const envManageModal = $("envManageModal");
|
||
const envManageList = $("envManageList");
|
||
const envManageWarn = $("envManageWarn");
|
||
const envManageCloseX = $("envManageCloseX");
|
||
const envManageAdd = $("envManageAdd");
|
||
const envModal = $("envModal");
|
||
const envModalTitle = $("envModalTitle");
|
||
const envNameInput = $("envNameInput");
|
||
const presetEnvTags = $("presetEnvTags");
|
||
const envModalWarn = $("envModalWarn");
|
||
const envModalCancel = $("envModalCancel");
|
||
const envModalCloseX = $("envModalCloseX");
|
||
const envModalConfirm = $("envModalConfirm");
|
||
const pwdModal = $("pwdModal");
|
||
const modalTitle = $("modalTitle");
|
||
const encryptToggle = $("encryptToggle");
|
||
const encryptToggleGroup = $("encryptToggleGroup");
|
||
const pwdFields = $("pwdFields");
|
||
const confirmGroup = $("confirmGroup");
|
||
const modalPwd = $("modalPwd");
|
||
const modalPwdConfirm = $("modalPwdConfirm");
|
||
const modalWarn = $("modalWarn");
|
||
const modalCancel = $("modalCancel");
|
||
const modalConfirm = $("modalConfirm");
|
||
const strengthBlock = $("strengthBlock");
|
||
const strengthBar = $("strengthBar");
|
||
const strengthLabel = $("strengthLabel");
|
||
const crackTime = $("crackTime");
|
||
let modalResolve = null;
|
||
// 密码强度估算:按 GPU 集群约 10^7 次/秒(PBKDF2-SHA256)计
|
||
function updateStrength(pwd) {
|
||
const { entropy, seconds, level, label } = estimateStrength(pwd);
|
||
// 5 档进度条宽度,对数缩放避免弱口令条太短看不见
|
||
const widthMap = {
|
||
"empty": 0,
|
||
"very-weak": 15,
|
||
"weak": 30,
|
||
"medium": 55,
|
||
"strong": 80,
|
||
"very-strong": 100,
|
||
};
|
||
strengthBar.style.width = widthMap[level] + "%";
|
||
strengthBar.className = "strength-bar-fg " + level;
|
||
strengthLabel.textContent = pwd ? t("strengthLabel", label, entropy.toFixed(1)) : t("strengthEmpty");
|
||
crackTime.textContent = pwd ? t("crackTimeLabel", formatTime(seconds)) : "";
|
||
}
|
||
modalPwd.addEventListener("input", () => {
|
||
if (strengthBlock.style.display !== "none")
|
||
updateStrength(modalPwd.value);
|
||
});
|
||
// 文件密码 modal:export 显示加密勾选 + 两次输入,import 只输入一次
|
||
function openPwdModal(mode) {
|
||
return new Promise((resolve) => {
|
||
modalResolve = resolve;
|
||
if (mode === "export") {
|
||
modalTitle.textContent = t("pwdModalExportTitle");
|
||
encryptToggleGroup.style.display = "";
|
||
confirmGroup.style.display = "";
|
||
strengthBlock.style.display = "";
|
||
encryptToggle.checked = true;
|
||
pwdFields.classList.remove("hidden");
|
||
modalPwd.value = "";
|
||
modalPwdConfirm.value = "";
|
||
modalWarn.textContent = t("pwdModalExportWarn");
|
||
}
|
||
else {
|
||
modalTitle.textContent = t("pwdModalImportTitle");
|
||
encryptToggleGroup.style.display = "none";
|
||
confirmGroup.style.display = "none";
|
||
strengthBlock.style.display = "none";
|
||
pwdFields.classList.remove("hidden");
|
||
modalPwd.value = "";
|
||
modalPwdConfirm.value = "";
|
||
modalWarn.textContent = "";
|
||
}
|
||
pwdModal.classList.add("show");
|
||
setTimeout(() => modalPwd.focus(), 0);
|
||
});
|
||
}
|
||
function closePwdModal() {
|
||
pwdModal.classList.remove("show");
|
||
modalPwd.value = "";
|
||
modalPwdConfirm.value = "";
|
||
modalResolve = null;
|
||
updateStrength("");
|
||
}
|
||
encryptToggle.addEventListener("change", () => {
|
||
if (encryptToggle.checked) {
|
||
pwdFields.classList.remove("hidden");
|
||
updateStrength(modalPwd.value);
|
||
}
|
||
else {
|
||
pwdFields.classList.add("hidden");
|
||
updateStrength("");
|
||
}
|
||
});
|
||
modalCancel.addEventListener("click", () => {
|
||
if (modalResolve)
|
||
modalResolve(null);
|
||
closePwdModal();
|
||
});
|
||
modalConfirm.addEventListener("click", () => {
|
||
if (!modalResolve)
|
||
return;
|
||
const isExport = modalTitle.textContent === t("pwdModalExportTitle");
|
||
if (isExport) {
|
||
if (encryptToggle.checked) {
|
||
const p1 = modalPwd.value;
|
||
const p2 = modalPwdConfirm.value;
|
||
if (!p1) {
|
||
modalWarn.textContent = t("warnFilePwdEmpty");
|
||
return;
|
||
}
|
||
if (p1 !== p2) {
|
||
modalWarn.textContent = t("warnFilePwdMismatch");
|
||
return;
|
||
}
|
||
modalResolve({ encrypt: true, password: p1 });
|
||
}
|
||
else {
|
||
modalResolve({ encrypt: false, password: null });
|
||
}
|
||
}
|
||
else {
|
||
const p = modalPwd.value;
|
||
if (!p) {
|
||
modalWarn.textContent = t("warnFilePwdEmpty");
|
||
return;
|
||
}
|
||
modalResolve({ encrypt: true, password: p });
|
||
}
|
||
closePwdModal();
|
||
});
|
||
modalPwd.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
if (confirmGroup.style.display === "none") {
|
||
modalConfirm.click();
|
||
}
|
||
else if (modalPwdConfirm.value === "") {
|
||
modalPwdConfirm.focus();
|
||
}
|
||
else {
|
||
modalConfirm.click();
|
||
}
|
||
}
|
||
});
|
||
modalPwdConfirm.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
modalConfirm.click();
|
||
}
|
||
});
|
||
function showToast(msg) {
|
||
toast.textContent = msg;
|
||
toast.classList.add("show");
|
||
setTimeout(() => toast.classList.remove("show"), 2000);
|
||
}
|
||
document.querySelectorAll(".toggle-pwd").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const target = $(btn.dataset.target || "");
|
||
if (target.type === "password") {
|
||
target.type = "text";
|
||
btn.classList.remove("eye-open");
|
||
btn.classList.add("eye-closed");
|
||
btn.textContent = "🙈";
|
||
}
|
||
else {
|
||
target.type = "password";
|
||
btn.classList.remove("eye-closed");
|
||
btn.classList.add("eye-open");
|
||
btn.textContent = "👁";
|
||
}
|
||
});
|
||
});
|
||
// 表单内容实时存入草稿,弹窗关闭后不丢失
|
||
const draftFields = [
|
||
domainInput, aliasInput, usernameInput, passwordInput, autoSubmitSelect,
|
||
usernameSelectorInput, passwordSelectorInput, submitSelectorInput,
|
||
enabledCheckbox, delayMsInput,
|
||
];
|
||
draftFields.forEach((el) => {
|
||
el.addEventListener("input", saveDraft);
|
||
el.addEventListener("change", saveDraft);
|
||
});
|
||
// 环境数据层(EnvStore)
|
||
const { loadEnvironments, saveEnvironments, setActiveEnv, createEnvironment, renameEnvironment, deleteEnvironment, loadConfigs, saveConfigs, loadAllConfigs, genId, genCfgId, } = EnvStore;
|
||
// isDomainMatch 由 utils.js 提供(全局函数)
|
||
const DRAFT_KEY = "formDraft";
|
||
function saveDraft() {
|
||
const draft = {
|
||
domain: domainInput.value,
|
||
alias: aliasInput.value,
|
||
username: usernameInput.value,
|
||
password: passwordInput.value,
|
||
autoSubmit: autoSubmitSelect.value,
|
||
usernameSelector: usernameSelectorInput.value,
|
||
passwordSelector: passwordSelectorInput.value,
|
||
submitSelector: submitSelectorInput.value,
|
||
enabled: enabledCheckbox.checked,
|
||
delayMs: delayMsInput.value,
|
||
};
|
||
chrome.storage.local.set({ [DRAFT_KEY]: draft });
|
||
}
|
||
async function loadDraft() {
|
||
const data = await chrome.storage.local.get(DRAFT_KEY);
|
||
const draft = data[DRAFT_KEY];
|
||
if (!draft)
|
||
return;
|
||
domainInput.value = draft.domain || "";
|
||
aliasInput.value = draft.alias || "";
|
||
usernameInput.value = draft.username || "";
|
||
passwordInput.value = draft.password || "";
|
||
autoSubmitSelect.value = draft.autoSubmit || "true";
|
||
usernameSelectorInput.value = draft.usernameSelector || "";
|
||
passwordSelectorInput.value = draft.passwordSelector || "";
|
||
submitSelectorInput.value = draft.submitSelector || "";
|
||
enabledCheckbox.checked = draft.enabled !== false;
|
||
delayMsInput.value = draft.delayMs || "";
|
||
addBtn.textContent = t("addConfigBtn");
|
||
}
|
||
function clearDraft() {
|
||
chrome.storage.local.remove(DRAFT_KEY);
|
||
}
|
||
// 环境栏
|
||
let envModalResolve = null;
|
||
// mode: "create" | "rename"
|
||
function openEnvModal(mode, currentName = "") {
|
||
return new Promise((resolve) => {
|
||
envModalResolve = resolve;
|
||
if (mode === "create") {
|
||
envModalTitle.textContent = t("envCreateTitle");
|
||
envNameInput.value = "";
|
||
presetEnvTags.style.display = "flex";
|
||
}
|
||
else {
|
||
envModalTitle.textContent = t("envRenameTitle");
|
||
envNameInput.value = currentName;
|
||
presetEnvTags.style.display = "flex";
|
||
}
|
||
envModalWarn.style.display = "none";
|
||
envModalWarn.textContent = "";
|
||
envModal.classList.add("show");
|
||
setTimeout(() => {
|
||
envNameInput.focus();
|
||
envNameInput.select();
|
||
}, 0);
|
||
});
|
||
}
|
||
function closeEnvModal() {
|
||
envModal.classList.remove("show");
|
||
envNameInput.value = "";
|
||
envModalWarn.style.display = "none";
|
||
envModalResolve = null;
|
||
}
|
||
presetEnvTags.querySelectorAll(".preset-tag").forEach((tag) => {
|
||
tag.addEventListener("click", () => {
|
||
envNameInput.value = tag.dataset.name || "";
|
||
envNameInput.focus();
|
||
});
|
||
});
|
||
function closeEnvModalWithResolve() {
|
||
if (envModalResolve)
|
||
envModalResolve(null);
|
||
closeEnvModal();
|
||
}
|
||
envModalCancel.addEventListener("click", closeEnvModalWithResolve);
|
||
envModalCloseX.addEventListener("click", closeEnvModalWithResolve);
|
||
envModalConfirm.addEventListener("click", async () => {
|
||
if (!envModalResolve)
|
||
return;
|
||
const name = envNameInput.value.trim();
|
||
if (!name) {
|
||
envModalWarn.textContent = t("envWarnEmpty");
|
||
envModalWarn.style.display = "block";
|
||
return;
|
||
}
|
||
if (name.length > 20) {
|
||
envModalWarn.textContent = t("envWarnTooLong");
|
||
envModalWarn.style.display = "block";
|
||
return;
|
||
}
|
||
envModalResolve(name);
|
||
closeEnvModal();
|
||
});
|
||
envNameInput.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
envModalConfirm.click();
|
||
}
|
||
});
|
||
async function renderEnvSelect() {
|
||
const { environments, activeEnvId } = await loadEnvironments();
|
||
envSelect.innerHTML = "";
|
||
environments.forEach((env) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = env.id;
|
||
opt.textContent = `${env.name} (${env.configs?.length || 0})`;
|
||
if (env.id === activeEnvId)
|
||
opt.selected = true;
|
||
envSelect.appendChild(opt);
|
||
});
|
||
}
|
||
envSelect.addEventListener("change", async () => {
|
||
await setActiveEnv(envSelect.value);
|
||
resetForm();
|
||
await renderConfigList();
|
||
showToast(t("toastSwitchEnv"));
|
||
});
|
||
// 环境管理对话框
|
||
function openEnvManageModal() {
|
||
envManageWarn.textContent = "";
|
||
envManageWarn.style.display = "none";
|
||
renderEnvManageList();
|
||
envManageModal.classList.add("show");
|
||
}
|
||
function closeEnvManageModal() {
|
||
envManageModal.classList.remove("show");
|
||
}
|
||
async function renderEnvManageList() {
|
||
const { environments, activeEnvId } = await loadEnvironments();
|
||
envManageList.innerHTML = "";
|
||
if (environments.length === 0) {
|
||
envManageList.innerHTML = `<div class="env-manage-empty">${t("envManageEmpty")}</div>`;
|
||
return;
|
||
}
|
||
environments.forEach((env) => {
|
||
const row = document.createElement("div");
|
||
row.className = "env-manage-row" + (env.id === activeEnvId ? " active" : "");
|
||
const nameSpan = document.createElement("span");
|
||
nameSpan.className = "env-manage-name";
|
||
const cnt = env.configs?.length || 0;
|
||
const activeMark = env.id === activeEnvId ? `<span class="env-manage-active-mark">${t("envCurrentMark")}</span>` : "";
|
||
nameSpan.innerHTML = `${escapeHtml(env.name)} <span class="env-manage-count">(${cnt})</span> ${activeMark}`;
|
||
const actions = document.createElement("div");
|
||
actions.className = "env-manage-actions";
|
||
const editBtn = document.createElement("button");
|
||
editBtn.className = "btn-icon";
|
||
editBtn.title = t("renameBtn");
|
||
editBtn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>';
|
||
editBtn.addEventListener("click", async () => {
|
||
const newName = await openEnvModal("rename", env.name);
|
||
if (!newName)
|
||
return;
|
||
try {
|
||
await renameEnvironment(env.id, newName);
|
||
await renderEnvManageList();
|
||
await renderEnvSelect();
|
||
showToast(t("toastRenamed"));
|
||
}
|
||
catch (err) {
|
||
showToast(err.message || t("toastRenameFail"));
|
||
}
|
||
});
|
||
const delBtn = document.createElement("button");
|
||
delBtn.className = "btn-icon";
|
||
delBtn.title = t("deleteBtn");
|
||
delBtn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
|
||
delBtn.addEventListener("click", async () => {
|
||
if (environments.length <= 1) {
|
||
showToast(t("toastKeepOneEnv"));
|
||
return;
|
||
}
|
||
const cnt2 = env.configs?.length || 0;
|
||
const msg = cnt2 > 0
|
||
? t("confirmDeleteEnvWithCfgs", env.name, String(cnt2))
|
||
: t("confirmDeleteEnv", env.name);
|
||
if (!confirm(msg))
|
||
return;
|
||
try {
|
||
await deleteEnvironment(env.id);
|
||
await renderEnvManageList();
|
||
await renderEnvSelect();
|
||
resetForm();
|
||
await renderConfigList();
|
||
showToast(t("toastEnvDeleted"));
|
||
}
|
||
catch (err) {
|
||
showToast(err.message || t("toastDeleteFail"));
|
||
}
|
||
});
|
||
actions.appendChild(editBtn);
|
||
actions.appendChild(delBtn);
|
||
row.appendChild(nameSpan);
|
||
row.appendChild(actions);
|
||
envManageList.appendChild(row);
|
||
});
|
||
}
|
||
envManageBtn.addEventListener("click", openEnvManageModal);
|
||
envManageCloseX.addEventListener("click", closeEnvManageModal);
|
||
envManageAdd.addEventListener("click", async () => {
|
||
const name = await openEnvModal("create");
|
||
if (!name)
|
||
return;
|
||
try {
|
||
await createEnvironment(name);
|
||
await renderEnvManageList();
|
||
await renderEnvSelect();
|
||
resetForm();
|
||
await renderConfigList();
|
||
showToast(t("toastEnvCreated", name));
|
||
}
|
||
catch (err) {
|
||
showToast(err.message || t("toastCreateFail"));
|
||
}
|
||
});
|
||
document.addEventListener("click", (e) => {
|
||
if (!e.target.closest(".config-actions"))
|
||
closeAllMenus();
|
||
});
|
||
async function renderConfigList() {
|
||
const allConfigs = await loadConfigs();
|
||
configList.innerHTML = "";
|
||
// 无配置时禁用导出,此时表单在上、空状态的列表在下;有配置时反过来
|
||
const isEmpty = allConfigs.length === 0;
|
||
addSection.classList.toggle("collapsed", !isEmpty);
|
||
exportBtn.disabled = isEmpty;
|
||
exportBtn.classList.toggle("btn-disabled", isEmpty);
|
||
const parent = addSection.parentElement;
|
||
const addIdx = Array.prototype.indexOf.call(parent.children, addSection);
|
||
const listIdx = Array.prototype.indexOf.call(parent.children, listSection);
|
||
if (isEmpty && addIdx > listIdx) {
|
||
parent.insertBefore(addSection, listSection);
|
||
}
|
||
else if (!isEmpty && listIdx > addIdx) {
|
||
parent.insertBefore(listSection, addSection);
|
||
}
|
||
if (isEmpty) {
|
||
configList.innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="empty-icon">
|
||
<svg viewBox="0 0 24 24" width="56" height="56" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||
<rect x="3" y="5" width="18" height="14" rx="2"/>
|
||
<path d="M3 10h18"/>
|
||
<circle cx="7" cy="14.5" r="0.6" fill="currentColor"/>
|
||
<path d="M11 14.5h6"/>
|
||
</svg>
|
||
</div>
|
||
<div class="empty-text">${t("emptyStateTitle")}</div>
|
||
<div class="empty-sub">${t("emptyStateSub")}</div>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
// 搜索过滤:支持域名、用户名、别名模糊检索
|
||
const kw = searchKeyword.trim().toLowerCase();
|
||
const configs = kw
|
||
? allConfigs.filter((c) => (c.domain || "").toLowerCase().includes(kw) ||
|
||
(c.username || "").toLowerCase().includes(kw) ||
|
||
(c.alias || "").toLowerCase().includes(kw))
|
||
: allConfigs;
|
||
if (configs.length === 0) {
|
||
configList.innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="empty-text">${t("noSearchResult")}</div>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
let draggedConfigId = null;
|
||
configs.forEach((cfg) => {
|
||
const item = document.createElement("div");
|
||
item.className = "config-item" + (cfg.enabled === false ? " config-disabled" : "");
|
||
if (!kw) {
|
||
item.draggable = true;
|
||
item.addEventListener("dragstart", (e) => {
|
||
draggedConfigId = cfg.id;
|
||
item.classList.add("dragging");
|
||
if (e.dataTransfer)
|
||
e.dataTransfer.effectAllowed = "move";
|
||
});
|
||
item.addEventListener("dragend", () => {
|
||
draggedConfigId = null;
|
||
item.classList.remove("dragging");
|
||
configList.querySelectorAll(".config-item").forEach((el) => el.classList.remove("drag-over"));
|
||
});
|
||
item.addEventListener("dragover", (e) => {
|
||
e.preventDefault();
|
||
if (e.dataTransfer)
|
||
e.dataTransfer.dropEffect = "move";
|
||
if (cfg.id !== draggedConfigId) {
|
||
item.classList.add("drag-over");
|
||
}
|
||
});
|
||
item.addEventListener("dragleave", () => {
|
||
item.classList.remove("drag-over");
|
||
});
|
||
item.addEventListener("drop", async (e) => {
|
||
e.preventDefault();
|
||
item.classList.remove("drag-over");
|
||
if (!draggedConfigId || draggedConfigId === cfg.id)
|
||
return;
|
||
const fromIdx = allConfigs.findIndex((c) => c.id === draggedConfigId);
|
||
const toIdx = allConfigs.findIndex((c) => c.id === cfg.id);
|
||
if (fromIdx === -1 || toIdx === -1)
|
||
return;
|
||
const [moved] = allConfigs.splice(fromIdx, 1);
|
||
allConfigs.splice(toIdx, 0, moved);
|
||
await saveConfigs(allConfigs);
|
||
renderConfigList();
|
||
});
|
||
}
|
||
const info = document.createElement("div");
|
||
info.className = "config-info";
|
||
const displayName = escapeHtml(cfg.alias || cfg.domain);
|
||
const enabledTag = cfg.enabled === false
|
||
? `<span class="tag-disabled">${t("tagDisabled")}</span>`
|
||
: "";
|
||
info.innerHTML = `
|
||
<div class="config-domain">${displayName} ${enabledTag}</div>
|
||
`;
|
||
const actions = document.createElement("div");
|
||
actions.className = "config-actions";
|
||
const isDisabled = cfg.enabled === false;
|
||
// 编辑(图标按钮)
|
||
const editBtn = document.createElement("button");
|
||
editBtn.className = "btn-icon";
|
||
editBtn.title = t("editBtn");
|
||
editBtn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>';
|
||
editBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
editModalEditingId = cfg.id;
|
||
editConfigModalTitle.textContent = t("editConfigTitle");
|
||
editConfigSave.textContent = t("updateConfigBtn");
|
||
editDomainInput.value = cfg.domain;
|
||
editAliasInput.value = cfg.alias || "";
|
||
editUsernameInput.value = cfg.username;
|
||
editPasswordInput.value = cfg.password;
|
||
editAutoSubmitSelect.value = String(cfg.autoSubmit);
|
||
editUsernameSelectorInput.value = cfg.usernameSelector || "";
|
||
editPasswordSelectorInput.value = cfg.passwordSelector || "";
|
||
editSubmitSelectorInput.value = cfg.submitSelector || "";
|
||
editEnabledCheckbox.checked = cfg.enabled !== false;
|
||
editDelayMsInput.value = cfg.delayMs || "";
|
||
editConfigModal.classList.add("show");
|
||
editDomainInput.focus();
|
||
});
|
||
// 三个小点下拉菜单(收起禁用/删除)
|
||
const moreBtn = document.createElement("button");
|
||
moreBtn.className = "btn-icon";
|
||
moreBtn.title = t("moreActionsTitle");
|
||
moreBtn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><circle cx="12" cy="5" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="19" r="1.6"/></svg>';
|
||
moreBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
const open = menu.classList.contains("open");
|
||
closeAllMenus();
|
||
if (!open)
|
||
menu.classList.add("open");
|
||
});
|
||
const menu = document.createElement("div");
|
||
menu.className = "config-menu";
|
||
const toggleItem = document.createElement("button");
|
||
toggleItem.className = "config-menu-item";
|
||
toggleItem.textContent = isDisabled ? t("toggleEnable") : t("toggleDisable");
|
||
toggleItem.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
closeAllMenus();
|
||
const updated = configs.map((c) => c.id === cfg.id ? { ...c, enabled: c.enabled === false } : c);
|
||
await saveConfigs(updated);
|
||
renderConfigList();
|
||
renderEnvSelect();
|
||
if (envManageModal.classList.contains("show"))
|
||
renderEnvManageList();
|
||
showToast(isDisabled ? t("toastEnabled") : t("toastDisabled"));
|
||
});
|
||
const delItem = document.createElement("button");
|
||
delItem.className = "config-menu-item danger";
|
||
delItem.textContent = t("deleteBtn");
|
||
delItem.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
closeAllMenus();
|
||
const updated = configs.filter((c) => c.id !== cfg.id);
|
||
await saveConfigs(updated);
|
||
showToast(t("toastDeleted"));
|
||
renderConfigList();
|
||
renderEnvSelect();
|
||
if (envManageModal.classList.contains("show"))
|
||
renderEnvManageList();
|
||
});
|
||
menu.appendChild(toggleItem);
|
||
menu.appendChild(delItem);
|
||
actions.appendChild(editBtn);
|
||
actions.appendChild(moreBtn);
|
||
actions.appendChild(menu);
|
||
// 点击整项 → 新窗口打开
|
||
item.addEventListener("click", () => openConfigUrl(cfg.domain));
|
||
item.appendChild(info);
|
||
item.appendChild(actions);
|
||
configList.appendChild(item);
|
||
});
|
||
}
|
||
function resetForm() {
|
||
domainInput.value = "";
|
||
aliasInput.value = "";
|
||
usernameInput.value = "";
|
||
passwordInput.value = "";
|
||
autoSubmitSelect.value = "true";
|
||
usernameSelectorInput.value = "";
|
||
passwordSelectorInput.value = "";
|
||
submitSelectorInput.value = "";
|
||
enabledCheckbox.checked = true;
|
||
delayMsInput.value = "";
|
||
addBtn.textContent = t("addConfigBtn");
|
||
clearDraft();
|
||
}
|
||
addBtn.addEventListener("click", async () => {
|
||
const domain = domainInput.value.trim();
|
||
const username = usernameInput.value.trim();
|
||
const password = passwordInput.value;
|
||
if (!domain || !username || !password) {
|
||
showToast(t("toastRequired"));
|
||
return;
|
||
}
|
||
const config = {
|
||
id: genCfgId(),
|
||
domain,
|
||
alias: aliasInput.value.trim() || await fetchTitleByDomain(domain) || null,
|
||
username,
|
||
password,
|
||
autoSubmit: autoSubmitSelect.value === "true",
|
||
usernameSelector: usernameSelectorInput.value.trim() || null,
|
||
passwordSelector: passwordSelectorInput.value.trim() || null,
|
||
submitSelector: submitSelectorInput.value.trim() || null,
|
||
enabled: enabledCheckbox.checked,
|
||
delayMs: delayMsInput.value ? Number(delayMsInput.value) : null,
|
||
};
|
||
const configs = await loadConfigs();
|
||
configs.push(config);
|
||
showToast(t("toastAdded"));
|
||
await saveConfigs(configs);
|
||
resetForm();
|
||
renderConfigList();
|
||
renderEnvSelect();
|
||
if (envManageModal.classList.contains("show"))
|
||
renderEnvManageList();
|
||
});
|
||
// 编辑配置弹窗:保存
|
||
editConfigSave.addEventListener("click", async () => {
|
||
const domain = editDomainInput.value.trim();
|
||
const username = editUsernameInput.value.trim();
|
||
const password = editPasswordInput.value;
|
||
if (!domain || !username || !password) {
|
||
showToast(t("toastRequired"));
|
||
return;
|
||
}
|
||
const config = {
|
||
id: editModalEditingId || genCfgId(),
|
||
domain,
|
||
alias: editAliasInput.value.trim() || await fetchTitleByDomain(domain) || null,
|
||
username,
|
||
password,
|
||
autoSubmit: editAutoSubmitSelect.value === "true",
|
||
usernameSelector: editUsernameSelectorInput.value.trim() || null,
|
||
passwordSelector: editPasswordSelectorInput.value.trim() || null,
|
||
submitSelector: editSubmitSelectorInput.value.trim() || null,
|
||
enabled: editEnabledCheckbox.checked,
|
||
delayMs: editDelayMsInput.value ? Number(editDelayMsInput.value) : null,
|
||
};
|
||
const configs = await loadConfigs();
|
||
if (editModalEditingId) {
|
||
const idx = configs.findIndex((c) => c.id === editModalEditingId);
|
||
if (idx !== -1) {
|
||
configs[idx] = config;
|
||
}
|
||
showToast(t("toastUpdated"));
|
||
}
|
||
else {
|
||
configs.push(config);
|
||
showToast(t("toastAdded"));
|
||
}
|
||
await saveConfigs(configs);
|
||
editConfigModal.classList.remove("show");
|
||
editModalEditingId = null;
|
||
renderConfigList();
|
||
renderEnvSelect();
|
||
if (envManageModal.classList.contains("show"))
|
||
renderEnvManageList();
|
||
});
|
||
// 添加配置入口按钮:打开新增弹窗
|
||
addEntryBtn.addEventListener("click", () => {
|
||
editModalEditingId = null;
|
||
editConfigModalTitle.textContent = t("addConfigTitle");
|
||
editConfigSave.textContent = t("okBtn");
|
||
editDomainInput.value = "";
|
||
editUsernameInput.value = "";
|
||
editPasswordInput.value = "";
|
||
editAliasInput.value = "";
|
||
editAutoSubmitSelect.value = "true";
|
||
editEnabledCheckbox.checked = true;
|
||
editDelayMsInput.value = "";
|
||
editUsernameSelectorInput.value = "";
|
||
editPasswordSelectorInput.value = "";
|
||
editSubmitSelectorInput.value = "";
|
||
editConfigModal.classList.add("show");
|
||
editDomainInput.focus();
|
||
});
|
||
// 搜索按钮:展开内联搜索框
|
||
searchBtn.addEventListener("click", () => {
|
||
searchBtn.hidden = true;
|
||
searchBox.hidden = false;
|
||
searchInput.focus();
|
||
});
|
||
// 搜索框内搜索图标:收起回图标按钮
|
||
searchToggleBtn.addEventListener("click", () => {
|
||
searchInput.value = "";
|
||
searchKeyword = "";
|
||
searchClear.hidden = true;
|
||
searchBox.hidden = true;
|
||
searchBtn.hidden = false;
|
||
renderConfigList();
|
||
});
|
||
// 搜索输入:实时过滤
|
||
searchInput.addEventListener("input", () => {
|
||
searchKeyword = searchInput.value;
|
||
searchClear.hidden = !searchKeyword;
|
||
renderConfigList();
|
||
});
|
||
// 清除搜索
|
||
searchClear.addEventListener("click", () => {
|
||
searchInput.value = "";
|
||
searchKeyword = "";
|
||
searchClear.hidden = true;
|
||
renderConfigList();
|
||
searchInput.focus();
|
||
});
|
||
// 编辑配置弹窗:取消
|
||
const editConfigCloseX = $("editConfigCloseX");
|
||
function closeEditConfigModal() {
|
||
editConfigModal.classList.remove("show");
|
||
editModalEditingId = null;
|
||
}
|
||
editConfigCancel.addEventListener("click", closeEditConfigModal);
|
||
editConfigCloseX.addEventListener("click", closeEditConfigModal);
|
||
// 点击遮罩关闭编辑弹窗
|
||
editConfigModal.addEventListener("click", (e) => {
|
||
if (e.target === editConfigModal) {
|
||
editConfigModal.classList.remove("show");
|
||
editModalEditingId = null;
|
||
}
|
||
});
|
||
fillCurrentBtn.addEventListener("click", async () => {
|
||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||
if (!tab || tab.id === undefined) {
|
||
showToast(t("toastNoTab"));
|
||
return;
|
||
}
|
||
// 跨所有环境查找匹配当前标签页的配置
|
||
const allConfigs = await loadAllConfigs();
|
||
const matched = allConfigs.filter((c) => isDomainMatch(tab.url, c.domain));
|
||
if (matched.length === 0) {
|
||
showToast(t("toastNoMatch"));
|
||
return;
|
||
}
|
||
if (matched.length === 1) {
|
||
try {
|
||
const resp = await chrome.tabs.sendMessage(tab.id, {
|
||
action: "manualFill",
|
||
config: matched[0],
|
||
});
|
||
if (resp && resp.success) {
|
||
showToast(t("toastFilled"));
|
||
}
|
||
else if (resp && resp.diag) {
|
||
const d = resp.diag;
|
||
showToast(`input=${d.total} pwd=${d.pwd} form=${d.forms} | ${d.sample.join(", ")}`);
|
||
}
|
||
else {
|
||
showToast(t("toastFilledNoForm"));
|
||
}
|
||
}
|
||
catch (err) {
|
||
// content script 未注入:常见于安装扩展前已打开的页面
|
||
if (/Could not establish connection|Receiving end does not exist/i.test(err.message)) {
|
||
showToast(t("toastScriptNotInjected"));
|
||
}
|
||
else {
|
||
showToast(t("toastSendFail", err.message || String(err)));
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
try {
|
||
await chrome.tabs.sendMessage(tab.id, {
|
||
action: "manualFillMulti",
|
||
configs: matched,
|
||
tabUrl: tab.url,
|
||
});
|
||
showToast(t("toastMultiMatch", String(matched.length)));
|
||
}
|
||
catch (err) {
|
||
if (/Could not establish connection|Receiving end does not exist/i.test(err.message)) {
|
||
showToast(t("toastScriptNotInjected"));
|
||
}
|
||
else {
|
||
showToast(t("toastSendFail", err.message || String(err)));
|
||
}
|
||
}
|
||
});
|
||
fillDomainBtn.addEventListener("click", async () => {
|
||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||
if (!tab || !tab.url) {
|
||
showToast(t("toastNoTab"));
|
||
return;
|
||
}
|
||
try {
|
||
const u = new URL(tab.url);
|
||
const host = u.host;
|
||
if (!host) {
|
||
showToast(t("toastNoDomain"));
|
||
return;
|
||
}
|
||
const value = u.pathname && u.pathname !== "/" ? host + u.pathname : host;
|
||
domainInput.value = value;
|
||
if (tab.title)
|
||
aliasInput.value = tab.title;
|
||
saveDraft();
|
||
showToast(t("toastDomainFilled", value));
|
||
}
|
||
catch {
|
||
showToast(t("toastUrlParseFail"));
|
||
}
|
||
});
|
||
editFillDomainBtn.addEventListener("click", async () => {
|
||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||
if (!tab || !tab.url) {
|
||
showToast(t("toastNoTab"));
|
||
return;
|
||
}
|
||
try {
|
||
const hostname = new URL(tab.url).hostname;
|
||
if (!hostname) {
|
||
showToast(t("toastNoDomain"));
|
||
return;
|
||
}
|
||
editDomainInput.value = hostname;
|
||
showToast(t("toastDomainFilled", hostname));
|
||
}
|
||
catch {
|
||
showToast(t("toastUrlParseFail"));
|
||
}
|
||
});
|
||
// 导出整个 environments 结构(含环境名)
|
||
exportBtn.addEventListener("click", async () => {
|
||
const { environments } = await loadEnvironments();
|
||
const totalConfigs = environments.reduce((s, e) => s + (e.configs?.length || 0), 0);
|
||
if (totalConfigs === 0) {
|
||
showToast(t("toastNoExportable"));
|
||
return;
|
||
}
|
||
const choice = await openPwdModal("export");
|
||
if (!choice) {
|
||
showToast(t("toastExportCancelled"));
|
||
return;
|
||
}
|
||
const stamp = new Date().toISOString().replace(/[:T]/g, "").slice(0, 15);
|
||
let payload;
|
||
let filename;
|
||
if (choice.encrypt) {
|
||
const cipher = await encryptConfigs(environments, choice.password);
|
||
payload = {
|
||
app: "AutoLoginManager",
|
||
version: 3,
|
||
exportedAt: new Date().toISOString(),
|
||
encrypted: true,
|
||
cipher,
|
||
};
|
||
filename = `auto-login-configs-${stamp}.enc.json`;
|
||
}
|
||
else {
|
||
payload = {
|
||
app: "AutoLoginManager",
|
||
version: 3,
|
||
exportedAt: new Date().toISOString(),
|
||
encrypted: false,
|
||
environments,
|
||
};
|
||
filename = `auto-login-configs-${stamp}.json`;
|
||
}
|
||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
showToast(t("toastExported", String(environments.length), String(totalConfigs), choice.encrypt ? t("encryptedMark") : ""));
|
||
});
|
||
// 导入配置:兼容 v1(数组) / v2(configs) / v3(environments),加密则弹口令 modal
|
||
importBtn.addEventListener("click", () => importFile.click());
|
||
importFile.addEventListener("change", async (e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file)
|
||
return;
|
||
try {
|
||
const text = await file.text();
|
||
const data = JSON.parse(text);
|
||
let incomingEnvironments = null;
|
||
let incomingConfigs = null;
|
||
// 1. v1: 直接数组格式
|
||
if (Array.isArray(data)) {
|
||
incomingConfigs = data;
|
||
}
|
||
// 2. 加密格式
|
||
else if (data?.encrypted === true && data?.cipher) {
|
||
const choice = await openPwdModal("import");
|
||
if (!choice) {
|
||
showToast(t("toastImportCancelled"));
|
||
importFile.value = "";
|
||
return;
|
||
}
|
||
try {
|
||
const decrypted = await decryptConfigs(data.cipher, choice.password);
|
||
// 解密后可能是 v3 (environments 数组/对象) 或 v1 (configs 数组)
|
||
if (Array.isArray(decrypted)) {
|
||
// 区分环境数组(v3, 元素含 configs)与配置数组(v1, 元素含 domain)
|
||
if (decrypted.some((e) => e && Array.isArray(e.configs))) {
|
||
incomingEnvironments = decrypted;
|
||
}
|
||
else {
|
||
incomingConfigs = decrypted;
|
||
}
|
||
}
|
||
else if (Array.isArray(decrypted?.environments)) {
|
||
incomingEnvironments = decrypted.environments;
|
||
}
|
||
else if (Array.isArray(decrypted?.configs)) {
|
||
incomingConfigs = decrypted.configs;
|
||
}
|
||
else {
|
||
showToast(t("toastInvalidFormat"));
|
||
importFile.value = "";
|
||
return;
|
||
}
|
||
}
|
||
catch (err) {
|
||
showToast(t("toastDecryptFail"));
|
||
importFile.value = "";
|
||
return;
|
||
}
|
||
}
|
||
// 3. v3 明文:含 environments
|
||
else if (Array.isArray(data?.environments)) {
|
||
incomingEnvironments = data.environments;
|
||
}
|
||
// 4. v2 明文:含 configs
|
||
else if (Array.isArray(data?.configs)) {
|
||
incomingConfigs = data.configs;
|
||
}
|
||
else {
|
||
showToast(t("toastFileInvalid"));
|
||
importFile.value = "";
|
||
return;
|
||
}
|
||
const { environments: existingEnvs, activeEnvId } = await loadEnvironments();
|
||
// 分支 A:导入 v3 环境结构 —— 按"环境名"匹配,已存在的环境跳过,新的环境整批导入
|
||
if (incomingEnvironments) {
|
||
const existingNames = new Set(existingEnvs.map((e) => e.name));
|
||
let addedEnvs = 0;
|
||
let addedCfgs = 0;
|
||
let skippedCfgs = 0;
|
||
for (const env of incomingEnvironments) {
|
||
if (!env || !env.name) {
|
||
skippedCfgs += (env?.configs?.length) || 0;
|
||
continue;
|
||
}
|
||
if (existingNames.has(env.name)) {
|
||
// 同名环境:跳过整个环境
|
||
skippedCfgs += (env.configs?.length) || 0;
|
||
continue;
|
||
}
|
||
existingNames.add(env.name);
|
||
const newEnv = {
|
||
id: genId(),
|
||
name: env.name,
|
||
configs: [],
|
||
};
|
||
for (const cfg of (env.configs || [])) {
|
||
if (!cfg || !cfg.domain || !cfg.username || !cfg.password) {
|
||
skippedCfgs++;
|
||
continue;
|
||
}
|
||
newEnv.configs.push({
|
||
id: cfg.id || genCfgId(),
|
||
domain: cfg.domain,
|
||
alias: cfg.alias || null,
|
||
username: cfg.username,
|
||
password: cfg.password,
|
||
autoSubmit: cfg.autoSubmit !== false,
|
||
usernameSelector: cfg.usernameSelector || null,
|
||
passwordSelector: cfg.passwordSelector || null,
|
||
submitSelector: cfg.submitSelector || null,
|
||
enabled: cfg.enabled !== false,
|
||
delayMs: cfg.delayMs || null,
|
||
});
|
||
addedCfgs++;
|
||
}
|
||
existingEnvs.push(newEnv);
|
||
addedEnvs++;
|
||
}
|
||
await saveEnvironments(existingEnvs, activeEnvId ?? undefined);
|
||
await renderEnvSelect();
|
||
await renderConfigList();
|
||
showToast(t("toastImportedEnvs", String(addedEnvs), String(addedCfgs), String(skippedCfgs)));
|
||
return;
|
||
}
|
||
// 分支 B:导入 v1/v2 配置数组 —— 合并到当前激活环境,相同 domain 跳过
|
||
const curEnv = existingEnvs.find((e) => e.id === activeEnvId) || existingEnvs[0];
|
||
const seenDomains = new Set(curEnv.configs.map((c) => c.domain));
|
||
let added = 0;
|
||
let skipped = 0;
|
||
for (const cfg of incomingConfigs || []) {
|
||
if (!cfg || !cfg.domain || !cfg.username || !cfg.password) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
if (seenDomains.has(cfg.domain)) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
seenDomains.add(cfg.domain);
|
||
curEnv.configs.push({
|
||
id: cfg.id || genCfgId(),
|
||
domain: cfg.domain,
|
||
alias: cfg.alias || null,
|
||
username: cfg.username,
|
||
password: cfg.password,
|
||
autoSubmit: cfg.autoSubmit !== false,
|
||
usernameSelector: cfg.usernameSelector || null,
|
||
passwordSelector: cfg.passwordSelector || null,
|
||
submitSelector: cfg.submitSelector || null,
|
||
enabled: cfg.enabled !== false,
|
||
delayMs: cfg.delayMs || null,
|
||
});
|
||
added++;
|
||
}
|
||
await saveEnvironments(existingEnvs);
|
||
await renderConfigList();
|
||
showToast(t("toastImportedCfgs", String(added), String(skipped)));
|
||
}
|
||
catch (err) {
|
||
showToast(t("toastImportFail", err.message || "parse error"));
|
||
}
|
||
finally {
|
||
importFile.value = "";
|
||
}
|
||
});
|
||
// 从书签导入
|
||
function flattenBookmarks(nodes, out) {
|
||
for (const node of nodes) {
|
||
if (node.url) {
|
||
out.push({ title: node.title || node.url, url: node.url });
|
||
}
|
||
if (node.children) {
|
||
flattenBookmarks(node.children, out);
|
||
}
|
||
}
|
||
}
|
||
function urlToDomain(url) {
|
||
try {
|
||
const u = new URL(url);
|
||
let domain = u.host + u.pathname;
|
||
if (domain.endsWith("/") && u.pathname !== "/")
|
||
domain = domain.replace(/\/$/, "");
|
||
if (u.pathname === "/")
|
||
domain = u.host;
|
||
return domain;
|
||
}
|
||
catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
let bookmarkItems = [];
|
||
function renderBookmarkList(filter) {
|
||
const kw = (filter || "").trim().toLowerCase();
|
||
bookmarkList.innerHTML = "";
|
||
const items = kw
|
||
? bookmarkItems.filter((b) => b.title.toLowerCase().includes(kw) || b.url.toLowerCase().includes(kw))
|
||
: bookmarkItems;
|
||
const frag = document.createDocumentFragment();
|
||
for (const b of items) {
|
||
const row = document.createElement("label");
|
||
row.className = "bookmark-item";
|
||
const cb = document.createElement("input");
|
||
cb.type = "checkbox";
|
||
cb.dataset.url = b.url;
|
||
const text = document.createElement("span");
|
||
text.className = "bookmark-item-text";
|
||
text.textContent = b.title;
|
||
const sub = document.createElement("span");
|
||
sub.className = "bookmark-item-url";
|
||
sub.textContent = b.url;
|
||
row.appendChild(cb);
|
||
row.appendChild(text);
|
||
row.appendChild(sub);
|
||
frag.appendChild(row);
|
||
}
|
||
bookmarkList.appendChild(frag);
|
||
updateBookmarkSelectAllState();
|
||
}
|
||
function updateBookmarkSelectAllState() {
|
||
const cbs = bookmarkList.querySelectorAll("input[type=checkbox]");
|
||
const checked = bookmarkList.querySelectorAll("input[type=checkbox]:checked");
|
||
bookmarkSelectAll.checked = cbs.length > 0 && cbs.length === checked.length;
|
||
bookmarkConfirm.disabled = checked.length === 0;
|
||
}
|
||
importBookmarkBtn.addEventListener("click", async () => {
|
||
try {
|
||
const tree = await chrome.bookmarks.getTree();
|
||
bookmarkItems = [];
|
||
flattenBookmarks(tree, bookmarkItems);
|
||
bookmarkSearch.value = "";
|
||
bookmarkSelectAll.checked = false;
|
||
renderBookmarkList("");
|
||
bookmarkModal.classList.add("show");
|
||
}
|
||
catch (e) {
|
||
showToast(t("bookmarkLoadFail"));
|
||
}
|
||
});
|
||
bookmarkSearch.addEventListener("input", () => {
|
||
renderBookmarkList(bookmarkSearch.value);
|
||
});
|
||
bookmarkList.addEventListener("change", () => {
|
||
updateBookmarkSelectAllState();
|
||
});
|
||
bookmarkSelectAll.addEventListener("change", () => {
|
||
const cbs = bookmarkList.querySelectorAll("input[type=checkbox]");
|
||
cbs.forEach((cb) => (cb.checked = bookmarkSelectAll.checked));
|
||
bookmarkConfirm.disabled = !bookmarkSelectAll.checked;
|
||
});
|
||
bookmarkCancel.addEventListener("click", () => {
|
||
bookmarkModal.classList.remove("show");
|
||
});
|
||
bookmarkModal.addEventListener("click", (e) => {
|
||
if (e.target === bookmarkModal)
|
||
bookmarkModal.classList.remove("show");
|
||
});
|
||
bookmarkConfirm.addEventListener("click", async () => {
|
||
const checked = bookmarkList.querySelectorAll("input[type=checkbox]:checked");
|
||
if (!checked.length)
|
||
return;
|
||
const configs = await loadConfigs();
|
||
const existingDomains = new Set(configs.map((c) => c.domain));
|
||
let added = 0;
|
||
let skipped = 0;
|
||
for (const cb of checked) {
|
||
const domain = urlToDomain(cb.dataset.url);
|
||
if (!domain || existingDomains.has(domain)) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
existingDomains.add(domain);
|
||
configs.push({
|
||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
||
domain,
|
||
alias: cb.parentElement.querySelector(".bookmark-item-text").textContent || null,
|
||
username: "",
|
||
password: "",
|
||
autoSubmit: true,
|
||
usernameSelector: null,
|
||
passwordSelector: null,
|
||
submitSelector: null,
|
||
enabled: true,
|
||
delayMs: null,
|
||
});
|
||
added++;
|
||
}
|
||
await saveConfigs(configs);
|
||
bookmarkModal.classList.remove("show");
|
||
renderConfigList();
|
||
renderEnvSelect();
|
||
showToast(t("toastBookmarkImported", String(added), String(skipped)));
|
||
});
|
||
// 锁定 / 解锁
|
||
let lockMode = "setup"; // "setup" | "unlock"
|
||
let lockAfterSetup = false;
|
||
function showLockScreen(mode) {
|
||
lockMode = mode;
|
||
lockScreen.style.display = "flex";
|
||
mainContainer.style.display = "none";
|
||
lockPassword.value = "";
|
||
lockPasswordConfirm.value = "";
|
||
lockWarn.textContent = "";
|
||
lockWarn.style.display = "none";
|
||
if (mode === "setup") {
|
||
lockTitle.textContent = t("lockSetupTitle");
|
||
lockHint.textContent = t("lockSetupHint");
|
||
lockConfirmGroup.style.display = "block";
|
||
lockSubmitBtn.textContent = t("lockSetupBtn");
|
||
}
|
||
else {
|
||
lockTitle.textContent = t("lockUnlockTitle");
|
||
lockHint.textContent = t("lockUnlockHint");
|
||
lockConfirmGroup.style.display = "none";
|
||
lockSubmitBtn.textContent = t("lockUnlockBtn");
|
||
}
|
||
setTimeout(() => lockPassword.focus(), 50);
|
||
}
|
||
function showMainUI() {
|
||
lockScreen.style.display = "none";
|
||
mainContainer.style.display = "flex";
|
||
}
|
||
async function initLockState() {
|
||
try {
|
||
const resp = await sendMessage({ action: "getVaultStatus" });
|
||
lockBtn.hidden = false;
|
||
if (!resp.hasVault) {
|
||
// 无 vault:明文模式,直接进入,不锁定
|
||
showMainUI();
|
||
loadDraft();
|
||
renderEnvSelect();
|
||
renderConfigList();
|
||
}
|
||
else if (!resp.unlocked) {
|
||
showLockScreen("unlock");
|
||
}
|
||
else {
|
||
showMainUI();
|
||
loadDraft();
|
||
renderEnvSelect();
|
||
renderConfigList();
|
||
}
|
||
}
|
||
catch (err) {
|
||
console.warn("[AutoLogin]", t("logGetLockStatusFail"), err);
|
||
lockBtn.hidden = false;
|
||
showMainUI();
|
||
loadDraft();
|
||
renderEnvSelect();
|
||
renderConfigList();
|
||
}
|
||
}
|
||
lockSubmitBtn.addEventListener("click", async () => {
|
||
const pwd = lockPassword.value;
|
||
if (!pwd) {
|
||
lockWarn.textContent = t("lockWarnEmpty");
|
||
lockWarn.style.display = "block";
|
||
return;
|
||
}
|
||
if (lockMode === "setup") {
|
||
if (pwd.length < 6) {
|
||
lockWarn.textContent = t("lockWarnShort");
|
||
lockWarn.style.display = "block";
|
||
return;
|
||
}
|
||
if (pwd !== lockPasswordConfirm.value) {
|
||
lockWarn.textContent = t("lockWarnMismatch");
|
||
lockWarn.style.display = "block";
|
||
return;
|
||
}
|
||
try {
|
||
await sendMessage({ action: "setupVault", password: pwd });
|
||
showToast(t("toastPasswordSet"));
|
||
if (lockAfterSetup) {
|
||
lockAfterSetup = false;
|
||
await sendMessage({ action: "lock" });
|
||
showLockScreen("unlock");
|
||
showToast(t("toastLocked"));
|
||
}
|
||
else {
|
||
showMainUI();
|
||
loadDraft();
|
||
renderEnvSelect();
|
||
renderConfigList();
|
||
}
|
||
}
|
||
catch (err) {
|
||
lockWarn.textContent = t("lockWarnSetupFail", err.message || String(err));
|
||
lockWarn.style.display = "block";
|
||
}
|
||
}
|
||
else {
|
||
try {
|
||
const resp = await sendMessage({ action: "unlock", password: pwd });
|
||
if (resp.success) {
|
||
showToast(t("toastUnlocked"));
|
||
showMainUI();
|
||
loadDraft();
|
||
renderEnvSelect();
|
||
renderConfigList();
|
||
}
|
||
else {
|
||
lockWarn.textContent = resp.error || t("lockWarnUnlockFail", "");
|
||
lockWarn.style.display = "block";
|
||
}
|
||
}
|
||
catch (err) {
|
||
lockWarn.textContent = t("lockWarnUnlockFail", err.message || String(err));
|
||
lockWarn.style.display = "block";
|
||
}
|
||
}
|
||
});
|
||
lockPassword.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter")
|
||
lockSubmitBtn.click();
|
||
});
|
||
lockPasswordConfirm.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter")
|
||
lockSubmitBtn.click();
|
||
});
|
||
lockBtn.addEventListener("click", async () => {
|
||
const resp = await sendMessage({ action: "getVaultStatus" }).catch(() => null);
|
||
if (resp && !resp.hasVault) {
|
||
// 明文模式:先设密码再锁定
|
||
lockAfterSetup = true;
|
||
showLockScreen("setup");
|
||
return;
|
||
}
|
||
if (!confirm(t("lockConfirmPrompt")))
|
||
return;
|
||
await sendMessage({ action: "lock" });
|
||
showLockScreen("unlock");
|
||
showToast(t("toastLocked"));
|
||
});
|
||
// 弹窗内的点击视为用户活动,重置 background 自动锁定计时
|
||
document.addEventListener("click", () => {
|
||
if (lockScreen.style.display === "none") {
|
||
sendMessage({ action: "ping" }, 1).catch(() => { });
|
||
}
|
||
});
|
||
initLockState();
|
||
});
|