提交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,12 +1,12 @@
export default [
{
files: ["**/*.js", "**/*.vue"],
languageOptions: {
ecmaVersion: 2024,
sourceType: "module"
},
rules: {
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
}
];
export default [
{
files: ["**/*.js", "**/*.vue"],
languageOptions: {
ecmaVersion: 2024,
sourceType: "module"
},
rules: {
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
}
];

View File

@@ -11,7 +11,7 @@
"lint": "eslint ."
},
"dependencies": {
"@browser-bookmark/shared": "0.1.0",
"@browser-bookmark/shared": "file:../../packages/shared",
"sortablejs": "^1.15.6",
"vue": "^3.5.24",
"vue-router": "^4.5.1"

View File

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

View File

@@ -1,153 +1,153 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
const props = defineProps({
modelValue: { type: [String, Number, Boolean, Object, null], default: null },
options: { type: Array, required: true },
disabled: { type: Boolean, default: false },
placeholder: { type: String, default: "请选择" },
size: { type: String, default: "md" } // md | sm
});
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 ?? "");
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;
if (open.value) close();
else openMenu();
}
function choose(value, isDisabled) {
if (props.disabled || isDisabled) return;
emit("update:modelValue", value);
close();
}
function onKeydownTrigger(e) {
if (props.disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle();
}
if (e.key === "Escape") {
e.preventDefault();
close();
}
}
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' : '',
open ? 'is-open' : ''
]"
>
<button
type="button"
class="bb-selectTrigger"
:disabled="disabled"
:aria-expanded="open ? 'true' : 'false'"
@click="toggle"
@keydown="onKeydownTrigger"
ref="triggerEl"
>
<span class="bb-selectValue" :class="!label ? 'bb-selectPlaceholder' : ''">
{{ label || placeholder }}
</span>
<span class="bb-selectChevron" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6" />
</svg>
</span>
</button>
<teleport to="body">
<div
v-if="open"
ref="menuEl"
class="bb-selectMenu bb-selectMenu--portal"
role="listbox"
:style="menuStyle"
>
<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>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
const props = defineProps({
modelValue: { type: [String, Number, Boolean, Object, null], default: null },
options: { type: Array, required: true },
disabled: { type: Boolean, default: false },
placeholder: { type: String, default: "请选择" },
size: { type: String, default: "md" } // md | sm
});
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 ?? "");
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;
if (open.value) close();
else openMenu();
}
function choose(value, isDisabled) {
if (props.disabled || isDisabled) return;
emit("update:modelValue", value);
close();
}
function onKeydownTrigger(e) {
if (props.disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle();
}
if (e.key === "Escape") {
e.preventDefault();
close();
}
}
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' : '',
open ? 'is-open' : ''
]"
>
<button
type="button"
class="bb-selectTrigger"
:disabled="disabled"
:aria-expanded="open ? 'true' : 'false'"
@click="toggle"
@keydown="onKeydownTrigger"
ref="triggerEl"
>
<span class="bb-selectValue" :class="!label ? 'bb-selectPlaceholder' : ''">
{{ label || placeholder }}
</span>
<span class="bb-selectChevron" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6" />
</svg>
</span>
</button>
<teleport to="body">
<div
v-if="open"
ref="menuEl"
class="bb-selectMenu bb-selectMenu--portal"
role="listbox"
:style="menuStyle"
>
<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>

View File

@@ -1,90 +1,90 @@
import { ref } from "vue";
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 || "";
}
export function setToken(token) {
const next = 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 || "";
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");
if (!(options.body instanceof FormData) && options.body != null) {
headers.set("Content-Type", "application/json");
}
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
const contentType = res.headers.get("content-type") || "";
const isJson = contentType.includes("application/json");
const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => "");
if (!res.ok) {
const message = payload?.message || `HTTP ${res.status}`;
const err = new Error(message);
err.status = res.status;
err.payload = payload;
throw err;
}
return payload;
}
import { ref } from "vue";
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 || "";
}
export function setToken(token) {
const next = 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 || "";
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");
if (!(options.body instanceof FormData) && options.body != null) {
headers.set("Content-Type", "application/json");
}
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
const contentType = res.headers.get("content-type") || "";
const isJson = contentType.includes("application/json");
const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => "");
if (!res.ok) {
const message = payload?.message || `HTTP ${res.status}`;
const err = new Error(message);
err.status = res.status;
err.payload = payload;
throw err;
}
return payload;
}

View File

