Files
Xu_BrowserBookmark/apps/web/src/pages/PasswordManagerPage.vue

267 lines
8.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>