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

View File

@@ -89,7 +89,7 @@ router.afterEach(() => {
<template>
<header class="nav" data-bb-menu>
<div class="navInner">
<RouterLink to="/" class="brand">
<RouterLink to="/my" class="brand">
<span class="brandMark">X</span>
<span class="brandText">云书签</span>
</RouterLink>
@@ -108,6 +108,10 @@ router.afterEach(() => {
<div v-if="menuOpen" class="menu" role="menu">
<RouterLink class="menuItem menuItem--import" to="/import" role="menuitem">导入 / 导出</RouterLink>
<a class="menuItem" href="http://mark.cloud-xl.top:9527/extension-dist.zip" role="menuitem" download>
下载插件
</a>
<RouterLink class="menuItem menuItem--password" to="/passwords" role="menuitem">密码管理</RouterLink>
<RouterLink v-if="isAdmin" class="menuItem" to="/admin" role="menuitem">管理用户</RouterLink>
<button class="menuItem danger" type="button" role="menuitem" @click="logout">退出登录</button>
</div>
@@ -186,5 +190,6 @@ router.afterEach(() => {
@media (max-width: 640px) {
.menuItem--import { display: none; }
.menuItem--password { display: none; }
}
</style>

View File

@@ -87,6 +87,7 @@ async function submit() {
placeholder="至少 8 位"
type="password"
autocomplete="current-password"
@keydown.enter.prevent="submit"
/>
</label>

View File

@@ -635,7 +635,7 @@ onBeforeUnmount(() => {
<BbModal v-model="addModalOpen" title="添加书签">
<div class="bb-modalForm">
<input v-model="title" class="bb-input" placeholder="标题" />
<input v-model="url" class="bb-input" placeholder="链接https://..." />
<input v-model="url" class="bb-input" placeholder="链接https://..." @keydown.enter.prevent="add" />
<BbSelect v-model="folderId" :options="folderOptions" />
<BbSelect v-model="visibility" :options="visibilityOptions" />
<div class="bb-row" style="justify-content: flex-end; gap: 10px;">
@@ -648,7 +648,7 @@ onBeforeUnmount(() => {
<BbModal v-if="loggedIn" v-model="folderModalOpen" title="新建文件夹">
<div class="bb-modalForm">
<input v-model="folderName" class="bb-input" placeholder="新文件夹名称" />
<input v-model="folderName" class="bb-input" placeholder="新文件夹名称" @keydown.enter.prevent="createFolder" />
<BbSelect v-model="folderVisibility" :options="folderVisibilityOptions" />
<div class="bb-row" style="justify-content: flex-end; gap: 10px;">
<button class="bb-btn bb-btn--secondary" type="button" @click="folderModalOpen = false">取消</button>
@@ -720,7 +720,7 @@ onBeforeUnmount(() => {
<div v-else class="edit">
<input v-model="editTitle" class="bb-input" placeholder="标题" />
<input v-model="editUrl" class="bb-input" placeholder="链接" />
<input v-model="editUrl" class="bb-input" placeholder="链接" @keydown.enter.prevent="saveEdit(b.id)" />
<BbSelect v-model="editFolderId" :options="folderOptions" />
<BbSelect v-model="editVisibility" :options="visibilityOptions" />
<div class="actions">
@@ -805,7 +805,7 @@ onBeforeUnmount(() => {
<div v-else class="edit">
<input v-model="editTitle" class="bb-input" placeholder="标题" />
<input v-model="editUrl" class="bb-input" placeholder="链接" />
<input v-model="editUrl" class="bb-input" placeholder="链接" @keydown.enter.prevent="saveEdit(b.id)" />
<BbSelect v-model="editFolderId" :options="folderOptions" />
<BbSelect v-model="editVisibility" :options="visibilityOptions" />
<div class="actions">

View File

@@ -0,0 +1,266 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import BbModal from "../components/BbModal.vue";
import BbSelect from "../components/BbSelect.vue";
import { apiFetch, ensureMe, tokenRef, userRef } from "../lib/api";
const loggedIn = computed(() => Boolean(tokenRef.value));
const isAdmin = computed(() => userRef.value?.role === "admin");
const isDesktop = ref(true);
function syncViewport() {
isDesktop.value = window.innerWidth >= 960;
}
const loading = ref(false);
const error = ref("");
const items = ref([]);
const users = ref([]);
const selectedUserId = ref("");
const selectedUser = computed(() => users.value.find((u) => u.id === selectedUserId.value) || null);
const showPasswords = ref(false);
const revealPasswords = computed(() => isAdmin.value || showPasswords.value);
function loadSessionFlag() {
try {
showPasswords.value = sessionStorage.getItem("bb_show_passwords") === "1";
} catch {
showPasswords.value = false;
}
}
function toggleShowPasswords() {
showPasswords.value = !showPasswords.value;
try {
sessionStorage.setItem("bb_show_passwords", showPasswords.value ? "1" : "0");
} catch {
// ignore
}
}
async function loadUsers() {
if (!isAdmin.value) return;
users.value = await apiFetch("/admin/users");
if (!selectedUserId.value && users.value.length) selectedUserId.value = users.value[0].id;
}
async function loadCredentials() {
if (!loggedIn.value) return;
loading.value = true;
error.value = "";
try {
if (isAdmin.value) {
if (!selectedUserId.value) {
items.value = [];
return;
}
const qs = revealPasswords.value ? "?includePassword=true" : "";
items.value = await apiFetch(`/admin/users/${selectedUserId.value}/credentials${qs}`);
return;
}
const qs = revealPasswords.value ? "?includePassword=true" : "";
items.value = await apiFetch(`/credentials${qs}`);
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
const editOpen = ref(false);
const editId = ref("");
const editSiteOrigin = ref("");
const editUsername = ref("");
const editPassword = ref("");
const editBusy = ref(false);
function startEdit(item) {
editId.value = item.id;
editSiteOrigin.value = item.siteOrigin || "";
editUsername.value = item.username || "";
editPassword.value = "";
editOpen.value = true;
}
async function submitEdit() {
const payload = {
siteOrigin: editSiteOrigin.value.trim(),
username: editUsername.value.trim()
};
if (editPassword.value) payload.password = editPassword.value;
editBusy.value = true;
error.value = "";
try {
if (isAdmin.value) {
await apiFetch(`/admin/users/${selectedUserId.value}/credentials/${editId.value}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
} else {
await apiFetch(`/credentials/${editId.value}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
editOpen.value = false;
await loadCredentials();
} catch (e) {
error.value = e.message || String(e);
} finally {
editBusy.value = false;
}
}
const confirmOpen = ref(false);
const pendingDeleteId = ref("");
const deleteBusy = ref(false);
function askDelete(id) {
pendingDeleteId.value = id;
confirmOpen.value = true;
}
async function confirmDelete() {
if (!pendingDeleteId.value) return;
deleteBusy.value = true;
error.value = "";
try {
if (isAdmin.value) {
await apiFetch(`/admin/users/${selectedUserId.value}/credentials/${pendingDeleteId.value}`, { method: "DELETE" });
} else {
await apiFetch(`/credentials/${pendingDeleteId.value}`, { method: "DELETE" });
}
confirmOpen.value = false;
pendingDeleteId.value = "";
await loadCredentials();
} catch (e) {
error.value = e.message || String(e);
} finally {
deleteBusy.value = false;
}
}
function cancelDelete() {
confirmOpen.value = false;
pendingDeleteId.value = "";
}
onMounted(async () => {
syncViewport();
window.addEventListener("resize", syncViewport);
loadSessionFlag();
if (loggedIn.value) await ensureMe();
await loadUsers();
await loadCredentials();
});
watch([() => selectedUserId.value, () => showPasswords.value], async () => {
await loadCredentials();
});
onBeforeUnmount(() => {
window.removeEventListener("resize", syncViewport);
});
</script>
<template>
<section class="bb-page">
<div class="bb-pageHeader">
<div>
<h1 style="margin: 0;">密码管理</h1>
<div class="bb-muted" style="margin-top: 4px;"> PC 显示可管理已保存的网站账号密码</div>
</div>
<div class="bb-row" style="gap: 8px;">
<button v-if="!isAdmin" class="bb-btn bb-btn--secondary" type="button" @click="toggleShowPasswords">
{{ showPasswords ? '隐藏明文' : '显示明文' }}
</button>
</div>
</div>
<div v-if="!isDesktop" class="bb-empty" style="margin-top: 12px;">
<div style="font-weight: 900;"> PC 可见</div>
<div class="bb-muted" style="margin-top: 6px;">请在电脑端访问此页面</div>
</div>
<div v-else class="bb-card" style="margin-top: 12px;">
<div v-if="isAdmin" class="bb-row" style="gap: 10px; margin-bottom: 12px;">
<div style="min-width: 220px; flex: 1;">
<BbSelect
v-model="selectedUserId"
:options="users.map((u) => ({ value: u.id, label: u.email }))"
placeholder="选择用户"
/>
</div>
<div class="bb-muted">
{{ selectedUser ? `当前:${selectedUser.email}` : '请选择用户' }}
</div>
</div>
<p v-if="error" class="bb-alert bb-alert--error">{{ error }}</p>
<p v-else-if="loading" class="bb-muted">加载中</p>
<div v-else-if="!items.length" class="bb-empty" style="margin-top: 12px;">
<div style="font-weight: 700;">暂无记录</div>
<div class="bb-muted" style="margin-top: 6px;">没有保存的账号密码</div>
</div>
<div v-else class="bb-credList">
<div v-for="c in items" :key="c.id" class="bb-card bb-card--interactive bb-credItem">
<div class="bb-credRow">
<div class="bb-credMeta">
<div class="bb-credSite">{{ c.siteOrigin }}</div>
<div class="bb-credUser">账号{{ c.username }}</div>
</div>
<div class="bb-credActions">
<button class="bb-btn bb-btn--secondary" type="button" @click="startEdit(c)">编辑</button>
<button class="bb-btn bb-btn--danger" type="button" @click="askDelete(c.id)">删除</button>
</div>
</div>
<div class="bb-credPwd">
<span class="bb-muted">密码</span>
<span v-if="revealPasswords">{{ c.password || '' }}</span>
<span v-else class="bb-muted">已隐藏</span>
</div>
</div>
</div>
</div>
<BbModal v-model="editOpen" title="编辑账号" max-width="560px">
<div class="bb-modalForm">
<input v-model="editSiteOrigin" class="bb-input" placeholder="网站来源 (https://example.com)" />
<input v-model="editUsername" class="bb-input" placeholder="账号" />
<input v-model="editPassword" class="bb-input" type="password" placeholder="新密码(可留空不改)" @keydown.enter.prevent="submitEdit" />
<div class="bb-row" style="justify-content: flex-end; gap: 10px;">
<button class="bb-btn bb-btn--secondary" type="button" @click="editOpen = false">取消</button>
<button class="bb-btn" type="button" :disabled="editBusy" @click="submitEdit">保存</button>
</div>
</div>
</BbModal>
<BbModal :model-value="confirmOpen" title="删除账号密码" max-width="520px" @update:model-value="(v) => (v ? (confirmOpen = true) : cancelDelete())">
<div class="bb-muted" style="line-height: 1.6;">确定删除该记录?</div>
<div class="bb-row" style="justify-content: flex-end; gap: 10px; margin-top: 14px;">
<button class="bb-btn bb-btn--secondary" type="button" @click="cancelDelete">取消</button>
<button class="bb-btn bb-btn--danger" type="button" :disabled="deleteBusy" @click="confirmDelete">删除</button>
</div>
</BbModal>
</section>
</template>
<style scoped>
.bb-credList { display: grid; gap: 10px; }
.bb-credItem { display: grid; gap: 8px; }
.bb-credRow { display: flex; gap: 12px; justify-content: space-between; align-items: center; flex-wrap: wrap; }
.bb-credMeta { display: grid; gap: 4px; min-width: 0; }
.bb-credSite { font-weight: 900; word-break: break-all; }
.bb-credUser { font-size: 13px; }
.bb-credActions { display: flex; gap: 8px; flex-wrap: wrap; }
.bb-credPwd { font-size: 13px; }
</style>

View File

@@ -139,7 +139,7 @@ onMounted(loadFolders);
<BbModal v-if="loggedIn" v-model="addModalOpen" title="添加书签">
<div class="bb-publicAdd" style="margin-top: 2px;">
<input v-model="addTitle" class="bb-input" placeholder="标题" />
<input v-model="addUrl" class="bb-input" placeholder="链接https://..." />
<input v-model="addUrl" class="bb-input" placeholder="链接https://..." @keydown.enter.prevent="addBookmark" />
<BbSelect
v-model="addFolderId"
:options="folderOptions"

View File

@@ -4,6 +4,7 @@ import LoginPage from "./pages/LoginPage.vue";
import MyPage from "./pages/MyPage.vue";
import ImportExportPage from "./pages/ImportExportPage.vue";
import AdminPage from "./pages/AdminPage.vue";
import PasswordManagerPage from "./pages/PasswordManagerPage.vue";
import { ensureMe, tokenRef } from "./lib/api";
export const router = createRouter({
@@ -13,14 +14,16 @@ export const router = createRouter({
{ path: "/login", component: LoginPage },
{ path: "/my", component: MyPage },
{ path: "/import", component: ImportExportPage },
{ path: "/admin", component: AdminPage }
{ path: "/admin", component: AdminPage },
{ path: "/passwords", component: PasswordManagerPage }
]
});
router.beforeEach(async (to) => {
const loggedIn = Boolean(tokenRef.value);
// 主页(/)永远是公共首页;不因登录态自动跳转
// 已登录访问首页时,跳转到个人页
if (to.path === "/" && loggedIn) return { path: "/my" };
// 已登录访问登录页:直接去“我的”
if (to.path === "/login" && loggedIn) return { path: "/my" };
@@ -30,6 +33,10 @@ router.beforeEach(async (to) => {
return { path: "/login", query: { next: to.fullPath } };
}
if (to.path === "/passwords" && !loggedIn) {
return { path: "/login", query: { next: to.fullPath } };
}
// 管理界面:仅管理员可见
if (to.path.startsWith("/admin")) {
if (!loggedIn) return { path: "/login", query: { next: to.fullPath } };