feat: 实现文件夹和书签的持久排序与拖拽功能
This commit is contained in:
@@ -12,8 +12,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@browser-bookmark/shared": "0.1.0",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue": "^3.5.24"
|
||||
"sortablejs": "^1.15.6",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { RouterLink, RouterView, useRouter } from "vue-router";
|
||||
import { setToken, tokenRef } from "./lib/api";
|
||||
import { ensureMe, setToken, tokenRef, userRef } from "./lib/api";
|
||||
|
||||
const router = useRouter();
|
||||
const loggedIn = computed(() => Boolean(tokenRef.value));
|
||||
const isAdmin = computed(() => userRef.value?.role === "admin");
|
||||
|
||||
const menuOpen = ref(false);
|
||||
|
||||
@@ -18,6 +19,7 @@ function closeMenu() {
|
||||
|
||||
async function logout() {
|
||||
setToken("");
|
||||
userRef.value = null;
|
||||
closeMenu();
|
||||
await router.push("/");
|
||||
}
|
||||
@@ -33,6 +35,19 @@ onMounted(() => {
|
||||
document.addEventListener("pointerdown", onDocPointerDown);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => tokenRef.value,
|
||||
(next) => {
|
||||
if (next) ensureMe();
|
||||
else userRef.value = null;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (loggedIn.value) ensureMe();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("pointerdown", onDocPointerDown);
|
||||
});
|
||||
@@ -64,6 +79,7 @@ router.afterEach(() => {
|
||||
|
||||
<div v-if="menuOpen" class="menu" role="menu">
|
||||
<RouterLink class="menuItem" to="/import" role="menuitem">导入 / 导出</RouterLink>
|
||||
<RouterLink v-if="isAdmin" class="menuItem" to="/admin" role="menuitem">管理用户</RouterLink>
|
||||
<button class="menuItem danger" type="button" role="menuitem" @click="logout">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
108
apps/web/src/components/BbModal.vue
Normal file
108
apps/web/src/components/BbModal.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
title: { type: String, default: "" },
|
||||
maxWidth: { type: String, default: "720px" }
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
function close() {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === "Escape") close();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div v-if="modelValue" class="bb-modalOverlay" role="dialog" aria-modal="true">
|
||||
<div class="bb-modalBackdrop" @click="close" />
|
||||
<div class="bb-modalPanel" :style="{ maxWidth }">
|
||||
<div class="bb-modalHeader">
|
||||
<div class="bb-modalTitle">{{ title }}</div>
|
||||
<button type="button" class="bb-modalClose" @click="close" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="bb-modalBody">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bb-modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483500;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.bb-modalBackdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.bb-modalPanel {
|
||||
position: relative;
|
||||
width: min(100%, var(--bb-modal-max, 720px));
|
||||
max-height: min(84vh, 860px);
|
||||
overflow: auto;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255,255,255,0.65);
|
||||
background: rgba(255,255,255,0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.bb-modalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.bb-modalTitle {
|
||||
font-weight: 900;
|
||||
color: var(--bb-text);
|
||||
}
|
||||
|
||||
.bb-modalClose {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255,255,255,0.55);
|
||||
background: rgba(255,255,255,0.45);
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
color: rgba(15, 23, 42, 0.72);
|
||||
}
|
||||
|
||||
.bb-modalClose:hover {
|
||||
background: rgba(255,255,255,0.75);
|
||||
}
|
||||
|
||||
.bb-modalBody {
|
||||
padding: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number, Boolean, Object, null], default: null },
|
||||
@@ -12,7 +12,10 @@ const props = defineProps({
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const rootEl = ref(null);
|
||||
const triggerEl = ref(null);
|
||||
const menuEl = ref(null);
|
||||
const open = ref(false);
|
||||
const menuStyle = ref({ left: "0px", top: "0px", width: "0px" });
|
||||
|
||||
const selected = computed(() => props.options.find((o) => Object.is(o.value, props.modelValue)) || null);
|
||||
const label = computed(() => selected.value?.label ?? "");
|
||||
@@ -21,9 +24,30 @@ function close() {
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
async function updateMenuPosition() {
|
||||
const el = triggerEl.value;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const gap = 8;
|
||||
|
||||
menuStyle.value = {
|
||||
left: `${Math.round(rect.left)}px`,
|
||||
top: `${Math.round(rect.bottom + gap)}px`,
|
||||
width: `${Math.round(rect.width)}px`
|
||||
};
|
||||
}
|
||||
|
||||
async function openMenu() {
|
||||
if (props.disabled) return;
|
||||
open.value = true;
|
||||
await nextTick();
|
||||
await updateMenuPosition();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (props.disabled) return;
|
||||
open.value = !open.value;
|
||||
if (open.value) close();
|
||||
else openMenu();
|
||||
}
|
||||
|
||||
function choose(value, isDisabled) {
|
||||
@@ -46,22 +70,41 @@ function onKeydownTrigger(e) {
|
||||
|
||||
function onDocPointerDown(e) {
|
||||
const el = rootEl.value;
|
||||
const menu = menuEl.value;
|
||||
if (!el) return;
|
||||
if (el.contains(e.target)) return;
|
||||
if (menu && menu.contains(e.target)) return;
|
||||
close();
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
if (!open.value) return;
|
||||
updateMenuPosition();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("pointerdown", onDocPointerDown);
|
||||
window.addEventListener("resize", onViewportChange);
|
||||
// capture scroll events from any scroll container
|
||||
window.addEventListener("scroll", onViewportChange, true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("pointerdown", onDocPointerDown);
|
||||
window.removeEventListener("resize", onViewportChange);
|
||||
window.removeEventListener("scroll", onViewportChange, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootEl" class="bb-selectWrap" :class="size === 'sm' ? 'bb-selectWrap--sm' : ''">
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="bb-selectWrap"
|
||||
:class="[
|
||||
size === 'sm' ? 'bb-selectWrap--sm' : '',
|
||||
open ? 'is-open' : ''
|
||||
]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="bb-selectTrigger"
|
||||
@@ -69,6 +112,7 @@ onBeforeUnmount(() => {
|
||||
:aria-expanded="open ? 'true' : 'false'"
|
||||
@click="toggle"
|
||||
@keydown="onKeydownTrigger"
|
||||
ref="triggerEl"
|
||||
>
|
||||
<span class="bb-selectValue" :class="!label ? 'bb-selectPlaceholder' : ''">
|
||||
{{ label || placeholder }}
|
||||
@@ -80,22 +124,30 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div v-if="open" class="bb-selectMenu" role="listbox">
|
||||
<button
|
||||
v-for="(o, idx) in options"
|
||||
:key="idx"
|
||||
type="button"
|
||||
class="bb-selectOption"
|
||||
:class="[
|
||||
Object.is(o.value, modelValue) ? 'is-selected' : '',
|
||||
o.disabled ? 'is-disabled' : ''
|
||||
]"
|
||||
role="option"
|
||||
:aria-selected="Object.is(o.value, modelValue) ? 'true' : 'false'"
|
||||
@click="choose(o.value, o.disabled)"
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="menuEl"
|
||||
class="bb-selectMenu bb-selectMenu--portal"
|
||||
role="listbox"
|
||||
:style="menuStyle"
|
||||
>
|
||||
<span class="bb-selectOptionLabel">{{ o.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-for="(o, idx) in options"
|
||||
:key="idx"
|
||||
type="button"
|
||||
class="bb-selectOption"
|
||||
:class="[
|
||||
Object.is(o.value, modelValue) ? 'is-selected' : '',
|
||||
o.disabled ? 'is-disabled' : ''
|
||||
]"
|
||||
role="option"
|
||||
:aria-selected="Object.is(o.value, modelValue) ? 'true' : 'false'"
|
||||
@click="choose(o.value, o.disabled)"
|
||||
>
|
||||
<span class="bb-selectOptionLabel">{{ o.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,9 @@ const BASE_URL = import.meta.env.VITE_SERVER_BASE_URL || "http://localhost:3001"
|
||||
|
||||
export const tokenRef = ref(localStorage.getItem("bb_token") || "");
|
||||
|
||||
export const userRef = ref(null);
|
||||
let mePromise = null;
|
||||
|
||||
export function getToken() {
|
||||
return tokenRef.value || "";
|
||||
}
|
||||
@@ -13,15 +16,51 @@ export function setToken(token) {
|
||||
tokenRef.value = next;
|
||||
if (next) localStorage.setItem("bb_token", next);
|
||||
else localStorage.removeItem("bb_token");
|
||||
|
||||
// reset cached user when auth changes
|
||||
userRef.value = null;
|
||||
mePromise = null;
|
||||
}
|
||||
|
||||
// Keep auth state in sync across tabs.
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === "bb_token") tokenRef.value = e.newValue || "";
|
||||
if (e.key === "bb_token") {
|
||||
tokenRef.value = e.newValue || "";
|
||||
userRef.value = null;
|
||||
mePromise = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureMe() {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
userRef.value = null;
|
||||
mePromise = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (userRef.value) return userRef.value;
|
||||
if (mePromise) return mePromise;
|
||||
|
||||
mePromise = (async () => {
|
||||
try {
|
||||
const me = await apiFetch("/auth/me", { method: "GET" });
|
||||
userRef.value = me;
|
||||
return me;
|
||||
} catch {
|
||||
// token may be invalid/expired
|
||||
userRef.value = null;
|
||||
return null;
|
||||
} finally {
|
||||
mePromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return mePromise;
|
||||
}
|
||||
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (!headers.has("Accept")) headers.set("Accept", "application/json");
|
||||
|
||||
@@ -132,13 +132,72 @@ export function patchLocalBookmark(id, patch) {
|
||||
return item;
|
||||
}
|
||||
|
||||
export function deleteLocalFolder(folderId) {
|
||||
const state = loadLocalState();
|
||||
const now = nowIso();
|
||||
|
||||
state.folders = (state.folders || []).filter((f) => f.id !== folderId);
|
||||
state.bookmarks = (state.bookmarks || []).map((b) => {
|
||||
if (b.deletedAt) return b;
|
||||
if ((b.folderId ?? null) !== (folderId ?? null)) return b;
|
||||
return { ...ensureBookmarkHashes(b), folderId: null, updatedAt: now };
|
||||
});
|
||||
|
||||
saveLocalState(state);
|
||||
}
|
||||
|
||||
export function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}) {
|
||||
const parsed = parseNetscapeBookmarkHtml(html || "");
|
||||
const state = loadLocalState();
|
||||
const now = nowIso();
|
||||
|
||||
state.folders = state.folders || [];
|
||||
state.bookmarks = state.bookmarks || [];
|
||||
|
||||
// Flatten folders (no nesting): dedupe local folders by name.
|
||||
const normName = (s) => String(s || "").replace(/\s+/g, " ").trim();
|
||||
const normKey = (s) => normName(s).toLowerCase();
|
||||
|
||||
const existingFolderByName = new Map(
|
||||
(state.folders || [])
|
||||
.filter((f) => !f.deletedAt)
|
||||
.map((f) => [normKey(f.name), f])
|
||||
);
|
||||
|
||||
const tempIdToFolderName = new Map(
|
||||
(parsed.folders || []).map((f) => [String(f.id), f.name])
|
||||
);
|
||||
|
||||
const folderIdByName = new Map(
|
||||
(state.folders || [])
|
||||
.filter((f) => !f.deletedAt)
|
||||
.map((f) => [normKey(f.name), f.id])
|
||||
);
|
||||
|
||||
for (const f of parsed.folders || []) {
|
||||
const name = normName(f.name);
|
||||
const key = normKey(name);
|
||||
if (!key) continue;
|
||||
|
||||
let id = folderIdByName.get(key);
|
||||
if (!id) {
|
||||
const created = {
|
||||
id: crypto.randomUUID(),
|
||||
userId: null,
|
||||
parentId: null,
|
||||
name,
|
||||
visibility: "private",
|
||||
sortOrder: (state.folders || []).filter((x) => !x.deletedAt && (x.parentId ?? null) === null).length,
|
||||
updatedAt: now,
|
||||
deletedAt: null
|
||||
};
|
||||
state.folders.push(created);
|
||||
existingFolderByName.set(key, created);
|
||||
folderIdByName.set(key, created.id);
|
||||
id = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
const existingByHash = new Map(
|
||||
(state.bookmarks || [])
|
||||
.filter((b) => !b.deletedAt)
|
||||
@@ -156,6 +215,10 @@ export function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}
|
||||
if (!url) continue;
|
||||
const title = (b.title || "").trim() || url;
|
||||
|
||||
const folderTempId = b.parentFolderId ?? null;
|
||||
const folderName = folderTempId ? tempIdToFolderName.get(String(folderTempId)) : null;
|
||||
const folderId = folderName ? (folderIdByName.get(normKey(folderName)) ?? null) : null;
|
||||
|
||||
const urlNormalized = normalizeUrl(url);
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
|
||||
@@ -166,6 +229,7 @@ export function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}
|
||||
existing.urlNormalized = urlNormalized;
|
||||
existing.urlHash = urlHash;
|
||||
existing.visibility = visibility;
|
||||
existing.folderId = folderId;
|
||||
existing.source = "import";
|
||||
existing.updatedAt = now;
|
||||
merged++;
|
||||
@@ -175,7 +239,7 @@ export function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}
|
||||
const created = {
|
||||
id: crypto.randomUUID(),
|
||||
userId: null,
|
||||
folderId: null,
|
||||
folderId,
|
||||
title,
|
||||
url,
|
||||
urlNormalized,
|
||||
|
||||
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>
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import BbSelect from "../components/BbSelect.vue";
|
||||
import BbModal from "../components/BbModal.vue";
|
||||
import Sortable from "sortablejs";
|
||||
import { apiFetch, tokenRef } from "../lib/api";
|
||||
import { loadLocalState, markLocalDeleted, patchLocalBookmark, upsertLocalBookmark } from "../lib/localData";
|
||||
import { deleteLocalFolder, loadLocalState, markLocalDeleted, patchLocalBookmark, upsertLocalBookmark } from "../lib/localData";
|
||||
|
||||
const loggedIn = computed(() => Boolean(tokenRef.value));
|
||||
const error = ref("");
|
||||
@@ -24,6 +26,15 @@ const visibility = ref("public");
|
||||
|
||||
const q = ref("");
|
||||
|
||||
const openFolderIds = ref(new Set());
|
||||
|
||||
const addModalOpen = ref(false);
|
||||
const folderModalOpen = ref(false);
|
||||
const treeEl = ref(null);
|
||||
|
||||
let folderSortable = null;
|
||||
const bookmarkSortables = new Map();
|
||||
|
||||
const items = ref([]);
|
||||
|
||||
const editingId = ref("");
|
||||
@@ -32,6 +43,35 @@ const editUrl = ref("");
|
||||
const editFolderId = ref(null);
|
||||
const editVisibility = ref("public");
|
||||
|
||||
const dragging = ref(false);
|
||||
let dragResetTimer = null;
|
||||
|
||||
function setDragging(next) {
|
||||
dragging.value = Boolean(next);
|
||||
if (dragResetTimer) {
|
||||
clearTimeout(dragResetTimer);
|
||||
dragResetTimer = null;
|
||||
}
|
||||
if (!next) return;
|
||||
// stop clicks right after drop (some browsers still emit a click)
|
||||
dragResetTimer = setTimeout(() => {
|
||||
dragging.value = false;
|
||||
dragResetTimer = null;
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function openUrl(url) {
|
||||
if (!url) return;
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
function onBookmarkActivate(b) {
|
||||
if (!b) return;
|
||||
if (dragging.value) return;
|
||||
if (editingId.value === b.id) return;
|
||||
openUrl(b.url);
|
||||
}
|
||||
|
||||
function buildFolderFlat(list) {
|
||||
const byId = new Map((list || []).map((f) => [f.id, f]));
|
||||
const children = new Map();
|
||||
@@ -41,7 +81,12 @@ function buildFolderFlat(list) {
|
||||
children.get(key).push(f);
|
||||
}
|
||||
for (const arr of children.values()) {
|
||||
arr.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
|
||||
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 = [];
|
||||
@@ -70,13 +115,48 @@ function buildFolderFlat(list) {
|
||||
|
||||
const folderFlat = computed(() => buildFolderFlat(folders.value));
|
||||
|
||||
const bookmarksByFolderId = computed(() => {
|
||||
const map = new Map();
|
||||
for (const b of items.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 next = new Set(openFolderIds.value);
|
||||
if (next.has(folderId)) next.delete(folderId);
|
||||
else next.add(folderId);
|
||||
openFolderIds.value = next;
|
||||
}
|
||||
|
||||
function isFolderOpen(folderId) {
|
||||
// 搜索时强制展开(否则用户会误以为“没结果”)
|
||||
if (q.value.trim()) return true;
|
||||
return openFolderIds.value.has(folderId);
|
||||
}
|
||||
|
||||
function folderLabel(f, depth) {
|
||||
const pad = depth > 0 ? `${"—".repeat(Math.min(depth, 6))} ` : "";
|
||||
return `${pad}${f.name}`;
|
||||
}
|
||||
|
||||
const folderOptions = computed(() => [
|
||||
{ value: null, label: "(无文件夹)" },
|
||||
{ value: null, label: "未分组(根目录)" },
|
||||
...folderFlat.value.map((x) => ({ value: x.folder.id, label: folderLabel(x.folder, x.depth) }))
|
||||
]);
|
||||
|
||||
@@ -119,6 +199,9 @@ async function loadRemote() {
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// Ensure the tree is rendered before binding Sortable.
|
||||
await syncSortables();
|
||||
}
|
||||
|
||||
function loadLocal() {
|
||||
@@ -129,6 +212,7 @@ function loadLocal() {
|
||||
items.value = query
|
||||
? base.filter((b) => String(b.title || "").toLowerCase().includes(query) || String(b.url || "").toLowerCase().includes(query))
|
||||
: base;
|
||||
syncSortables();
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
@@ -139,15 +223,19 @@ async function createFolder() {
|
||||
if (loggedIn.value) {
|
||||
await apiFetch("/folders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ parentId: folderParentId.value ?? null, name, visibility: folderVisibility.value })
|
||||
body: JSON.stringify({ parentId: null, name, visibility: folderVisibility.value })
|
||||
});
|
||||
folderName.value = "";
|
||||
folderParentId.value = null;
|
||||
folderVisibility.value = "private";
|
||||
await loadRemote();
|
||||
folderModalOpen.value = false;
|
||||
} else {
|
||||
// Local folders are MVP-only for now
|
||||
folderName.value = "";
|
||||
folderParentId.value = null;
|
||||
folderVisibility.value = "private";
|
||||
folderModalOpen.value = false;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
@@ -158,7 +246,7 @@ function startEditFolder(f) {
|
||||
folderEditingId.value = f.id;
|
||||
editFolderName.value = f.name || "";
|
||||
editFolderVisibility.value = f.visibility || "private";
|
||||
editFolderParentId.value = f.parentId ?? null;
|
||||
editFolderParentId.value = null;
|
||||
}
|
||||
|
||||
function cancelEditFolder() {
|
||||
@@ -174,7 +262,7 @@ async function saveFolder(id) {
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
visibility: editFolderVisibility.value,
|
||||
parentId: editFolderParentId.value === id ? null : editFolderParentId.value ?? null
|
||||
parentId: null
|
||||
})
|
||||
});
|
||||
folderEditingId.value = "";
|
||||
@@ -186,37 +274,209 @@ async function saveFolder(id) {
|
||||
|
||||
async function removeFolder(id) {
|
||||
try {
|
||||
await apiFetch(`/folders/${id}`, { method: "DELETE" });
|
||||
// If current selection removed, reset
|
||||
if (loggedIn.value) {
|
||||
if (!confirm("确定删除该文件夹?文件夹内书签会移到『未分组』。")) return;
|
||||
await apiFetch(`/folders/${id}`, { method: "DELETE" });
|
||||
if (folderId.value === id) folderId.value = null;
|
||||
await loadRemote();
|
||||
return;
|
||||
}
|
||||
if (!confirm("确定删除该文件夹?文件夹内书签会移到『未分组』(本地)。")) return;
|
||||
deleteLocalFolder(id);
|
||||
if (folderId.value === id) folderId.value = null;
|
||||
await loadRemote();
|
||||
loadLocal();
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
const showBackToTop = ref(false);
|
||||
function onWindowScroll() {
|
||||
showBackToTop.value = (window.scrollY || 0) > 400;
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
|
||||
window.scrollTo({ top: 0, behavior: reduced ? "auto" : "smooth" });
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!title.value || !url.value) return;
|
||||
|
||||
if (loggedIn.value) {
|
||||
await apiFetch("/bookmarks", {
|
||||
try {
|
||||
if (loggedIn.value) {
|
||||
await apiFetch("/bookmarks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ folderId: folderId.value ?? null, title: title.value, url: url.value, visibility: visibility.value })
|
||||
});
|
||||
title.value = "";
|
||||
url.value = "";
|
||||
folderId.value = null;
|
||||
visibility.value = "public";
|
||||
await loadRemote();
|
||||
addModalOpen.value = false;
|
||||
} else {
|
||||
upsertLocalBookmark({ title: title.value, url: url.value, visibility: visibility.value, folderId: folderId.value ?? null });
|
||||
title.value = "";
|
||||
url.value = "";
|
||||
folderId.value = null;
|
||||
visibility.value = "public";
|
||||
loadLocal();
|
||||
addModalOpen.value = false;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function parentKeyFromDataset(v) {
|
||||
const s = String(v ?? "");
|
||||
return s ? s : null;
|
||||
}
|
||||
|
||||
async function persistFolderOrder(parentId, orderedIds) {
|
||||
// Optimistic update
|
||||
const nextOrder = new Map(orderedIds.map((id, idx) => [id, idx]));
|
||||
folders.value = (folders.value || []).map((f) => {
|
||||
if ((f.parentId ?? null) !== (parentId ?? null)) return f;
|
||||
const so = nextOrder.get(f.id);
|
||||
if (so === undefined) return f;
|
||||
return { ...f, sortOrder: so };
|
||||
});
|
||||
|
||||
try {
|
||||
await apiFetch("/folders/reorder", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ folderId: folderId.value ?? null, title: title.value, url: url.value, visibility: visibility.value })
|
||||
body: JSON.stringify({ parentId: parentId ?? null, orderedIds })
|
||||
});
|
||||
title.value = "";
|
||||
url.value = "";
|
||||
folderId.value = null;
|
||||
await loadRemote();
|
||||
} else {
|
||||
upsertLocalBookmark({ title: title.value, url: url.value, visibility: visibility.value, folderId: folderId.value ?? null });
|
||||
title.value = "";
|
||||
url.value = "";
|
||||
folderId.value = null;
|
||||
loadLocal();
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
await loadRemote();
|
||||
}
|
||||
}
|
||||
|
||||
async function persistBookmarkOrder(folderId, orderedIds) {
|
||||
// Optimistic update
|
||||
const nextOrder = new Map(orderedIds.map((id, idx) => [id, idx]));
|
||||
items.value = (items.value || []).map((b) => {
|
||||
if ((b.folderId ?? null) !== (folderId ?? null)) return b;
|
||||
const so = nextOrder.get(b.id);
|
||||
if (so === undefined) return b;
|
||||
return { ...b, sortOrder: so };
|
||||
});
|
||||
|
||||
try {
|
||||
await apiFetch("/bookmarks/reorder", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ folderId: folderId ?? null, orderedIds })
|
||||
});
|
||||
await loadRemote();
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
await loadRemote();
|
||||
}
|
||||
}
|
||||
|
||||
function destroySortables() {
|
||||
if (folderSortable) {
|
||||
folderSortable.destroy();
|
||||
folderSortable = null;
|
||||
}
|
||||
for (const s of bookmarkSortables.values()) s.destroy();
|
||||
bookmarkSortables.clear();
|
||||
}
|
||||
|
||||
async function syncSortables() {
|
||||
destroySortables();
|
||||
if (!loggedIn.value) return;
|
||||
if (q.value.trim()) return; // search mode: avoid accidental reorder
|
||||
|
||||
await nextTick();
|
||||
const root = treeEl.value;
|
||||
if (!root) return;
|
||||
|
||||
// Folder sorting (same parent only)
|
||||
folderSortable = new Sortable(root, {
|
||||
animation: 150,
|
||||
handle: ".bb-dragHint--folder",
|
||||
draggable: ".bb-myFolder[data-folder-id]",
|
||||
forceFallback: true,
|
||||
fallbackOnBody: true,
|
||||
delay: 150,
|
||||
delayOnTouchOnly: true,
|
||||
touchStartThreshold: 3,
|
||||
onStart: () => setDragging(true),
|
||||
onMove: (evt) => {
|
||||
const dragged = evt.dragged;
|
||||
const related = evt.related;
|
||||
if (!dragged?.dataset?.folderId || !related?.dataset?.folderId) return false;
|
||||
// Only allow sorting among siblings with the same parent
|
||||
return (dragged.dataset.parentId || "") === (related.dataset.parentId || "");
|
||||
},
|
||||
onEnd: async (evt) => {
|
||||
const item = evt.item;
|
||||
const parentId = parentKeyFromDataset(item?.dataset?.parentId);
|
||||
const allEls = Array.from(root.querySelectorAll(`.bb-myFolder[data-folder-id]`));
|
||||
const els = allEls.filter((el) => parentKeyFromDataset(el?.dataset?.parentId) === (parentId ?? null));
|
||||
const orderedIds = els.map((el) => el.dataset.folderId).filter(Boolean);
|
||||
if (orderedIds.length) await persistFolderOrder(parentId, orderedIds);
|
||||
setDragging(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Bookmark sorting per visible folder list
|
||||
const lists = Array.from(root.querySelectorAll("ul[data-bookmark-list='1']"));
|
||||
for (const ul of lists) {
|
||||
const folderKey = ul.getAttribute("data-folder-id") || "";
|
||||
const folderId = folderKey && folderKey !== "__ROOT__" ? folderKey : null;
|
||||
|
||||
// local push animation helper (gives a "squeezed/pushed" feel)
|
||||
let pushedEl = null;
|
||||
let pushTimer = null;
|
||||
function push(el) {
|
||||
if (!el) return;
|
||||
if (pushedEl && pushedEl !== el) pushedEl.classList.remove("bb-sortPush");
|
||||
pushedEl = el;
|
||||
pushedEl.classList.add("bb-sortPush");
|
||||
if (pushTimer) clearTimeout(pushTimer);
|
||||
pushTimer = setTimeout(() => {
|
||||
if (pushedEl) pushedEl.classList.remove("bb-sortPush");
|
||||
pushedEl = null;
|
||||
pushTimer = null;
|
||||
}, 180);
|
||||
}
|
||||
|
||||
const s = new Sortable(ul, {
|
||||
animation: 220,
|
||||
easing: "cubic-bezier(0.2, 0.8, 0.2, 1)",
|
||||
handle: ".bb-dragHint--bookmark",
|
||||
draggable: "li[data-bookmark-id]",
|
||||
forceFallback: true,
|
||||
fallbackOnBody: true,
|
||||
delay: 150,
|
||||
delayOnTouchOnly: true,
|
||||
touchStartThreshold: 3,
|
||||
invertSwap: true,
|
||||
swapThreshold: 0.75,
|
||||
onStart: () => setDragging(true),
|
||||
onMove: (evt) => {
|
||||
// Provide a subtle "pushed" feedback on the neighbor being swapped.
|
||||
push(evt.related);
|
||||
return true;
|
||||
},
|
||||
onEnd: async () => {
|
||||
const orderedIds = Array.from(ul.querySelectorAll("li[data-bookmark-id]")).map((li) => li.dataset.bookmarkId).filter(Boolean);
|
||||
if (orderedIds.length) await persistBookmarkOrder(folderId, orderedIds);
|
||||
setDragging(false);
|
||||
}
|
||||
});
|
||||
bookmarkSortables.set(folderKey, s);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!confirm("确定删除该书签?")) return;
|
||||
if (loggedIn.value) {
|
||||
await apiFetch(`/bookmarks/${id}`, { method: "DELETE" });
|
||||
await loadRemote();
|
||||
@@ -284,15 +544,35 @@ async function clearSearch() {
|
||||
onMounted(() => {
|
||||
if (loggedIn.value) loadRemote();
|
||||
else loadLocal();
|
||||
|
||||
onWindowScroll();
|
||||
window.addEventListener("scroll", onWindowScroll, { passive: true });
|
||||
});
|
||||
|
||||
watch([() => loggedIn.value, () => q.value, () => openFolderIds.value], () => {
|
||||
// keep Sortable instances in sync with current DOM (open folders)
|
||||
syncSortables();
|
||||
}, { flush: "post" });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroySortables();
|
||||
window.removeEventListener("scroll", onWindowScroll);
|
||||
});
|
||||
</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 class="bb-row" style="justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap;">
|
||||
<div>
|
||||
<h1 style="margin: 0;">我的书签</h1>
|
||||
<div class="bb-muted" style="margin-top: 4px;">像课程进度一样管理你的链接:分组、搜索、可公开。</div>
|
||||
</div>
|
||||
|
||||
<div class="bb-row" style="gap: 10px; flex-wrap: wrap;">
|
||||
<button class="bb-btn" type="button" @click="addModalOpen = true">添加书签</button>
|
||||
<button v-if="loggedIn" class="bb-btn bb-btn--secondary" type="button" @click="folderModalOpen = true">新建文件夹</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -304,67 +584,37 @@ onMounted(() => {
|
||||
<div class="sectionTitle">快速搜索</div>
|
||||
<div class="searchRow" style="margin-top: 10px;">
|
||||
<input v-model="q" class="bb-input" placeholder="搜索标题/链接" @keyup.enter="reload" />
|
||||
<button class="bb-btn bb-btn--secondary" :disabled="loading" @click="reload">搜索</button>
|
||||
<button class="bb-btn bb-btn--secondary" :disabled="loading" @click="clearSearch">清除</button>
|
||||
<div class="searchActions">
|
||||
<button class="bb-btn bb-btn--secondary bb-btn--soft" :disabled="loading" @click="reload">搜索</button>
|
||||
<button class="bb-btn bb-btn--secondary bb-btn--soft" :disabled="loading" @click="clearSearch">清除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bb-card" style="margin-top: 12px;">
|
||||
<div class="sectionTitle">添加书签</div>
|
||||
<div class="form" style="margin-top: 10px;">
|
||||
<BbModal v-model="addModalOpen" title="添加书签">
|
||||
<div class="bb-modalForm">
|
||||
<input v-model="title" class="bb-input" placeholder="标题" />
|
||||
<input v-model="url" class="bb-input" placeholder="链接(https://...)" />
|
||||
<BbSelect v-model="folderId" :options="folderOptions" />
|
||||
<BbSelect v-model="visibility" :options="visibilityOptions" />
|
||||
<button class="bb-btn" @click="add">添加</button>
|
||||
<div class="bb-row" style="justify-content: flex-end; gap: 10px;">
|
||||
<button class="bb-btn bb-btn--secondary" type="button" @click="addModalOpen = false">取消</button>
|
||||
<button class="bb-btn" type="button" @click="add">添加</button>
|
||||
</div>
|
||||
<div class="bb-muted" style="margin-top: 8px;">小贴士:标题可写“为什么收藏/怎么用”,以后复习更快。</div>
|
||||
</div>
|
||||
<div class="bb-muted" style="margin-top: 10px;">
|
||||
小贴士:标题可写“为什么收藏/怎么用”,以后复习更快。
|
||||
</div>
|
||||
</div>
|
||||
</BbModal>
|
||||
|
||||
<div v-if="loggedIn" class="bb-card" style="margin: 12px 0;">
|
||||
<div class="bb-row">
|
||||
<div class="sectionTitle">文件夹(云端)</div>
|
||||
</div>
|
||||
<div class="bb-row" style="margin-top: 10px;">
|
||||
<BbModal v-if="loggedIn" v-model="folderModalOpen" title="新建文件夹">
|
||||
<div class="bb-modalForm">
|
||||
<input v-model="folderName" class="bb-input" placeholder="新文件夹名称" />
|
||||
<BbSelect v-model="folderParentId" :options="folderRootOptions" />
|
||||
<BbSelect v-model="folderVisibility" :options="folderVisibilityOptions" />
|
||||
<button class="bb-btn" @click="createFolder">创建文件夹</button>
|
||||
</div>
|
||||
|
||||
<div v-if="folders.length" class="folderList">
|
||||
<div v-for="x in folderFlat" :key="x.folder.id" class="folderRow">
|
||||
<template v-if="folderEditingId !== x.folder.id">
|
||||
<div class="folderName" :style="{ paddingLeft: `${x.depth * 14}px` }">{{ x.folder.name }}</div>
|
||||
<div class="folderMeta">
|
||||
{{ x.folder.visibility === 'public' ? '公开' : '私有' }} · {{ x.folder.parentId ? '子文件夹' : '根文件夹' }}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="bb-btn bb-btn--secondary" @click="startEditFolder(x.folder)">编辑</button>
|
||||
<button class="bb-btn bb-btn--danger" @click="removeFolder(x.folder.id)">删除</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<input v-model="editFolderName" class="bb-input" placeholder="文件夹名称" />
|
||||
<BbSelect
|
||||
v-model="editFolderParentId"
|
||||
:options="folderRootOptionsForEdit(x.folder.id)"
|
||||
/>
|
||||
<BbSelect v-model="editFolderVisibility" :options="folderVisibilityOptions" />
|
||||
<div class="actions">
|
||||
<button class="bb-btn" @click="saveFolder(x.folder.id)">保存</button>
|
||||
<button class="bb-btn bb-btn--secondary" @click="cancelEditFolder">取消</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="bb-row" style="justify-content: flex-end; gap: 10px;">
|
||||
<button class="bb-btn bb-btn--secondary" type="button" @click="folderModalOpen = false">取消</button>
|
||||
<button class="bb-btn" type="button" @click="createFolder">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="bb-empty" style="margin-top: 12px;">
|
||||
<div style="font-weight: 700;">还没有文件夹</div>
|
||||
<div class="bb-muted" style="margin-top: 6px;">可以先创建一个根文件夹,或为它选择父级形成层级结构。</div>
|
||||
</div>
|
||||
</div>
|
||||
</BbModal>
|
||||
|
||||
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p>
|
||||
<p v-if="loading" class="bb-muted">加载中…</p>
|
||||
@@ -374,29 +624,158 @@ onMounted(() => {
|
||||
<div class="bb-muted" style="margin-top: 6px;">用上面的输入框快速添加一个吧。</div>
|
||||
</div>
|
||||
|
||||
<ul v-if="!loading && !error && items.length" class="list">
|
||||
<li v-for="b in items" :key="b.id" class="bb-card bb-card--interactive">
|
||||
<div v-if="editingId !== b.id">
|
||||
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
|
||||
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div>
|
||||
<div class="actions">
|
||||
<button class="bb-btn bb-btn--secondary" @click="startEdit(b)">编辑</button>
|
||||
<button class="bb-btn bb-btn--danger" @click="remove(b.id)">删除</button>
|
||||
</div>
|
||||
<div ref="treeEl" v-if="!loading && !error && items.length" class="bb-myTree" style="margin-top: 12px;">
|
||||
<!-- Root group -->
|
||||
<div class="bb-myFolder" :class="isFolderOpen('ROOT') ? 'is-open' : ''">
|
||||
<div class="bb-myFolderHeaderRow">
|
||||
<button
|
||||
type="button"
|
||||
class="bb-myFolderHeader"
|
||||
:aria-expanded="isFolderOpen('ROOT') ? 'true' : 'false'"
|
||||
@click="toggleFolder('ROOT')"
|
||||
>
|
||||
<span class="name">未分组</span>
|
||||
<span class="meta">{{ folderCount(null) }} 条</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isFolderOpen('ROOT')" class="bb-myFolderBody">
|
||||
<ul class="list" data-bookmark-list="1" data-folder-id="__ROOT__">
|
||||
<li
|
||||
v-for="b in (bookmarksByFolderId.get(null) || [])"
|
||||
:key="b.id"
|
||||
:data-bookmark-id="b.id"
|
||||
class="bb-card bb-card--interactive"
|
||||
:class="editingId !== b.id ? 'bb-clickCard' : ''"
|
||||
role="link"
|
||||
:tabindex="editingId !== b.id ? 0 : -1"
|
||||
@click="onBookmarkActivate(b)"
|
||||
@keydown.enter.prevent="onBookmarkActivate(b)"
|
||||
@keydown.space.prevent="onBookmarkActivate(b)"
|
||||
>
|
||||
<div v-if="editingId !== b.id">
|
||||
<div class="bb-row" style="justify-content: space-between; gap: 10px;">
|
||||
<div class="bb-bookmarkTitle bb-bookmarkTitleRow" style="font-weight: 700; color: var(--bb-text);">{{ b.title }}</div>
|
||||
<span
|
||||
v-if="loggedIn"
|
||||
class="bb-dragHint bb-dragHint--bookmark"
|
||||
title="拖动排序"
|
||||
@click.stop.prevent
|
||||
>⋮⋮</span>
|
||||
</div>
|
||||
<div class="bb-muted bb-oneLineEllipsis" style="margin-top: 6px;">{{ b.url }}</div>
|
||||
<div class="actions">
|
||||
<button class="bb-btn bb-btn--secondary" @click.stop="startEdit(b)">编辑</button>
|
||||
<button class="bb-btn bb-btn--danger" @click.stop="remove(b.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="edit">
|
||||
<input v-model="editTitle" class="bb-input" placeholder="标题" />
|
||||
<input v-model="editUrl" class="bb-input" placeholder="链接" />
|
||||
<BbSelect v-model="editFolderId" :options="folderOptions" />
|
||||
<BbSelect v-model="editVisibility" :options="visibilityOptions" />
|
||||
<div class="actions">
|
||||
<button class="bb-btn" @click="saveEdit(b.id)">保存</button>
|
||||
<button class="bb-btn bb-btn--secondary" @click="cancelEdit">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder groups -->
|
||||
<div
|
||||
v-for="x in folderFlat"
|
||||
:key="x.folder.id"
|
||||
class="bb-myFolder"
|
||||
:class="isFolderOpen(x.folder.id) ? 'is-open' : ''"
|
||||
:style="{ paddingLeft: '0px' }"
|
||||
:data-folder-id="x.folder.id"
|
||||
:data-parent-id="x.folder.parentId ?? ''"
|
||||
>
|
||||
<div
|
||||
class="bb-myFolderHeaderRow"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="bb-myFolderHeader"
|
||||
:aria-expanded="isFolderOpen(x.folder.id) ? 'true' : 'false'"
|
||||
@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-folderDelete"
|
||||
type="button"
|
||||
title="删除文件夹(书签会移到未分组)"
|
||||
@click.stop="removeFolder(x.folder.id)"
|
||||
>删除</button>
|
||||
|
||||
<span
|
||||
v-if="loggedIn"
|
||||
class="bb-dragHint bb-dragHint--folder"
|
||||
title="拖动排序"
|
||||
>
|
||||
⋮⋮
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="edit">
|
||||
<input v-model="editTitle" class="bb-input" placeholder="标题" />
|
||||
<input v-model="editUrl" class="bb-input" placeholder="链接" />
|
||||
<BbSelect v-model="editFolderId" :options="folderOptions" />
|
||||
<BbSelect v-model="editVisibility" :options="visibilityOptions" />
|
||||
<div class="actions">
|
||||
<button class="bb-btn" @click="saveEdit(b.id)">保存</button>
|
||||
<button class="bb-btn bb-btn--secondary" @click="cancelEdit">取消</button>
|
||||
</div>
|
||||
<div v-if="isFolderOpen(x.folder.id)" class="bb-myFolderBody">
|
||||
<ul class="list" data-bookmark-list="1" :data-folder-id="x.folder.id">
|
||||
<li
|
||||
v-for="b in (bookmarksByFolderId.get(x.folder.id) || [])"
|
||||
:key="b.id"
|
||||
:data-bookmark-id="b.id"
|
||||
class="bb-card bb-card--interactive"
|
||||
:class="editingId !== b.id ? 'bb-clickCard' : ''"
|
||||
role="link"
|
||||
:tabindex="editingId !== b.id ? 0 : -1"
|
||||
@click="onBookmarkActivate(b)"
|
||||
@keydown.enter.prevent="onBookmarkActivate(b)"
|
||||
@keydown.space.prevent="onBookmarkActivate(b)"
|
||||
>
|
||||
<div v-if="editingId !== b.id">
|
||||
<div class="bb-row" style="justify-content: space-between; gap: 10px;">
|
||||
<div class="bb-bookmarkTitle bb-bookmarkTitleRow" style="font-weight: 700; color: var(--bb-text);">{{ b.title }}</div>
|
||||
<span
|
||||
v-if="loggedIn"
|
||||
class="bb-dragHint bb-dragHint--bookmark"
|
||||
title="拖动排序"
|
||||
@click.stop.prevent
|
||||
>⋮⋮</span>
|
||||
</div>
|
||||
<div class="bb-muted bb-oneLineEllipsis" style="margin-top: 6px;">{{ b.url }}</div>
|
||||
<div class="actions">
|
||||
<button class="bb-btn bb-btn--secondary" @click.stop="startEdit(b)">编辑</button>
|
||||
<button class="bb-btn bb-btn--danger" @click.stop="remove(b.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="edit">
|
||||
<input v-model="editTitle" class="bb-input" placeholder="标题" />
|
||||
<input v-model="editUrl" class="bb-input" placeholder="链接" />
|
||||
<BbSelect v-model="editFolderId" :options="folderOptions" />
|
||||
<BbSelect v-model="editVisibility" :options="visibilityOptions" />
|
||||
<div class="actions">
|
||||
<button class="bb-btn" @click="saveEdit(b.id)">保存</button>
|
||||
<button class="bb-btn bb-btn--secondary" @click="cancelEdit">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="showBackToTop"
|
||||
type="button"
|
||||
class="bb-btn bb-btn--secondary bb-backTop"
|
||||
@click="scrollToTop"
|
||||
>回到顶部</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -405,10 +784,15 @@ onMounted(() => {
|
||||
.form { display: grid; gap: 10px; grid-template-columns: 1fr; margin: 12px 0; }
|
||||
@media (min-width: 768px) { .form { grid-template-columns: 2fr 3fr 2fr 1fr auto; align-items: center; } }
|
||||
.searchRow { display: grid; gap: 10px; grid-template-columns: 1fr; margin: 12px 0; }
|
||||
@media (min-width: 768px) { .searchRow { grid-template-columns: 1fr auto auto; align-items: center; } }
|
||||
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 12px; }
|
||||
@media (min-width: 768px) { .list { grid-template-columns: 1fr 1fr; } }
|
||||
.searchActions { display: grid; gap: 10px; grid-template-columns: 1fr 1fr; }
|
||||
@media (min-width: 768px) {
|
||||
.searchRow { grid-template-columns: 1fr auto; align-items: center; }
|
||||
.searchActions { display: flex; gap: 10px; }
|
||||
}
|
||||
.list { list-style: none; padding: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; margin-top: 12px; }
|
||||
@media (min-width: 768px) { .list { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.title { color: var(--bb-text); font-weight: 700; text-decoration: none; }
|
||||
.bb-clickCard { cursor: pointer; }
|
||||
.actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.edit { display: grid; gap: 10px; }
|
||||
.sectionTitle { font-weight: 800; }
|
||||
@@ -417,4 +801,64 @@ onMounted(() => {
|
||||
@media (min-width: 768px) { .folderRow { grid-template-columns: 2fr 1fr 2fr; align-items: center; } }
|
||||
.folderName { font-weight: 700; }
|
||||
.folderMeta { font-size: 12px; color: #475569; }
|
||||
|
||||
.bb-myFolder { margin-top: 10px; }
|
||||
.bb-myFolderHeaderRow { display: flex; gap: 8px; align-items: center; }
|
||||
.bb-myFolderHeader {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
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-myFolderHeader:hover { background: rgba(255,255,255,0.6); }
|
||||
.bb-myFolderHeader .name { font-weight: 900; color: var(--bb-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bb-myFolderHeader .meta { font-size: 12px; color: rgba(19, 78, 74, 0.72); }
|
||||
.bb-myFolderBody { margin-top: 10px; }
|
||||
|
||||
/* Sticky open folder header (keeps the row visible while scrolling long lists) */
|
||||
.bb-myFolder.is-open > .bb-myFolderHeaderRow {
|
||||
position: sticky;
|
||||
top: 10px;
|
||||
z-index: 30;
|
||||
padding: 4px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255,255,255,0.72);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255,255,255,0.55);
|
||||
}
|
||||
|
||||
.bb-folderDelete{
|
||||
padding: 8px 10px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.bb-backTop{
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
.bb-dragHint {
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
padding: 6px 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255,255,255,0.45);
|
||||
background: rgba(255,255,255,0.25);
|
||||
color: rgba(15, 23, 42, 0.58);
|
||||
}
|
||||
.bb-dragHint:active { cursor: grabbing; }
|
||||
|
||||
.bb-modalForm { display: grid; gap: 10px; }
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,79 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import BbSelect from "../components/BbSelect.vue";
|
||||
import BbModal from "../components/BbModal.vue";
|
||||
import { apiFetch, tokenRef } from "../lib/api";
|
||||
|
||||
const q = ref("");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const items = ref([]);
|
||||
|
||||
const loggedIn = computed(() => Boolean(tokenRef.value));
|
||||
|
||||
const addTitle = ref("");
|
||||
const addUrl = ref("");
|
||||
const addVisibility = ref("public");
|
||||
const addFolderId = ref(null);
|
||||
const addBusy = ref(false);
|
||||
const addStatus = ref("");
|
||||
const addModalOpen = ref(false);
|
||||
|
||||
const folders = ref([]);
|
||||
const foldersLoading = ref(false);
|
||||
|
||||
const folderOptions = computed(() => [
|
||||
{ value: null, label: "(无文件夹)" },
|
||||
...folders.value.map((f) => ({ value: f.id, label: f.name }))
|
||||
]);
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: "public", label: "公开" },
|
||||
{ value: "private", label: "私有" }
|
||||
];
|
||||
|
||||
async function loadFolders() {
|
||||
if (!loggedIn.value) return;
|
||||
foldersLoading.value = true;
|
||||
try {
|
||||
folders.value = await apiFetch("/folders");
|
||||
} finally {
|
||||
foldersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addBookmark() {
|
||||
const title = addTitle.value.trim();
|
||||
const url = addUrl.value.trim();
|
||||
if (!title || !url) return;
|
||||
|
||||
addBusy.value = true;
|
||||
addStatus.value = "";
|
||||
error.value = "";
|
||||
try {
|
||||
await apiFetch("/bookmarks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
folderId: addFolderId.value ?? null,
|
||||
title,
|
||||
url,
|
||||
visibility: addVisibility.value
|
||||
})
|
||||
});
|
||||
addTitle.value = "";
|
||||
addUrl.value = "";
|
||||
addFolderId.value = null;
|
||||
addVisibility.value = "public";
|
||||
addStatus.value = "已添加";
|
||||
addModalOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
addBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
@@ -20,14 +87,21 @@ async function load() {
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
onMounted(loadFolders);
|
||||
</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 class="bb-row" style="justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap;">
|
||||
<div>
|
||||
<h1 style="margin: 0;">公开书签</h1>
|
||||
<div class="bb-muted" style="margin-top: 4px;">无需登录即可浏览:来自用户设置为“公开”的书签。</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loggedIn" class="bb-row" style="gap: 10px; flex-wrap: wrap;">
|
||||
<button class="bb-btn" type="button" @click="addModalOpen = true">添加书签</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,17 +119,45 @@ onMounted(load);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BbModal v-if="loggedIn" v-model="addModalOpen" title="添加书签">
|
||||
<div class="bb-publicAdd" style="margin-top: 2px;">
|
||||
<input v-model="addTitle" class="bb-input" placeholder="标题" />
|
||||
<input v-model="addUrl" class="bb-input" placeholder="链接(https://...)" />
|
||||
<BbSelect
|
||||
v-model="addFolderId"
|
||||
:options="folderOptions"
|
||||
:disabled="foldersLoading"
|
||||
placeholder="选择文件夹"
|
||||
/>
|
||||
<BbSelect v-model="addVisibility" :options="visibilityOptions" />
|
||||
<div class="bb-row" style="justify-content: flex-end; gap: 10px;">
|
||||
<button class="bb-btn bb-btn--secondary" type="button" @click="addModalOpen = false">取消</button>
|
||||
<button class="bb-btn" type="button" :disabled="addBusy" @click="addBookmark">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</BbModal>
|
||||
|
||||
<p v-if="addStatus" class="bb-alert bb-alert--ok" style="margin-top: 12px;">{{ addStatus }}</p>
|
||||
|
||||
<ul v-if="!loading && !error && items.length" class="bb-publicList">
|
||||
<li v-for="b in items" :key="b.id" class="bb-card bb-card--interactive">
|
||||
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
|
||||
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div>
|
||||
<li v-for="b in items" :key="b.id" class="bb-card bb-card--interactive bb-clickCard">
|
||||
<a :href="b.url" target="_blank" rel="noopener" class="bb-cardLink">
|
||||
<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>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.title { color: var(--bb-text); font-weight: 900; text-decoration: none; }
|
||||
.bb-publicList { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 12px; }
|
||||
@media (min-width: 768px) { .bb-publicList { grid-template-columns: 1fr 1fr; } }
|
||||
|
||||
.bb-publicAdd { display: grid; gap: 10px; grid-template-columns: 1fr; }
|
||||
|
||||
|
||||
.bb-clickCard { padding: 0; }
|
||||
.bb-cardLink { display: block; padding: 12px; text-decoration: none; color: inherit; }
|
||||
.bb-cardLink:hover .bb-bookmarkTitle { color: var(--bb-cta); }
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,8 @@ import PublicPage from "./pages/PublicPage.vue";
|
||||
import LoginPage from "./pages/LoginPage.vue";
|
||||
import MyPage from "./pages/MyPage.vue";
|
||||
import ImportExportPage from "./pages/ImportExportPage.vue";
|
||||
import { tokenRef } from "./lib/api";
|
||||
import AdminPage from "./pages/AdminPage.vue";
|
||||
import { ensureMe, tokenRef } from "./lib/api";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -11,11 +12,12 @@ export const router = createRouter({
|
||||
{ path: "/", component: PublicPage },
|
||||
{ path: "/login", component: LoginPage },
|
||||
{ path: "/my", component: MyPage },
|
||||
{ path: "/import", component: ImportExportPage }
|
||||
{ path: "/import", component: ImportExportPage },
|
||||
{ path: "/admin", component: AdminPage }
|
||||
]
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
router.beforeEach(async (to) => {
|
||||
const loggedIn = Boolean(tokenRef.value);
|
||||
|
||||
// 主页(/)永远是公共首页;不因登录态自动跳转
|
||||
@@ -28,5 +30,12 @@ router.beforeEach((to) => {
|
||||
return { path: "/login", query: { next: to.fullPath } };
|
||||
}
|
||||
|
||||
// 管理界面:仅管理员可见
|
||||
if (to.path.startsWith("/admin")) {
|
||||
if (!loggedIn) return { path: "/login", query: { next: to.fullPath } };
|
||||
const me = await ensureMe();
|
||||
if (!me || me.role !== "admin") return { path: "/my" };
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -31,6 +31,23 @@
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Hide scrollbars but keep scrolling behavior */
|
||||
body {
|
||||
-ms-overflow-style: none; /* IE/Edge legacy */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
@@ -41,6 +58,9 @@ body {
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -137,6 +157,10 @@ input:focus-visible {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bb-selectWrap.is-open {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.bb-selectWrap--sm .bb-selectTrigger {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
@@ -182,7 +206,7 @@ input:focus-visible {
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
z-index: 10000;
|
||||
border-radius: 18px;
|
||||
padding: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.65);
|
||||
@@ -193,6 +217,14 @@ input:focus-visible {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bb-selectMenu--portal {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: auto;
|
||||
z-index: 2147483000;
|
||||
}
|
||||
|
||||
.bb-selectOption {
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
@@ -226,7 +258,7 @@ input:focus-visible {
|
||||
}
|
||||
|
||||
.bb-btn {
|
||||
padding: 10px 12px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
|
||||
@@ -235,6 +267,14 @@ input:focus-visible {
|
||||
transition: transform 120ms ease, filter 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
.bb-oneLineEllipsis{
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bb-btn:hover {
|
||||
filter: brightness(1.03);
|
||||
}
|
||||
@@ -249,6 +289,50 @@ input:focus-visible {
|
||||
color: var(--bb-text);
|
||||
}
|
||||
|
||||
/* Slightly stronger secondary background (better contrast on light cards) */
|
||||
.bb-btn--secondary.bb-btn--soft {
|
||||
background: rgba(19, 78, 74, 0.10);
|
||||
border-color: rgba(19, 78, 74, 0.16);
|
||||
}
|
||||
|
||||
/* Bookmark title: single line with ellipsis */
|
||||
.bb-bookmarkTitle {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* When title sits in a flex row, allow it to shrink */
|
||||
.bb-bookmarkTitleRow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* SortableJS: nicer drag visuals */
|
||||
.sortable-ghost {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
opacity: 0.98;
|
||||
transform: rotate(0.4deg) scale(1.01);
|
||||
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
/* "Pushed" neighbor feedback when swapping */
|
||||
.bb-sortPush {
|
||||
transform: translateX(28px) translateY(-2px) scale(0.985);
|
||||
transition: transform 220ms cubic-bezier(0.2, 0.9, 0.2, 1);
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.bb-sortPush {
|
||||
transform: translateX(18px) translateY(-1px) scale(0.99);
|
||||
}
|
||||
}
|
||||
|
||||
.bb-btn--danger {
|
||||
border-color: #fecaca;
|
||||
background: #fee2e2;
|
||||
@@ -267,6 +351,13 @@ input:focus-visible {
|
||||
padding: 12px;
|
||||
background: rgba(255,255,255,0.55);
|
||||
backdrop-filter: blur(12px);
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Avoid long content (e.g. URLs) forcing horizontal overflow */
|
||||
.bb-card > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bb-card--interactive {
|
||||
|
||||
Reference in New Issue
Block a user