436 lines
16 KiB
Vue
436 lines
16 KiB
Vue
<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>
|