Files
Xu_BrowserBookmark/apps/web/src/pages/AdminPage.vue
Xujiacheng 1a3bbac9ff 提交0.1.0版本
- 完成了书签的基本功能和插件
2026-01-21 23:09:33 +08:00

436 lines
16 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 { apiFetch, ensureMe, userRef } from "../lib/api";
import BbModal from "../components/BbModal.vue";
const loadingUsers = ref(false);
const usersError = ref("");
const users = ref([]);
const selectedUserId = ref("");
const selectedUser = computed(() => users.value.find((u) => u.id === selectedUserId.value) || null);
const q = ref("");
const loadingBookmarks = ref(false);
const bookmarksError = ref("");
const bookmarks = ref([]);
const loadingFolders = ref(false);
const foldersError = ref("");
const folders = ref([]);
const openFolderIds = ref(new Set());
const isAdmin = computed(() => userRef.value?.role === "admin");
const confirmOpen = ref(false);
const confirmTitle = ref("请确认");
const confirmMessage = ref("");
const confirmOkText = ref("确定");
const confirmDanger = ref(false);
let confirmResolve = null;
function askConfirm(message, { title = "请确认", okText = "确定", danger = false } = {}) {
confirmTitle.value = title;
confirmMessage.value = message;
confirmOkText.value = okText;
confirmDanger.value = danger;
confirmOpen.value = true;
return new Promise((resolve) => {
confirmResolve = resolve;
});
}
function resolveConfirm(result) {
const resolve = confirmResolve;
confirmResolve = null;
confirmOpen.value = false;
if (resolve) resolve(Boolean(result));
}
function onConfirmModalUpdate(v) {
if (!v) resolveConfirm(false);
else confirmOpen.value = true;
}
async function loadUsers() {
loadingUsers.value = true;
usersError.value = "";
try {
users.value = await apiFetch("/admin/users");
if (!selectedUserId.value && users.value.length) selectedUserId.value = users.value[0].id;
} catch (e) {
usersError.value = e.message || String(e);
} finally {
loadingUsers.value = false;
}
}
async function loadFolders() {
if (!selectedUserId.value) return;
loadingFolders.value = true;
foldersError.value = "";
try {
folders.value = await apiFetch(`/admin/users/${selectedUserId.value}/folders`);
} catch (e) {
foldersError.value = e.message || String(e);
} finally {
loadingFolders.value = false;
}
}
async function loadBookmarks() {
if (!selectedUserId.value) return;
loadingBookmarks.value = true;
bookmarksError.value = "";
try {
const qs = q.value.trim() ? `?q=${encodeURIComponent(q.value.trim())}` : "";
bookmarks.value = await apiFetch(`/admin/users/${selectedUserId.value}/bookmarks${qs}`);
} catch (e) {
bookmarksError.value = e.message || String(e);
} finally {
loadingBookmarks.value = false;
}
}
let searchTimer = 0;
watch(
() => q.value,
() => {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
loadBookmarks();
}, 200);
}
);
onBeforeUnmount(() => {
window.clearTimeout(searchTimer);
});
function selectUser(id) {
selectedUserId.value = id;
q.value = "";
folders.value = [];
bookmarks.value = [];
openFolderIds.value = new Set(["ROOT"]);
loadFolders();
loadBookmarks();
}
function openUrl(url) {
if (!url) return;
window.open(url, "_blank", "noopener,noreferrer");
}
function buildFolderFlat(list) {
const byId = new Map((list || []).map((f) => [f.id, f]));
const children = new Map();
for (const f of list || []) {
const key = f.parentId ?? null;
if (!children.has(key)) children.set(key, []);
children.get(key).push(f);
}
for (const arr of children.values()) {
arr.sort((a, b) => {
const ao = Number.isFinite(a.sortOrder) ? a.sortOrder : 0;
const bo = Number.isFinite(b.sortOrder) ? b.sortOrder : 0;
if (ao !== bo) return ao - bo;
return String(a.name || "").localeCompare(String(b.name || ""));
});
}
const out = [];
const visited = new Set();
function walk(parentId, depth) {
const arr = children.get(parentId ?? null) || [];
for (const f of arr) {
if (!f?.id || visited.has(f.id)) continue;
visited.add(f.id);
out.push({ folder: f, depth });
walk(f.id, depth + 1);
}
}
walk(null, 0);
for (const f of list || []) {
if (f?.id && !visited.has(f.id) && byId.has(f.id)) {
out.push({ folder: f, depth: 0 });
visited.add(f.id);
}
}
return out;
}
const folderFlat = computed(() => buildFolderFlat(folders.value));
const bookmarksByFolderId = computed(() => {
const map = new Map();
for (const b of bookmarks.value || []) {
const key = b.folderId ?? null;
if (!map.has(key)) map.set(key, []);
map.get(key).push(b);
}
for (const arr of map.values()) {
arr.sort((a, b) => {
const ao = Number.isFinite(a.sortOrder) ? a.sortOrder : 0;
const bo = Number.isFinite(b.sortOrder) ? b.sortOrder : 0;
if (ao !== bo) return ao - bo;
return String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""));
});
}
return map;
});
function folderCount(folderId) {
return (bookmarksByFolderId.value.get(folderId ?? null) || []).length;
}
function toggleFolder(folderId) {
const set = new Set(openFolderIds.value);
if (set.has(folderId)) set.delete(folderId);
else set.add(folderId);
openFolderIds.value = set;
}
function isFolderOpen(folderId) {
return openFolderIds.value.has(folderId);
}
async function deleteBookmark(bookmarkId) {
if (!selectedUserId.value) return;
if (!(await askConfirm("确定删除该书签?", { title: "删除书签", okText: "删除", danger: true }))) return;
try {
await apiFetch(`/admin/users/${selectedUserId.value}/bookmarks/${bookmarkId}`, { method: "DELETE" });
await loadBookmarks();
} catch (e) {
bookmarksError.value = e.message || String(e);
}
}
async function deleteFolder(folderId) {
if (!selectedUserId.value) return;
if (!(await askConfirm("确定删除该文件夹?子文件夹会一起删除,文件夹内书签会变成未分组。", { title: "删除文件夹", okText: "删除", danger: true }))) return;
try {
await apiFetch(`/admin/users/${selectedUserId.value}/folders/${folderId}`, { method: "DELETE" });
await loadFolders();
await loadBookmarks();
} catch (e) {
foldersError.value = e.message || String(e);
}
}
async function copyToMe(bookmarkId) {
if (!selectedUserId.value) return;
try {
await apiFetch(`/admin/users/${selectedUserId.value}/bookmarks/${bookmarkId}/copy-to-me`, { method: "POST" });
} catch (e) {
bookmarksError.value = e.message || String(e);
}
}
onMounted(async () => {
await ensureMe();
if (!isAdmin.value) return;
await loadUsers();
if (selectedUserId.value) openFolderIds.value = new Set(["ROOT"]);
await loadFolders();
await loadBookmarks();
});
</script>
<template>
<section class="bb-page">
<div class="bb-pageHeader">
<div>
<h1 style="margin: 0;">管理用户</h1>
<div class="bb-muted" style="margin-top: 4px;">仅管理员可见查看用户列表与其书签</div>
</div>
</div>
<div v-if="!isAdmin" class="bb-empty" style="margin-top: 12px;">
<div style="font-weight: 900;">无权限</div>
<div class="bb-muted" style="margin-top: 6px;">当前账号不是管理员</div>
</div>
<div v-else class="bb-adminGrid" style="margin-top: 12px;">
<aside class="bb-card">
<div class="bb-cardTitle">用户</div>
<div class="bb-muted" style="margin-top: 4px;">点击查看该用户书签</div>
<p v-if="usersError" class="bb-alert bb-alert--error" style="margin-top: 10px;">{{ usersError }}</p>
<p v-else-if="loadingUsers" class="bb-muted" style="margin-top: 10px;">加载中</p>
<div v-else class="bb-adminUserList" style="margin-top: 10px;">
<button
v-for="u in users"
:key="u.id"
type="button"
class="bb-adminUser"
:class="u.id === selectedUserId ? 'is-active' : ''"
@click="selectUser(u.id)"
>
<div class="email">{{ u.email }}</div>
<div class="meta">{{ u.role === 'admin' ? '管理员' : '用户' }}</div>
</button>
</div>
</aside>
<section class="bb-card">
<div class="bb-row" style="justify-content: space-between; align-items: flex-end;">
<div>
<div class="bb-cardTitle">书签</div>
<div class="bb-muted" style="margin-top: 4px;">
<span v-if="selectedUser">当前{{ selectedUser.email }}</span>
<span v-else>请选择一个用户</span>
</div>
</div>
<div class="bb-row" style="gap: 8px;">
<div class="bb-searchWrap" style="min-width: 260px;">
<input v-model="q" class="bb-input bb-input--sm bb-input--withClear" placeholder="搜索标题/链接" />
<button v-if="q.trim()" class="bb-searchClear" type="button" aria-label="清空搜索" @click="q = ''">×</button>
</div>
</div>
</div>
<p v-if="bookmarksError" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ bookmarksError }}</p>
<p v-else-if="loadingBookmarks" class="bb-muted" style="margin-top: 12px;">加载中</p>
<p v-if="foldersError" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ foldersError }}</p>
<div v-else-if="(!folders.length && !bookmarks.length)" class="bb-empty" style="margin-top: 12px;">
<div style="font-weight: 800;">暂无数据</div>
<div class="bb-muted" style="margin-top: 6px;">该用户还没有书签或文件夹</div>
</div>
<div v-else class="bb-adminTree" style="margin-top: 12px;">
<!-- Root group -->
<div class="bb-adminFolder" :class="isFolderOpen('ROOT') ? 'is-open' : ''">
<button type="button" class="bb-adminFolderHeader" @click="toggleFolder('ROOT')">
<span class="name">未分组</span>
<span class="meta">{{ folderCount(null) }} </span>
</button>
<div v-if="isFolderOpen('ROOT')" class="bb-adminFolderBody">
<ul class="bb-adminBookmarks">
<li
v-for="b in (bookmarksByFolderId.get(null) || [])"
:key="b.id"
class="bb-card bb-card--interactive bb-clickCard"
role="link"
tabindex="0"
@click="openUrl(b.url)"
@keydown.enter.prevent="openUrl(b.url)"
@keydown.space.prevent="openUrl(b.url)"
>
<div class="bb-bookmarkTitle" style="font-weight: 900; color: var(--bb-text);">{{ b.title }}</div>
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div>
<div class="bb-adminActions" style="margin-top: 10px;">
<button class="bb-btn bb-btn--secondary" type="button" @click.stop="copyToMe(b.id)">复制到我</button>
<button class="bb-btn bb-btn--danger" type="button" @click.stop="deleteBookmark(b.id)">删除</button>
</div>
</li>
</ul>
</div>
</div>
<!-- Folder tree (flat with indent) -->
<div
v-for="x in folderFlat"
:key="x.folder.id"
class="bb-adminFolder"
:class="isFolderOpen(x.folder.id) ? 'is-open' : ''"
:style="{ paddingLeft: `${x.depth * 14}px` }"
>
<div class="bb-adminFolderHeaderRow">
<button type="button" class="bb-adminFolderHeader" @click="toggleFolder(x.folder.id)">
<span class="name">{{ x.folder.name }}</span>
<span class="meta">{{ folderCount(x.folder.id) }} </span>
</button>
<button class="bb-btn bb-btn--danger bb-adminFolderDel" type="button" @click.stop="deleteFolder(x.folder.id)">删除文件夹</button>
</div>
<div v-if="isFolderOpen(x.folder.id)" class="bb-adminFolderBody">
<ul class="bb-adminBookmarks">
<li
v-for="b in (bookmarksByFolderId.get(x.folder.id) || [])"
:key="b.id"
class="bb-card bb-card--interactive bb-clickCard"
role="link"
tabindex="0"
@click="openUrl(b.url)"
@keydown.enter.prevent="openUrl(b.url)"
@keydown.space.prevent="openUrl(b.url)"
>
<div class="bb-bookmarkTitle" style="font-weight: 900; color: var(--bb-text);">{{ b.title }}</div>
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div>
<div class="bb-adminActions" style="margin-top: 10px;">
<button class="bb-btn bb-btn--secondary" type="button" @click.stop="copyToMe(b.id)">复制到我</button>
<button class="bb-btn bb-btn--danger" type="button" @click.stop="deleteBookmark(b.id)">删除</button>
</div>
</li>
</ul>
</div>
</div>
</div>
</section>
</div>
<BbModal :model-value="confirmOpen" :title="confirmTitle" max-width="520px" @update:model-value="onConfirmModalUpdate">
<div class="bb-muted" style="white-space: pre-wrap; line-height: 1.6;">{{ confirmMessage }}</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="resolveConfirm(false)">取消</button>
<button class="bb-btn" :class="confirmDanger ? 'bb-btn--danger' : ''" type="button" @click="resolveConfirm(true)">{{ confirmOkText }}</button>
</div>
</BbModal>
</section>
</template>
<style scoped>
.bb-adminGrid { display: grid; grid-template-columns: 1fr; gap: 12px; }
@media (min-width: 960px) { .bb-adminGrid { grid-template-columns: 1.1fr 2fr; } }
.bb-adminUserList { display: grid; gap: 8px; }
.bb-adminUser {
width: 100%;
text-align: left;
padding: 10px 12px;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.45);
background: rgba(255,255,255,0.35);
cursor: pointer;
}
.bb-adminUser:hover { background: rgba(255,255,255,0.6); }
.bb-adminUser.is-active { background: rgba(13, 148, 136, 0.12); border-color: rgba(13, 148, 136, 0.22); }
.bb-adminUser .email { font-weight: 800; color: var(--bb-text); }
.bb-adminUser .meta { font-size: 12px; color: rgba(19, 78, 74, 0.72); margin-top: 2px; }
.bb-adminBookmarks { list-style: none; padding: 0; margin: 12px 0 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 768px) { .bb-adminBookmarks { grid-template-columns: 1fr 1fr; } }
.bb-clickCard { cursor: pointer; }
.title { font-weight: 900; color: var(--bb-text); }
.bb-adminFolder { margin-top: 10px; }
.bb-adminFolderHeaderRow { display: flex; gap: 10px; align-items: center; }
.bb-adminFolderHeader {
flex: 1;
width: 100%;
text-align: left;
padding: 10px 12px;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.45);
background: rgba(255,255,255,0.35);
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.bb-adminFolderHeader:hover { background: rgba(255,255,255,0.6); }
.bb-adminFolderHeader .name { font-weight: 900; color: var(--bb-text); }
.bb-adminFolderHeader .meta { font-size: 12px; color: rgba(19, 78, 74, 0.72); }
.bb-adminFolderBody { margin-top: 10px; }
.bb-adminFolderDel { white-space: nowrap; }
.bb-adminActions { display: flex; gap: 8px; flex-wrap: wrap; }
</style>