2026-01-18 23:33:31 +08:00
|
|
|
|
<script setup>
|
2026-01-19 10:00:21 +08:00
|
|
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
2026-01-18 23:33:31 +08:00
|
|
|
|
import { apiFetch, ensureMe, userRef } from "../lib/api";
|
2026-01-19 10:00:21 +08:00
|
|
|
|
import BbModal from "../components/BbModal.vue";
|
2026-01-18 23:33:31 +08:00
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
|
2026-01-19 10:00:21 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 23:33:31 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 10:00:21 +08:00
|
|
|
|
let searchTimer = 0;
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => q.value,
|
|
|
|
|
|
() => {
|
|
|
|
|
|
window.clearTimeout(searchTimer);
|
|
|
|
|
|
searchTimer = window.setTimeout(() => {
|
|
|
|
|
|
loadBookmarks();
|
|
|
|
|
|
}, 200);
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
window.clearTimeout(searchTimer);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-18 23:33:31 +08:00
|
|
|
|
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;
|
2026-01-19 10:00:21 +08:00
|
|
|
|
if (!(await askConfirm("确定删除该书签?", { title: "删除书签", okText: "删除", danger: true }))) return;
|
2026-01-18 23:33:31 +08:00
|
|
|
|
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;
|
2026-01-19 10:00:21 +08:00
|
|
|
|
if (!(await askConfirm("确定删除该文件夹?子文件夹会一起删除,文件夹内书签会变成未分组。", { title: "删除文件夹", okText: "删除", danger: true }))) return;
|
2026-01-18 23:33:31 +08:00
|
|
|
|
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;">
|
2026-01-19 10:00:21 +08:00
|
|
|
|
<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>
|
2026-01-18 23:33:31 +08:00
|
|
|
|
</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>
|
2026-01-19 10:00:21 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-01-18 23:33:31 +08:00
|
|
|
|
</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>
|