267 lines
8.7 KiB
Vue
267 lines
8.7 KiB
Vue
<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>
|