feat: 添加密码管理功能,包括 API、数据库支持和前端界面

This commit is contained in:
2026-01-23 23:55:08 +08:00
parent 1a3bbac9ff
commit a8c96d84f0
43 changed files with 1957 additions and 110 deletions

Binary file not shown.

View File

@@ -1,7 +1,7 @@
{
"name": "extension",
"private": true,
"version": "0.0.0",
"version": "1.0.3",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,12 +1,24 @@
{
"manifest_version": 3,
"name": "BrowserBookmark",
"version": "0.1.0",
"version": "1.0.3",
"action": {
"default_title": "Bookmarks",
"default_popup": "popup.html"
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"options_page": "options.html",
"permissions": ["storage", "tabs"],
"host_permissions": ["<all_urls>"]
"host_permissions": ["<all_urls>"],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle",
"type": "module"
}
]
}

View File

@@ -0,0 +1,21 @@
const PENDING_KEY = "bb_pending_credential";
const PENDING_TAB_KEY = "bb_pending_tab_id";
function openPopup() {
if (typeof chrome === "undefined" || !chrome.action?.openPopup) return;
chrome.action.openPopup().catch(() => {
// ignore if popup cannot be opened (no user gesture)
});
}
chrome.runtime.onMessage.addListener((msg, sender) => {
if (!msg || msg.type !== "CREDENTIAL_CAPTURED") return;
const payload = msg.payload || {};
chrome.storage.local.set({
[PENDING_KEY]: payload,
[PENDING_TAB_KEY]: sender?.tab?.id || null
}, () => {
openPopup();
});
});

View File

