feat: 实现文件夹和书签的持久排序与拖拽功能
This commit is contained in:
379
apps/web/src/pages/AdminPage.vue
Normal file
379
apps/web/src/pages/AdminPage.vue
Normal file
@@ -0,0 +1,379 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { apiFetch, ensureMe, userRef } from "../lib/api";
|
||||
|
||||
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");
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 (!confirm("确定删除该书签?")) 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 (!confirm("确定删除该文件夹?子文件夹会一起删除,文件夹内书签会变成未分组。")) 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;">
|
||||
<input v-model="q" class="bb-input bb-input--sm" placeholder="搜索标题/链接" @keyup.enter="loadBookmarks" />
|
||||
<button class="bb-btn bb-btn--secondary" :disabled="loadingBookmarks" @click="loadBookmarks">搜索</button>
|
||||
</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>
|
||||
</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>
|
||||
Reference in New Issue
Block a user