@@ -1,290 +1,290 @@
import { computeUrlHash, normalizeUrl, parseNetscapeBookmarkHtml } from "@browser-bookmark/shared";
const KEY = "bb_local_state_v1";
function nowIso() {
return new Date().toISOString();
}
function ensureBookmarkHashes(bookmark) {
if (!bookmark) return bookmark;
const url = bookmark.url || "";
const urlNormalized = bookmark.urlNormalized || normalizeUrl(url);
const urlHash = bookmark.urlHash || computeUrlHash(urlNormalized);
bookmark.urlNormalized = urlNormalized;
bookmark.urlHash = urlHash;
return bookmark;
}
export function loadLocalState() {
try {
const parsed = JSON.parse(localStorage.getItem(KEY) || "") || { folders: [], bookmarks: [] };
parsed.folders = parsed.folders || [];
parsed.bookmarks = (parsed.bookmarks || []).map((b) => ensureBookmarkHashes(b));
return parsed;
} catch {
return { folders: [], bookmarks: [] };
}
}
export function saveLocalState(state) {
localStorage.setItem(KEY, JSON.stringify(state));
}
export function listLocalBookmarks({ includeDeleted = false } = {}) {
const state = loadLocalState();
const all = state.bookmarks || [];
return includeDeleted ? all : all.filter((b) => !b.deletedAt);
}
export function upsertLocalBookmark({ title, url, visibility = "public", folderId = null, source = "manual" }) {
const state = loadLocalState();
const now = nowIso();
const urlNormalized = normalizeUrl(url || "");
const urlHash = computeUrlHash(urlNormalized);
// Dedupe: same urlHash and not deleted -> update LWW
const existing = (state.bookmarks || []).find((b) => !b.deletedAt && (b.urlHash || "") === urlHash);
if (existing) {
ensureBookmarkHashes(existing);
existing.title = title || existing.title;
existing.url = url || existing.url;
existing.urlNormalized = urlNormalized;
existing.urlHash = urlHash;
existing.visibility = visibility;
existing.folderId = folderId;
existing.source = source;
existing.updatedAt = now;
saveLocalState(state);
return existing;
}
const bookmark = {
id: crypto.randomUUID(),
userId: null,
folderId,
title,
url,
urlNormalized,
urlHash,
visibility,
source,
updatedAt: now,
deletedAt: null
};
state.bookmarks.unshift(bookmark);
saveLocalState(state);
return bookmark;
}
export function markLocalDeleted(id) {
const state = loadLocalState();
const now = nowIso();
const item = state.bookmarks.find((b) => b.id === id);
if (item) {
item.deletedAt = now;
item.updatedAt = now;
saveLocalState(state);
}
}
export function patchLocalBookmark(id, patch) {
const state = loadLocalState();
const now = nowIso();
const item = state.bookmarks.find((b) => b.id === id);
if (!item) return null;
if (patch.url !== undefined) {
const nextUrl = patch.url || "";
const nextNormalized = normalizeUrl(nextUrl);
const nextHash = computeUrlHash(nextNormalized);
const target = (state.bookmarks || []).find((b) => !b.deletedAt && b.id !== id && (b.urlHash || "") === nextHash);
if (target) {
// Merge: write changes to target and soft-delete current
ensureBookmarkHashes(target);
target.title = patch.title !== undefined ? patch.title : item.title;
target.url = nextUrl;
target.urlNormalized = nextNormalized;
target.urlHash = nextHash;
if (patch.folderId !== undefined) target.folderId = patch.folderId;
if (patch.visibility !== undefined) target.visibility = patch.visibility;
target.updatedAt = now;
item.deletedAt = now;
item.updatedAt = now;
saveLocalState(state);
return target;
}
item.url = nextUrl;
item.urlNormalized = nextNormalized;
item.urlHash = nextHash;
}
if (patch.title !== undefined) item.title = patch.title;
if (patch.folderId !== undefined) item.folderId = patch.folderId;
if (patch.visibility !== undefined) item.visibility = patch.visibility;
item.updatedAt = now;
saveLocalState(state);
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)
.map((b) => {
const fixed = ensureBookmarkHashes(b);
return [fixed.urlHash, fixed];
})
);
let imported = 0;
let merged = 0;
for (const b of parsed.bookmarks || []) {
const url = (b.url || "").trim();
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);
const existing = existingByHash.get(urlHash);
if (existing) {
existing.title = title || existing.title;
existing.url = url;
existing.urlNormalized = urlNormalized;
existing.urlHash = urlHash;
existing.visibility = visibility;
existing.folderId = folderId;
existing.source = "import";
existing.updatedAt = now;
merged++;
continue;
}
const created = {
id: crypto.randomUUID(),
userId: null,
folderId,
title,
url,
urlNormalized,
urlHash,
visibility,
source: "import",
updatedAt: now,
deletedAt: null
};
state.bookmarks.unshift(created);
existingByHash.set(urlHash, created);
imported++;
}
saveLocalState(state);
return { imported, merged };
}
export function exportLocalToNetscapeHtml(bookmarks) {
const safe = (s) => String(s || "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
const lines = [];
lines.push("<!DOCTYPE NETSCAPE-Bookmark-file-1>");
lines.push('<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">');
lines.push("<TITLE>Bookmarks</TITLE>");
lines.push("<H1>Bookmarks</H1>");
lines.push("<DL><p>");
for (const b of bookmarks || []) {
if (b.deletedAt) continue;
lines.push(` <DT><A HREF="${safe(b.url)}">${safe(b.title || b.url)}</A>`);
}
lines.push("</DL><p>");
return lines.join("\n");
}
export function mergeLocalToUser(userId) {
const state = loadLocalState();
return {
folders: state.folders.map((f) => ({ ...f, userId })),
bookmarks: state.bookmarks.map((b) => ({ ...ensureBookmarkHashes(b), userId }))
};
}
export function clearLocalState() {
saveLocalState({ folders: [], bookmarks: [] });
}
import { computeUrlHash, normalizeUrl, parseNetscapeBookmarkHtml } from "@browser-bookmark/shared";
const KEY = "bb_local_state_v1";
function nowIso() {
return new Date().toISOString();
}
function ensureBookmarkHashes(bookmark) {
if (!bookmark) return bookmark;
const url = bookmark.url || "";
const urlNormalized = bookmark.urlNormalized || normalizeUrl(url);
const urlHash = bookmark.urlHash || computeUrlHash(urlNormalized);
bookmark.urlNormalized = urlNormalized;
bookmark.urlHash = urlHash;
return bookmark;
}
export function loadLocalState() {
try {
const parsed = JSON.parse(localStorage.getItem(KEY) || "") || { folders: [], bookmarks: [] };
parsed.folders = parsed.folders || [];
parsed.bookmarks = (parsed.bookmarks || []).map((b) => ensureBookmarkHashes(b));
return parsed;
} catch {
return { folders: [], bookmarks: [] };
}
}
export function saveLocalState(state) {
localStorage.setItem(KEY, JSON.stringify(state));
}
export function listLocalBookmarks({ includeDeleted = false } = {}) {
const state = loadLocalState();
const all = state.bookmarks || [];
return includeDeleted ? all : all.filter((b) => !b.deletedAt);
}
export function upsertLocalBookmark({ title, url, visibility = "public", folderId = null, source = "manual" }) {
const state = loadLocalState();
const now = nowIso();
const urlNormalized = normalizeUrl(url || "");
const urlHash = computeUrlHash(urlNormalized);
// Dedupe: same urlHash and not deleted -> update LWW
const existing = (state.bookmarks || []).find((b) => !b.deletedAt && (b.urlHash || "") === urlHash);
if (existing) {
ensureBookmarkHashes(existing);
existing.title = title || existing.title;
existing.url = url || existing.url;
existing.urlNormalized = urlNormalized;
existing.urlHash = urlHash;
existing.visibility = visibility;
existing.folderId = folderId;
existing.source = source;
existing.updatedAt = now;
saveLocalState(state);
return existing;
}
const bookmark = {
id: crypto.randomUUID(),
userId: null,
folderId,
title,
url,
urlNormalized,
urlHash,
visibility,
source,
updatedAt: now,
deletedAt: null
};
state.bookmarks.unshift(bookmark);
saveLocalState(state);
return bookmark;
}
export function markLocalDeleted(id) {
const state = loadLocalState();
const now = nowIso();
const item = state.bookmarks.find((b) => b.id === id);
if (item) {
item.deletedAt = now;
item.updatedAt = now;
saveLocalState(state);
}
}
export function patchLocalBookmark(id, patch) {
const state = loadLocalState();
const now = nowIso();
const item = state.bookmarks.find((b) => b.id === id);
if (!item) return null;
if (patch.url !== undefined) {
const nextUrl = patch.url || "";
const nextNormalized = normalizeUrl(nextUrl);
const nextHash = computeUrlHash(nextNormalized);
const target = (state.bookmarks || []).find((b) => !b.deletedAt && b.id !== id && (b.urlHash || "") === nextHash);
if (target) {
// Merge: write changes to target and soft-delete current
ensureBookmarkHashes(target);
target.title = patch.title !== undefined ? patch.title : item.title;
target.url = nextUrl;
target.urlNormalized = nextNormalized;
target.urlHash = nextHash;
if (patch.folderId !== undefined) target.folderId = patch.folderId;
if (patch.visibility !== undefined) target.visibility = patch.visibility;
target.updatedAt = now;
item.deletedAt = now;
item.updatedAt = now;
saveLocalState(state);
return target;
}
item.url = nextUrl;
item.urlNormalized = nextNormalized;
item.urlHash = nextHash;
}
if (patch.title !== undefined) item.title = patch.title;
if (patch.folderId !== undefined) item.folderId = patch.folderId;
if (patch.visibility !== undefined) item.visibility = patch.visibility;
item.updatedAt = now;
saveLocalState(state);
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)
.map((b) => {
const fixed = ensureBookmarkHashes(b);
return [fixed.urlHash, fixed];
})
);
let imported = 0;
let merged = 0;
for (const b of parsed.bookmarks || []) {
const url = (b.url || "").trim();
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);
const existing = existingByHash.get(urlHash);
if (existing) {
existing.title = title || existing.title;
existing.url = url;
existing.urlNormalized = urlNormalized;
existing.urlHash = urlHash;
existing.visibility = visibility;
existing.folderId = folderId;
existing.source = "import";
existing.updatedAt = now;
merged++;
continue;
}
const created = {
id: crypto.randomUUID(),
userId: null,
folderId,
title,
url,
urlNormalized,
urlHash,
visibility,
source: "import",
updatedAt: now,
deletedAt: null
};
state.bookmarks.unshift(created);
existingByHash.set(urlHash, created);
imported++;
}
saveLocalState(state);
return { imported, merged };
}
export function exportLocalToNetscapeHtml(bookmarks) {
const safe = (s) => String(s || "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
const lines = [];
lines.push("<!DOCTYPE NETSCAPE-Bookmark-file-1>");
lines.push('<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">');
lines.push("<TITLE>Bookmarks</TITLE>");
lines.push("<H1>Bookmarks</H1>");
lines.push("<DL><p>");
for (const b of bookmarks || []) {
if (b.deletedAt) continue;
lines.push(` <DT><A HREF="${safe(b.url)}">${safe(b.title || b.url)}</A>`);
}
lines.push("</DL><p>");
return lines.join("\n");
}
export function mergeLocalToUser(userId) {
const state = loadLocalState();
return {
folders: state.folders.map((f) => ({ ...f, userId })),
bookmarks: state.bookmarks.map((b) => ({ ...ensureBookmarkHashes(b), userId }))
};
}
export function clearLocalState() {
saveLocalState({ folders: [], bookmarks: [] });
}

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>

View File

@@ -1,168 +1,168 @@
<script setup>
import { computed, ref } from "vue";
import { apiFetch, tokenRef } from "../lib/api";
import {
exportLocalToNetscapeHtml,
importLocalFromNetscapeHtml,
listLocalBookmarks
} from "../lib/localData";
const loggedIn = computed(() => Boolean(tokenRef.value));
const file = ref(null);
const status = ref("");
const error = ref("");
const busy = ref(false);
const fileInputEl = ref(null);
function onFileChange(e) {
file.value = e?.target?.files?.[0] || null;
}
function openFilePicker() {
fileInputEl.value?.click?.();
}
async function importFile() {
status.value = "";
error.value = "";
if (!file.value) return;
try {
busy.value = true;
if (loggedIn.value) {
const fd = new FormData();
fd.append("file", file.value);
const res = await apiFetch("/bookmarks/import/html", { method: "POST", body: fd });
status.value = `导入完成:新增 ${res.imported},合并 ${res.merged}`;
} else {
const text = await file.value.text();
const res = importLocalFromNetscapeHtml(text, { visibility: "public" });
status.value = `本地导入完成:新增 ${res.imported},合并 ${res.merged}`;
}
} catch (e) {
error.value = e.message || String(e);
} finally {
busy.value = false;
}
}
async function exportCloud() {
status.value = "";
error.value = "";
try {
busy.value = true;
const html = await apiFetch("/bookmarks/export/html", {
method: "GET",
headers: { Accept: "text/html" }
});
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks-cloud.html";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
status.value = "云端导出完成";
} catch (e) {
error.value = e.message || String(e);
} finally {
busy.value = false;
}
}
async function exportLocal() {
status.value = "";
error.value = "";
try {
busy.value = true;
const bookmarks = listLocalBookmarks({ includeDeleted: false });
const html = exportLocalToNetscapeHtml(bookmarks);
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks.html";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
status.value = `本地导出完成:${bookmarks.length}`;
} catch (e) {
error.value = e.message || String(e);
} finally {
busy.value = false;
}
}
</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 class="bb-clayCard" style="margin-top: 12px;">
<div class="bb-cardTitle">导入 Chrome/Edge 书签 HTML</div>
<div class="bb-cardSub">支持自动去重与合并登录后会写入云端数据库</div>
<div class="bb-row" style="margin-top: 12px;">
<input
ref="fileInputEl"
class="bb-fileInput"
type="file"
accept="text/html,.html"
@change="onFileChange"
/>
<button class="bb-btn bb-btn--secondary" :disabled="busy" @click="openFilePicker">选择 HTML 文件</button>
<div class="bb-fileName" :class="!file ? 'is-empty' : ''">
{{ file ? file.name : "未选择文件" }}
</div>
<button class="bb-btn" :disabled="busy || !file" @click="importFile">开始导入</button>
</div>
<p v-if="status" class="bb-alert bb-alert--ok" style="margin-top: 12px;">{{ status }}</p>
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p>
<p class="bb-muted" style="margin-top: 10px;">
未登录导入写入本机 localStorage并自动去重登录导入写入数据库并自动去重合并
</p>
</div>
<div v-if="loggedIn" class="bb-grid2" style="margin-top: 12px;">
<div class="bb-card bb-card--interactive">
<div class="bb-cardTitle">导出云端</div>
<div class="bb-cardSub">保留文件夹层级已携带登录态导出</div>
<div style="margin-top: 12px;">
<button class="bb-btn" :disabled="busy" @click="exportCloud">导出为 HTML</button>
</div>
</div>
<div class="bb-card bb-card--interactive">
<div class="bb-cardTitle">导出本地</div>
<div class="bb-cardSub">导出当前浏览器 localStorage 中的书签平铺导出</div>
<div style="margin-top: 12px;">
<button class="bb-btn bb-btn--secondary" :disabled="busy" @click="exportLocal">导出为 HTML</button>
</div>
</div>
</div>
<div v-else class="bb-empty" style="margin-top: 12px;">
<div style="font-weight: 900;">登录后可导出云端书签</div>
<div class="bb-muted" style="margin-top: 6px;">你仍可在未登录状态下导入到本机并导出本机数据</div>
</div>
</section>
</template>
<style scoped>
/* page-level tweaks only */
</style>
<script setup>
import { computed, ref } from "vue";
import { apiFetch, tokenRef } from "../lib/api";
import {
exportLocalToNetscapeHtml,
importLocalFromNetscapeHtml,
listLocalBookmarks
} from "../lib/localData";
const loggedIn = computed(() => Boolean(tokenRef.value));
const file = ref(null);
const status = ref("");
const error = ref("");
const busy = ref(false);
const fileInputEl = ref(null);
function onFileChange(e) {
file.value = e?.target?.files?.[0] || null;
}
function openFilePicker() {
fileInputEl.value?.click?.();
}
async function importFile() {
status.value = "";
error.value = "";
if (!file.value) return;
try {
busy.value = true;
if (loggedIn.value) {
const fd = new FormData();
fd.append("file", file.value);
const res = await apiFetch("/bookmarks/import/html", { method: "POST", body: fd });
status.value = `导入完成:新增 ${res.imported},合并 ${res.merged}`;
} else {
const text = await file.value.text();
const res = importLocalFromNetscapeHtml(text, { visibility: "public" });
status.value = `本地导入完成:新增 ${res.imported},合并 ${res.merged}`;
}
} catch (e) {
error.value = e.message || String(e);
} finally {
busy.value = false;
}
}
async function exportCloud() {
status.value = "";
error.value = "";
try {
busy.value = true;
const html = await apiFetch("/bookmarks/export/html", {
method: "GET",
headers: { Accept: "text/html" }
});
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks-cloud.html";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
status.value = "云端导出完成";
} catch (e) {
error.value = e.message || String(e);
} finally {
busy.value = false;
}
}
async function exportLocal() {
status.value = "";
error.value = "";
try {
busy.value = true;
const bookmarks = listLocalBookmarks({ includeDeleted: false });
const html = exportLocalToNetscapeHtml(bookmarks);
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks.html";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
status.value = `本地导出完成:${bookmarks.length}`;
} catch (e) {
error.value = e.message || String(e);
} finally {
busy.value = false;
}
}
</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 class="bb-clayCard" style="margin-top: 12px;">
<div class="bb-cardTitle">导入 Chrome/Edge 书签 HTML</div>
<div class="bb-cardSub">支持自动去重与合并登录后会写入云端数据库</div>
<div class="bb-row" style="margin-top: 12px;">
<input
ref="fileInputEl"
class="bb-fileInput"
type="file"
accept="text/html,.html"
@change="onFileChange"
/>
<button class="bb-btn bb-btn--secondary" :disabled="busy" @click="openFilePicker">选择 HTML 文件</button>
<div class="bb-fileName" :class="!file ? 'is-empty' : ''">
{{ file ? file.name : "未选择文件" }}
</div>
<button class="bb-btn" :disabled="busy || !file" @click="importFile">开始导入</button>
</div>
<p v-if="status" class="bb-alert bb-alert--ok" style="margin-top: 12px;">{{ status }}</p>
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p>
<p class="bb-muted" style="margin-top: 10px;">
未登录导入写入本机 localStorage并自动去重登录导入写入数据库并自动去重合并
</p>
</div>
<div v-if="loggedIn" class="bb-grid2" style="margin-top: 12px;">
<div class="bb-card bb-card--interactive">
<div class="bb-cardTitle">导出云端</div>
<div class="bb-cardSub">保留文件夹层级已携带登录态导出</div>
<div style="margin-top: 12px;">
<button class="bb-btn" :disabled="busy" @click="exportCloud">导出为 HTML</button>
</div>
</div>
<div class="bb-card bb-card--interactive">
<div class="bb-cardTitle">导出本地</div>
<div class="bb-cardSub">导出当前浏览器 localStorage 中的书签平铺导出</div>
<div style="margin-top: 12px;">
<button class="bb-btn bb-btn--secondary" :disabled="busy" @click="exportLocal">导出为 HTML</button>
</div>
</div>
</div>
<div v-else class="bb-empty" style="margin-top: 12px;">
<div style="font-weight: 900;">登录后可导出云端书签</div>
<div class="bb-muted" style="margin-top: 6px;">你仍可在未登录状态下导入到本机并导出本机数据</div>
</div>
</section>
</template>
<style scoped>
/* page-level tweaks only */
</style>

View File

@@ -1,133 +1,133 @@
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { apiFetch, setToken } from "../lib/api";
import { clearLocalState, mergeLocalToUser } from "../lib/localData";
const router = useRouter();
const mode = ref("login");
const email = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
async function submit() {
loading.value = true;
error.value = "";
try {
const path = mode.value === "register" ? "/auth/register" : "/auth/login";
const res = await apiFetch(path, {
method: "POST",
body: JSON.stringify({ email: email.value, password: password.value })
});
setToken(res.token);
// Bootstrap: push local data to server (server enforces userId from token)
const payload = mergeLocalToUser(res.user.id);
await apiFetch("/sync/push", {
method: "POST",
body: JSON.stringify(payload)
});
clearLocalState();
const next = String(router.currentRoute.value.query?.next || "").trim();
await router.push(next || "/my");
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
</script>
<template>
<section class="bb-page bb-auth">
<div class="bb-authCard bb-clayCard">
<div class="bb-authHeader">
<div class="bb-pill">云端同步 · 本地不丢</div>
<h1 class="bb-authTitle">{{ mode === 'register' ? '注册账号' : '登录账号' }}</h1>
<p class="bb-heroSub">登录后自动同步到云端不登录也能本地管理localStorage</p>
</div>
<div class="bb-seg" role="tablist" aria-label="登录模式">
<button
class="bb-segBtn"
:class="{ active: mode === 'login' }"
role="tab"
:aria-selected="mode === 'login'"
@click="mode = 'login'"
>
登录
</button>
<button
class="bb-segBtn"
:class="{ active: mode === 'register' }"
role="tab"
:aria-selected="mode === 'register'"
@click="mode = 'register'"
>
注册
</button>
</div>
<div class="bb-authForm">
<label class="bb-field">
<span class="bb-label">邮箱</span>
<input v-model="email" class="bb-input" placeholder="name@example.com" autocomplete="email" />
</label>
<label class="bb-field">
<span class="bb-label">密码</span>
<input
v-model="password"
class="bb-input"
placeholder="至少 8 位"
type="password"
autocomplete="current-password"
/>
</label>
<button class="bb-btn" :disabled="loading" @click="submit">
{{ loading ? '处理中' : (mode === 'register' ? '创建账号并登录' : '登录') }}
</button>
<p v-if="error" class="bb-alert bb-alert--error" style="margin: 0;">{{ error }}</p>
</div>
<div class="bb-authFoot bb-muted">
Token 会持久化保存仅当你手动退出才会清除
</div>
</div>
<aside class="bb-authAside bb-card">
<div class="bb-cardTitle">你将获得</div>
<div class="bb-cardSub">更像课程平台的学习型收藏体验</div>
<div class="bb-authBadges">
<span class="bb-tag">同步</span>
<span class="bb-tag bb-tag2">去重</span>
<span class="bb-tag bb-tag3">文件夹层级</span>
</div>
<ul class="bb-bullets" style="margin-top: 10px;">
<li>导入 Chrome/Edge 书签 HTML 一键合并</li>
<li>公开/私有可切换公开页可分享</li>
<li>跨标签页登录态自动同步</li>
</ul>
<div class="bb-miniQuote" style="margin-top: 12px;">
<div class="bb-quote">终于把书签变成了可复习的知识库</div>
<div class="bb-quoteBy"> 早八人 · 收藏课学员</div>
</div>
</aside>
</section>
</template>
<style scoped>
.bb-authTitle { margin: 10px 0 6px; }
.bb-authForm { display: grid; gap: 10px; margin-top: 12px; }
.bb-authFoot { margin-top: 10px; }
</style>
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { apiFetch, setToken } from "../lib/api";
import { clearLocalState, mergeLocalToUser } from "../lib/localData";
const router = useRouter();
const mode = ref("login");
const email = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
async function submit() {
loading.value = true;
error.value = "";
try {
const path = mode.value === "register" ? "/auth/register" : "/auth/login";
const res = await apiFetch(path, {
method: "POST",
body: JSON.stringify({ email: email.value, password: password.value })
});
setToken(res.token);
// Bootstrap: push local data to server (server enforces userId from token)
const payload = mergeLocalToUser(res.user.id);
await apiFetch("/sync/push", {
method: "POST",
body: JSON.stringify(payload)
});
clearLocalState();
const next = String(router.currentRoute.value.query?.next || "").trim();
await router.push(next || "/my");
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
</script>
<template>
<section class="bb-page bb-auth">
<div class="bb-authCard bb-clayCard">
<div class="bb-authHeader">
<div class="bb-pill">云端同步 · 本地不丢</div>
<h1 class="bb-authTitle">{{ mode === 'register' ? '注册账号' : '登录账号' }}</h1>
<p class="bb-heroSub">登录后自动同步到云端不登录也能本地管理localStorage</p>
</div>
<div class="bb-seg" role="tablist" aria-label="登录模式">
<button
class="bb-segBtn"
:class="{ active: mode === 'login' }"
role="tab"
:aria-selected="mode === 'login'"
@click="mode = 'login'"
>
登录
</button>
<button
class="bb-segBtn"
:class="{ active: mode === 'register' }"
role="tab"
:aria-selected="mode === 'register'"
@click="mode = 'register'"
>
注册
</button>
</div>
<div class="bb-authForm">
<label class="bb-field">
<span class="bb-label">邮箱</span>
<input v-model="email" class="bb-input" placeholder="name@example.com" autocomplete="email" />
</label>
<label class="bb-field">
<span class="bb-label">密码</span>
<input
v-model="password"
class="bb-input"
placeholder="至少 8 位"
type="password"
autocomplete="current-password"
/>
</label>
<button class="bb-btn" :disabled="loading" @click="submit">
{{ loading ? '处理中' : (mode === 'register' ? '创建账号并登录' : '登录') }}
</button>
<p v-if="error" class="bb-alert bb-alert--error" style="margin: 0;">{{ error }}</p>
</div>
<div class="bb-authFoot bb-muted">
Token 会持久化保存仅当你手动退出才会清除
</div>
</div>
<aside class="bb-authAside bb-card">
<div class="bb-cardTitle">你将获得</div>
<div class="bb-cardSub">更像课程平台的学习型收藏体验</div>
<div class="bb-authBadges">
<span class="bb-tag">同步</span>
<span class="bb-tag bb-tag2">去重</span>
<span class="bb-tag bb-tag3">文件夹层级</span>
</div>
<ul class="bb-bullets" style="margin-top: 10px;">
<li>导入 Chrome/Edge 书签 HTML 一键合并</li>
<li>公开/私有可切换公开页可分享</li>
<li>跨标签页登录态自动同步</li>
</ul>
<div class="bb-miniQuote" style="margin-top: 12px;">
<div class="bb-quote">终于把书签变成了可复习的知识库</div>
<div class="bb-quoteBy"> 早八人 · 收藏课学员</div>
</div>
</aside>
</section>
</template>
<style scoped>
.bb-authTitle { margin: 10px 0 6px; }
.bb-authForm { display: grid; gap: 10px; margin-top: 12px; }
.bb-authFoot { margin-top: 10px; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,180 +1,180 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } 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 = "";
try {
items.value = await apiFetch(`/bookmarks/public?q=${encodeURIComponent(q.value)}`);
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
let searchTimer = 0;
watch(
() => q.value,
() => {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
load();
}, 200);
}
);
onBeforeUnmount(() => {
window.clearTimeout(searchTimer);
});
onMounted(load);
onMounted(loadFolders);
</script>
<template>
<section class="bb-page">
<div class="bb-pageHeader">
<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>
<div class="bb-card" style="margin-top: 12px;">
<div class="bb-row">
<div class="bb-searchWrap">
<input v-model="q" class="bb-input bb-input--withClear" placeholder="搜索标题或链接" />
<button v-if="q.trim()" class="bb-searchClear" type="button" aria-label="清空搜索" @click="q = ''">×</button>
</div>
</div>
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p>
<p v-else-if="loading" class="bb-muted" style="margin-top: 12px;">加载中</p>
<div v-else-if="!items.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>
<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 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>
.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>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } 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 = "";
try {
items.value = await apiFetch(`/bookmarks/public?q=${encodeURIComponent(q.value)}`);
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
let searchTimer = 0;
watch(
() => q.value,
() => {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
load();
}, 200);
}
);
onBeforeUnmount(() => {
window.clearTimeout(searchTimer);
});
onMounted(load);
onMounted(loadFolders);
</script>
<template>
<section class="bb-page">
<div class="bb-pageHeader">
<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>
<div class="bb-card" style="margin-top: 12px;">
<div class="bb-row">
<div class="bb-searchWrap">
<input v-model="q" class="bb-input bb-input--withClear" placeholder="搜索标题或链接" />
<button v-if="q.trim()" class="bb-searchClear" type="button" aria-label="清空搜索" @click="q = ''">×</button>
</div>
</div>
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p>
<p v-else-if="loading" class="bb-muted" style="margin-top: 12px;">加载中</p>
<div v-else-if="!items.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>
<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 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>
.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>

View File

@@ -1,41 +1,41 @@
import { createRouter, createWebHistory } from "vue-router";
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 AdminPage from "./pages/AdminPage.vue";
import { ensureMe, tokenRef } from "./lib/api";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: PublicPage },
{ path: "/login", component: LoginPage },
{ path: "/my", component: MyPage },
{ path: "/import", component: ImportExportPage },
{ path: "/admin", component: AdminPage }
]
});
router.beforeEach(async (to) => {
const loggedIn = Boolean(tokenRef.value);
// 主页(/)永远是公共首页;不因登录态自动跳转
// 已登录访问登录页:直接去“我的”
if (to.path === "/login" && loggedIn) return { path: "/my" };
// 导入导出:登录后才可见/可用
if (to.path === "/import" && !loggedIn) {
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;
});
import { createRouter, createWebHistory } from "vue-router";
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 AdminPage from "./pages/AdminPage.vue";
import { ensureMe, tokenRef } from "./lib/api";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: PublicPage },
{ path: "/login", component: LoginPage },
{ path: "/my", component: MyPage },
{ path: "/import", component: ImportExportPage },
{ path: "/admin", component: AdminPage }
]
});
router.beforeEach(async (to) => {
const loggedIn = Boolean(tokenRef.value);
// 主页(/)永远是公共首页;不因登录态自动跳转
// 已登录访问登录页:直接去“我的”
if (to.path === "/login" && loggedIn) return { path: "/my" };
// 导入导出:登录后才可见/可用
if (to.path === "/import" && !loggedIn) {
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;
});