@@ -0,0 +1,245 @@
function getOrigin() {
return window.location.origin;
}
function hasChromeStorage() {
return typeof chrome !== "undefined" && chrome.storage?.local;
}
function storageGet(keys) {
return new Promise((resolve) => {
if (!hasChromeStorage()) return resolve({});
chrome.storage.local.get(keys, (res) => resolve(res || {}));
});
}
async function getToken() {
const res = await storageGet(["bb_token"]);
return res.bb_token || "";
}
async function apiFetch(path, options = {}) {
const headers = new Headers(options.headers || {});
if (!headers.has("Accept")) headers.set("Accept", "application/json");
if (!(options.body instanceof FormData) && options.body != null) {
headers.set("Content-Type", "application/json");
}
const token = await getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const base = (typeof chrome !== "undefined" && chrome.runtime?.getURL)
? (import.meta?.env?.VITE_SERVER_BASE_URL || "http://localhost:3001")
: "http://localhost:3001";
const res = await fetch(`${base}${path}`, { ...options, headers });
const contentType = res.headers.get("content-type") || "";
const isJson = contentType.includes("application/json");
const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => "");
if (!res.ok) {
const message = payload?.message || `HTTP ${res.status}`;
const err = new Error(message);
err.status = res.status;
err.payload = payload;
throw err;
}
return payload;
}
function ensureStyles() {
if (document.getElementById("bb-cred-style")) return;
const style = document.createElement("style");
style.id = "bb-cred-style";
style.textContent = `
.bb-cred-selector{position:fixed;z-index:2147483646;background:#fff;border:1px solid rgba(0,0,0,0.12);box-shadow:0 10px 24px rgba(0,0,0,0.15);border-radius:10px;padding:6px;min-width:240px;max-width:320px;}
.bb-cred-item{display:flex;flex-direction:column;gap:4px;padding:8px;border-radius:8px;cursor:pointer;}
.bb-cred-item:hover{background:rgba(13,148,136,0.08);}
.bb-cred-site{font-size:12px;color:#64748b;}
.bb-cred-user{font-weight:700;color:#0f172a;}
`;
document.head.appendChild(style);
}
async function fetchCredentials({ includePassword = false, siteOrigin }) {
const qs = new URLSearchParams();
if (siteOrigin) qs.set("siteOrigin", siteOrigin);
if (includePassword) qs.set("includePassword", "true");
return apiFetch(`/credentials?${qs.toString()}`);
}
function findUsernameInput(form, passwordInput) {
if (!form) return null;
const inputs = Array.from(form.querySelectorAll("input"));
const pwdIndex = inputs.indexOf(passwordInput);
const candidates = inputs.filter((el, idx) => {
if (el.type === "password") return false;
if (el.disabled || el.readOnly) return false;
if (idx > pwdIndex && pwdIndex !== -1) return false;
const t = (el.type || "text").toLowerCase();
return t === "text" || t === "email" || t === "tel";
});
return candidates[candidates.length - 1] || null;
}
function findPasswordInput(anchor) {
if (!anchor) return null;
if (anchor instanceof HTMLInputElement && anchor.type === "password") return anchor;
const form = anchor.closest("form");
if (form) return form.querySelector("input[type='password']");
let cur = anchor.parentElement;
let depth = 0;
while (cur && depth < 4) {
const found = cur.querySelector("input[type='password']");
if (found) return found;
cur = cur.parentElement;
depth++;
}
return null;
}
function createSelector(anchorEl, creds, onSelect) {
ensureStyles();
const rect = anchorEl.getBoundingClientRect();
const panel = document.createElement("div");
panel.className = "bb-cred-selector";
panel.style.left = `${Math.max(8, rect.left)}px`;
panel.style.top = `${rect.bottom + 6}px`;
panel.style.maxWidth = `${Math.min(360, window.innerWidth - rect.left - 8)}px`;
creds.forEach((c) => {
const item = document.createElement("div");
item.className = "bb-cred-item";
item.innerHTML = `<div class="bb-cred-user">${c.username}</div><div class="bb-cred-site">${c.siteOrigin}</div>`;
item.addEventListener("click", () => {
onSelect(c);
cleanup();
});
panel.appendChild(item);
});
document.body.appendChild(panel);
function cleanup() {
panel.remove();
document.removeEventListener("pointerdown", onDocClick, true);
}
function onDocClick(e) {
if (!panel.contains(e.target) && e.target !== anchorEl) cleanup();
}
document.addEventListener("pointerdown", onDocClick, true);
return cleanup;
}
let lastAnchor = null;
let lastShownAt = 0;
async function showSelectorForInput(anchorEl) {
if (!anchorEl) return;
const now = Date.now();
if (lastAnchor === anchorEl && now - lastShownAt < 300) return;
lastAnchor = anchorEl;
lastShownAt = now;
const passwordInput = findPasswordInput(anchorEl);
if (!passwordInput) return;
const form = passwordInput.closest("form");
const usernameInput = findUsernameInput(form, passwordInput) || (anchorEl.type !== "password" ? anchorEl : null);
const siteOrigin = getOrigin();
let creds = [];
try {
creds = await fetchCredentials({ includePassword: false, siteOrigin });
} catch {
return;
}
if (!Array.isArray(creds) || creds.length === 0) return;
createSelector(anchorEl, creds, async (cred) => {
try {
const full = await fetchCredentials({ includePassword: true, siteOrigin });
const target = full.find((x) => x.id === cred.id);
if (!target) return;
if (usernameInput) {
usernameInput.value = target.username;
usernameInput.dispatchEvent(new Event("input", { bubbles: true }));
}
if (passwordInput) {
passwordInput.value = target.password || "";
passwordInput.dispatchEvent(new Event("input", { bubbles: true }));
}
} catch {
// ignore
}
});
}
function handleInputFocus(e) {
const el = e.target;
if (!(el instanceof HTMLInputElement)) return;
const t = (el.type || "text").toLowerCase();
if (t !== "password" && t !== "text" && t !== "email" && t !== "tel") return;
showSelectorForInput(el);
}
async function handleFormSubmit(e) {
const form = e.target;
if (!(form instanceof HTMLFormElement)) return;
const pwd = form.querySelector("input[type='password']");
if (!pwd) return;
const usernameInput = findUsernameInput(form, pwd);
const username = usernameInput?.value?.trim();
const password = pwd.value?.trim();
if (!username || !password) return;
const token = await getToken();
if (!token) return;
const siteOrigin = getOrigin();
let existing = [];
try {
existing = await fetchCredentials({ includePassword: true, siteOrigin });
} catch {
// if we cannot fetch, still allow prompt to save
existing = [];
}
const sameUser = Array.isArray(existing)
? existing.find((c) => c.username === username)
: null;
if (sameUser && sameUser.password === password) {
return;
}
const reason = sameUser ? "update" : "new";
if (typeof chrome !== "undefined" && chrome.runtime?.sendMessage) {
chrome.runtime.sendMessage({
type: "CREDENTIAL_CAPTURED",
payload: {
siteOrigin,
username,
password,
reason
}
});
}
}
function init() {
document.addEventListener("focusin", handleInputFocus, true);
document.addEventListener("click", handleInputFocus, true);
document.addEventListener("submit", handleFormSubmit, true);
}
init();

View File

