提交0.1.0版本

- 完成了书签的基本功能和插件
This commit is contained in:
2026-01-21 23:09:33 +08:00
parent 3e2d1456eb
commit 1a3bbac9ff
95 changed files with 12431 additions and 12445 deletions

View File

@@ -1,435 +1,435 @@
<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>
<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>