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

267 lines
8.7 KiB
Vue
Raw Normal View History

<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>