@@ -1,5 +1,8 @@
const TOKEN_KEY = "bb_token";
const LOCAL_STATE_KEY = "bb_local_state_v1";
const PENDING_CREDENTIAL_KEY = "bb_pending_credential";
const REAUTH_TOKEN_KEY = "bb_reauth_token";
const REAUTH_EXPIRES_KEY = "bb_reauth_expires_at";
function hasChromeStorage() {
return typeof chrome !== "undefined" && chrome.storage?.local;
@@ -41,3 +44,49 @@ export async function saveLocalState(state) {
}
localStorage.setItem(LOCAL_STATE_KEY, JSON.stringify(state));
}
export async function getPendingCredential() {
if (hasChromeStorage()) {
const res = await chrome.storage.local.get([PENDING_CREDENTIAL_KEY]);
return res[PENDING_CREDENTIAL_KEY] || null;
}
try {
return JSON.parse(localStorage.getItem(PENDING_CREDENTIAL_KEY) || "") || null;
} catch {
return null;
}
}
export async function clearPendingCredential() {
if (hasChromeStorage()) {
await chrome.storage.local.remove([PENDING_CREDENTIAL_KEY]);
return;
}
localStorage.removeItem(PENDING_CREDENTIAL_KEY);
}
export async function setReauthToken(token, expiresAt) {
if (hasChromeStorage()) {
await chrome.storage.local.set({
[REAUTH_TOKEN_KEY]: token || "",
[REAUTH_EXPIRES_KEY]: expiresAt || ""
});
return;
}
localStorage.setItem(REAUTH_TOKEN_KEY, token || "");
localStorage.setItem(REAUTH_EXPIRES_KEY, expiresAt || "");
}
export async function getReauthToken() {
if (hasChromeStorage()) {
const res = await chrome.storage.local.get([REAUTH_TOKEN_KEY, REAUTH_EXPIRES_KEY]);
return {
token: res[REAUTH_TOKEN_KEY] || "",
expiresAt: res[REAUTH_EXPIRES_KEY] || ""
};
}
return {
token: localStorage.getItem(REAUTH_TOKEN_KEY) || "",
expiresAt: localStorage.getItem(REAUTH_EXPIRES_KEY) || ""
};
}

View File

