feat: 添加密码管理功能,包括 API、数据库支持和前端界面
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -87,6 +87,7 @@ async function submit() {
|
||||
placeholder="至少 8 位"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
@keydown.enter.prevent="submit"
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
266
apps/web/src/pages/PasswordManagerPage.vue
Normal file
266
apps/web/src/pages/PasswordManagerPage.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
Reference in New Issue
Block a user