@@ -55,7 +55,7 @@ async function submit() {
<div class="form">
<input v-model="email" class="input" placeholder="邮箱" autocomplete="email" />
<input v-model="password" class="input" placeholder="密码(至少 8 位)" type="password" autocomplete="current-password" />
<input v-model="password" class="input" placeholder="密码(至少 8 位)" type="password" autocomplete="current-password" @keydown.enter.prevent="submit" />
<button class="btn" :disabled="loading" @click="submit">提交</button>
<p v-if="error" class="error">{{ error }}</p>
</div>

View File

@@ -79,7 +79,7 @@ onMounted(load);
<div class="form">
<input v-model="title" class="input" placeholder="标题" />
<input v-model="url" class="input" placeholder="链接" />
<input v-model="url" class="input" placeholder="链接" @keydown.enter.prevent="add" />
<button class="btn" @click="add">添加</button>
</div>

View File

@@ -1,7 +1,7 @@
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import { apiFetch } from "../lib/api";
import { getToken } from "../lib/extStorage";
import { clearPendingCredential, getPendingCredential, getToken } from "../lib/extStorage";
const view = ref("list"); // add | list
@@ -10,6 +10,10 @@ const loggedIn = computed(() => Boolean(token.value));
const loading = ref(false);
const error = ref("");
const savePromptOpen = ref(false);
const pendingCredential = ref(null);
const saveBusy = ref(false);
const saveStatus = ref("");
function openOptions() {
if (typeof chrome !== "undefined" && chrome.runtime?.openOptionsPage) {
@@ -30,6 +34,46 @@ async function refreshAuth() {
token.value = await getToken();
}
async function loadPendingCredential() {
const pending = await getPendingCredential();
pendingCredential.value = pending;
if (pending) savePromptOpen.value = true;
}
async function cancelSavePrompt() {
savePromptOpen.value = false;
pendingCredential.value = null;
await clearPendingCredential();
}
async function confirmSavePrompt() {
error.value = "";
saveStatus.value = "";
if (!pendingCredential.value) return;
if (!loggedIn.value) {
error.value = "请先在『更多操作』里登录";
return;
}
try {
saveBusy.value = true;
await apiFetch("/credentials", {
method: "POST",
body: JSON.stringify({
siteOrigin: pendingCredential.value.siteOrigin,
username: pendingCredential.value.username,
password: pendingCredential.value.password
})
});
saveStatus.value = "已保存账号密码";
await cancelSavePrompt();
} catch (e) {
error.value = e.message || String(e);
} finally {
saveBusy.value = false;
}
}
// folders + bookmarks
const q = ref("");
const folders = ref([]);
@@ -188,6 +232,7 @@ onMounted(async () => {
await refreshAuth();
await prepareAddCurrent();
if (loggedIn.value) await loadAll();
await loadPendingCredential();
});
watch(
@@ -219,6 +264,7 @@ watch(
<p v-if="error" class="alert">{{ error }}</p>
<p v-if="addStatus" class="ok">{{ addStatus }}</p>
<p v-if="saveStatus" class="ok">{{ saveStatus }}</p>
<section v-if="view === 'add'" class="card">
<div class="cardTitle">一键添加书签</div>
@@ -228,7 +274,7 @@ watch(
<input v-model="addTitle" class="input" placeholder="标题" />
<label class="label">链接</label>
<input v-model="addUrl" class="input" placeholder="https://..." />
<input v-model="addUrl" class="input" placeholder="https://..." @keydown.enter.prevent="submitAddCurrent" />
<label class="label">文件夹不选则未分组</label>
<select v-model="addFolderId" class="input" :disabled="!loggedIn">
@@ -276,7 +322,7 @@ watch(
class="input"
placeholder="文件夹名称"
:disabled="!loggedIn || folderBusy"
@keyup.enter="createFolder"
@keydown.enter.prevent="createFolder"
/>
<div class="dialogActions">
<button class="btn btn--secondary" type="button" @click="folderModalOpen = false" :disabled="folderBusy">取消</button>
@@ -287,6 +333,29 @@ watch(
</div>
</div>
<div v-if="savePromptOpen" class="modal" @click.self="cancelSavePrompt">
<div class="dialog" role="dialog" aria-modal="true" aria-label="记住密码">
<div class="dialogTitle">记住密码</div>
<div class="muted" style="margin-top: 4px;">
{{ pendingCredential?.reason === 'update'
? '检测到密码与保存的不一致,是否更新密码?'
: '检测到新账号登录提交,是否保存新账号密码?' }}
</div>
<div class="subCard" style="margin-top: 10px;">
<div class="subTitle">网站</div>
<div class="muted">{{ pendingCredential?.siteOrigin || '-' }}</div>
<div class="subTitle" style="margin-top: 8px;">账号</div>
<div>{{ pendingCredential?.username || '-' }}</div>
<div class="subTitle" style="margin-top: 8px;">密码</div>
<div class="muted">{{ pendingCredential?.password ? '••••••••' : '-' }}</div>
</div>
<div class="dialogActions">
<button class="btn btn--secondary" type="button" @click="cancelSavePrompt">取消</button>
<button class="btn" type="button" :disabled="saveBusy" @click="confirmSavePrompt">确认记住</button>
</div>
</div>
</div>
<div v-if="loading" class="muted" style="margin-top: 10px;">加载中</div>
<div v-if="loggedIn" class="tree">
@@ -335,6 +404,7 @@ watch(
<style scoped>
.wrap{
width: 380px;
min-height: 120vh;
padding: 12px;
font-family: ui-sans-serif, system-ui;
color: var(--bb-text);
@@ -488,7 +558,11 @@ button:disabled{ opacity: 0.6; cursor: not-allowed; }
padding: 8px 10px;
cursor: pointer;
text-align: left;
width: 100%;
max-width: 100%;
display: block;
overflow: hidden;
}
.bmTitle{ font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bmUrl{ font-size: 12px; color: rgba(19, 78, 74, 0.72); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 4px; }
.bmTitle{ font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; display: block; }
.bmUrl{ font-size: 12px; color: rgba(19, 78, 74, 0.72); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 4px; min-width: 0; display: block; }
</style>

View File

@@ -9,7 +9,14 @@ export default defineConfig({
rollupOptions: {
input: {
popup: path.resolve(__dirname, "popup.html"),
options: path.resolve(__dirname, "options.html")
options: path.resolve(__dirname, "options.html"),
background: path.resolve(__dirname, "src/background.js"),
content: path.resolve(__dirname, "src/content/main.js")
},
output: {
entryFileNames: "[name].js",
chunkFileNames: "assets/[name].js",
assetFileNames: "assets/[name][extname]"
}
}
}