提交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,5 +1,5 @@
# Extension API base (Fastify server)
VITE_SERVER_BASE_URL=http://mark.cloud-xl.top:6667
# Web app base (used by Options -> 跳转 Web)
VITE_WEB_BASE_URL=http://mark.cloud-xl.top:6666
# Extension API base (Fastify server)
VITE_SERVER_BASE_URL=http://mark.cloud-xl.top:6667
# Web app base (used by Options -> 跳转 Web)
VITE_WEB_BASE_URL=http://mark.cloud-xl.top:6666

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": "^_" }]
}
}
];

Binary file not shown.

View File

@@ -1,12 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>云书签 选项</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/options/main.js"></script>
</body>
</html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>云书签 选项</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/options/main.js"></script>
</body>
</html>

View File

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

View File

@@ -1,12 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bookmarks</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/popup/main.js"></script>
</body>
</html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bookmarks</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/popup/main.js"></script>
</body>
</html>

View File

@@ -1,12 +1,12 @@
{
"manifest_version": 3,
"name": "BrowserBookmark",
"version": "0.1.0",
"action": {
"default_title": "Bookmarks",
"default_popup": "popup.html"
},
"options_page": "options.html",
"permissions": ["storage", "tabs"],
"host_permissions": ["<all_urls>"]
}
{
"manifest_version": 3,
"name": "BrowserBookmark",
"version": "0.1.0",
"action": {
"default_title": "Bookmarks",
"default_popup": "popup.html"
},
"options_page": "options.html",
"permissions": ["storage", "tabs"],
"host_permissions": ["<all_urls>"]
}

View File

@@ -1,154 +1,154 @@
<script setup>
import { onBeforeUnmount, onMounted } from "vue";
const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: "请确认" },
message: { type: String, default: "" },
confirmText: { type: String, default: "确定" },
cancelText: { type: String, default: "取消" },
danger: { type: Boolean, default: false },
maxWidth: { type: String, default: "520px" }
});
const emit = defineEmits(["update:modelValue", "confirm", "cancel"]);
function cancel() {
emit("update:modelValue", false);
emit("cancel");
}
function confirm() {
emit("confirm");
}
function onKeydown(e) {
if (e.key === "Escape") cancel();
}
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="cancel" />
<div class="bb-modalPanel" :style="{ maxWidth }">
<div class="bb-modalHeader">
<div class="bb-modalTitle">{{ title }}</div>
<button type="button" class="bb-modalClose" @click="cancel" aria-label="关闭">×</button>
</div>
<div class="bb-modalBody">
<div v-if="message" class="bb-modalMessage">{{ message }}</div>
<slot />
<div class="bb-modalActions">
<button type="button" class="bb-btn bb-btn--secondary" @click="cancel">{{ cancelText }}</button>
<button type="button" class="bb-btn" :class="danger ? 'bb-btn--danger' : ''" @click="confirm">{{ confirmText }}</button>
</div>
</div>
</div>
</div>
</teleport>
</template>
<style scoped>
.bb-modalOverlay {
position: fixed;
inset: 0;
z-index: 2147483500;
display: grid;
place-items: center;
padding: 16px;
}
.bb-modalBackdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.38);
backdrop-filter: blur(8px);
}
.bb-modalPanel {
position: relative;
width: min(100%, 560px);
max-height: min(84vh, 860px);
overflow: auto;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.22);
color: var(--bb-text);
}
.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.1);
}
.bb-modalTitle {
font-weight: 900;
}
.bb-modalClose {
width: 34px;
height: 34px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.5);
cursor: pointer;
font-size: 20px;
line-height: 1;
color: rgba(15, 23, 42, 0.75);
}
.bb-modalClose:hover {
background: rgba(255, 255, 255, 0.78);
}
.bb-modalBody {
padding: 14px;
}
.bb-modalMessage {
color: rgba(15, 23, 42, 0.78);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.bb-modalActions {
margin-top: 14px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.bb-btn {
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 14px;
padding: 10px 12px;
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
color: white;
cursor: pointer;
}
.bb-btn--secondary {
background: rgba(255, 255, 255, 0.55);
color: var(--bb-text);
border-color: var(--bb-border);
}
.bb-btn--danger {
background: linear-gradient(135deg, #ef4444, #f97316);
}
</style>
<script setup>
import { onBeforeUnmount, onMounted } from "vue";
const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: "请确认" },
message: { type: String, default: "" },
confirmText: { type: String, default: "确定" },
cancelText: { type: String, default: "取消" },
danger: { type: Boolean, default: false },
maxWidth: { type: String, default: "520px" }
});
const emit = defineEmits(["update:modelValue", "confirm", "cancel"]);
function cancel() {
emit("update:modelValue", false);
emit("cancel");
}
function confirm() {
emit("confirm");
}
function onKeydown(e) {
if (e.key === "Escape") cancel();
}
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="cancel" />
<div class="bb-modalPanel" :style="{ maxWidth }">
<div class="bb-modalHeader">
<div class="bb-modalTitle">{{ title }}</div>
<button type="button" class="bb-modalClose" @click="cancel" aria-label="关闭">×</button>
</div>
<div class="bb-modalBody">
<div v-if="message" class="bb-modalMessage">{{ message }}</div>
<slot />
<div class="bb-modalActions">
<button type="button" class="bb-btn bb-btn--secondary" @click="cancel">{{ cancelText }}</button>
<button type="button" class="bb-btn" :class="danger ? 'bb-btn--danger' : ''" @click="confirm">{{ confirmText }}</button>
</div>
</div>
</div>
</div>
</teleport>
</template>
<style scoped>
.bb-modalOverlay {
position: fixed;
inset: 0;
z-index: 2147483500;
display: grid;
place-items: center;
padding: 16px;
}
.bb-modalBackdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.38);
backdrop-filter: blur(8px);
}
.bb-modalPanel {
position: relative;
width: min(100%, 560px);
max-height: min(84vh, 860px);
overflow: auto;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.22);
color: var(--bb-text);
}
.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.1);
}
.bb-modalTitle {
font-weight: 900;
}
.bb-modalClose {
width: 34px;
height: 34px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.5);
cursor: pointer;
font-size: 20px;
line-height: 1;
color: rgba(15, 23, 42, 0.75);
}
.bb-modalClose:hover {
background: rgba(255, 255, 255, 0.78);
}
.bb-modalBody {
padding: 14px;
}
.bb-modalMessage {
color: rgba(15, 23, 42, 0.78);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.bb-modalActions {
margin-top: 14px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.bb-btn {
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 14px;
padding: 10px 12px;
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
color: white;
cursor: pointer;
}
.bb-btn--secondary {
background: rgba(255, 255, 255, 0.55);
color: var(--bb-text);
border-color: var(--bb-border);
}
.bb-btn--danger {
background: linear-gradient(135deg, #ef4444, #f97316);
}
</style>

View File

@@ -1,31 +1,31 @@
const BASE_URL = import.meta.env.VITE_SERVER_BASE_URL || "http://localhost:3001";
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");
}
// Lazy import to avoid circular deps
const { getToken } = await import("./extStorage.js");
const token = await 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;
}
const BASE_URL = import.meta.env.VITE_SERVER_BASE_URL || "http://localhost:3001";
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");
}
// Lazy import to avoid circular deps
const { getToken } = await import("./extStorage.js");
const token = await 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,43 +1,43 @@
const TOKEN_KEY = "bb_token";
const LOCAL_STATE_KEY = "bb_local_state_v1";
function hasChromeStorage() {
return typeof chrome !== "undefined" && chrome.storage?.local;
}
export async function getToken() {
if (hasChromeStorage()) {
const res = await chrome.storage.local.get([TOKEN_KEY]);
return res[TOKEN_KEY] || "";
}
return localStorage.getItem(TOKEN_KEY) || "";
}
export async function setToken(token) {
if (hasChromeStorage()) {
await chrome.storage.local.set({ [TOKEN_KEY]: token || "" });
return;
}
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
export async function loadLocalState() {
if (hasChromeStorage()) {
const res = await chrome.storage.local.get([LOCAL_STATE_KEY]);
return res[LOCAL_STATE_KEY] || { folders: [], bookmarks: [] };
}
try {
return JSON.parse(localStorage.getItem(LOCAL_STATE_KEY) || "") || { folders: [], bookmarks: [] };
} catch {
return { folders: [], bookmarks: [] };
}
}
export async function saveLocalState(state) {
if (hasChromeStorage()) {
await chrome.storage.local.set({ [LOCAL_STATE_KEY]: state });
return;
}
localStorage.setItem(LOCAL_STATE_KEY, JSON.stringify(state));
}
const TOKEN_KEY = "bb_token";
const LOCAL_STATE_KEY = "bb_local_state_v1";
function hasChromeStorage() {
return typeof chrome !== "undefined" && chrome.storage?.local;
}
export async function getToken() {
if (hasChromeStorage()) {
const res = await chrome.storage.local.get([TOKEN_KEY]);
return res[TOKEN_KEY] || "";
}
return localStorage.getItem(TOKEN_KEY) || "";
}
export async function setToken(token) {
if (hasChromeStorage()) {
await chrome.storage.local.set({ [TOKEN_KEY]: token || "" });
return;
}
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
export async function loadLocalState() {
if (hasChromeStorage()) {
const res = await chrome.storage.local.get([LOCAL_STATE_KEY]);
return res[LOCAL_STATE_KEY] || { folders: [], bookmarks: [] };
}
try {
return JSON.parse(localStorage.getItem(LOCAL_STATE_KEY) || "") || { folders: [], bookmarks: [] };
} catch {
return { folders: [], bookmarks: [] };
}
}
export async function saveLocalState(state) {
if (hasChromeStorage()) {
await chrome.storage.local.set({ [LOCAL_STATE_KEY]: state });
return;
}
localStorage.setItem(LOCAL_STATE_KEY, JSON.stringify(state));
}

View File

@@ -1,218 +1,218 @@
import { computeUrlHash, normalizeUrl, parseNetscapeBookmarkHtml } from "@browser-bookmark/shared";
import { loadLocalState, saveLocalState } from "./extStorage";
function nowIso() {
return new Date().toISOString();
}
function ensureBookmarkHashes(bookmark) {
const urlNormalized = bookmark.urlNormalized || normalizeUrl(bookmark.url || "");
const urlHash = bookmark.urlHash || computeUrlHash(urlNormalized);
return { ...bookmark, urlNormalized, urlHash };
}
export async function listLocalBookmarks({ includeDeleted = false } = {}) {
const state = await loadLocalState();
const items = (state.bookmarks || []).map(ensureBookmarkHashes);
const filtered = includeDeleted ? items : items.filter((b) => !b.deletedAt);
// Keep newest first
filtered.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
return filtered;
}
export async function upsertLocalBookmark({ title, url, visibility = "public", folderId = null, source = "manual" }) {
const state = await 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) {
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;
await saveLocalState(state);
return { bookmark: ensureBookmarkHashes(existing), merged: true };
}
const bookmark = {
id: crypto.randomUUID(),
userId: null,
folderId,
title: title || "",
url: url || "",
urlNormalized,
urlHash,
visibility,
source,
updatedAt: now,
deletedAt: null
};
state.bookmarks = state.bookmarks || [];
state.bookmarks.unshift(bookmark);
await saveLocalState(state);
return { bookmark: ensureBookmarkHashes(bookmark), merged: false };
}
export async function markLocalDeleted(id) {
const state = await loadLocalState();
const now = nowIso();
const item = (state.bookmarks || []).find((b) => b.id === id);
if (!item) return false;
item.deletedAt = now;
item.updatedAt = now;
await saveLocalState(state);
return true;
}
export async function clearLocalState() {
await saveLocalState({ folders: [], bookmarks: [] });
}
export async function mergeLocalToUser() {
const state = await loadLocalState();
return {
folders: (state.folders || []).map((f) => ({
id: f.id,
parentId: f.parentId ?? null,
name: f.name || "",
visibility: f.visibility || "private",
updatedAt: f.updatedAt || nowIso()
})),
bookmarks: (state.bookmarks || []).map((b) => {
const fixed = ensureBookmarkHashes(b);
return {
id: fixed.id,
folderId: fixed.folderId ?? null,
title: fixed.title || "",
url: fixed.url || "",
visibility: fixed.visibility || "private",
source: fixed.source || "manual",
updatedAt: fixed.updatedAt || nowIso(),
deletedAt: fixed.deletedAt || null
};
})
};
}
export async function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}) {
const parsed = parseNetscapeBookmarkHtml(html || "");
const state = await loadLocalState();
const now = nowIso();
state.folders = state.folders || [];
state.bookmarks = state.bookmarks || [];
// Folder id remap (avoid collisions with existing UUID ids)
const folderIdMap = new Map();
for (const f of parsed.folders || []) {
folderIdMap.set(f.id, crypto.randomUUID());
}
// Dedupe folders by (parentId,name)
const folderKeyToId = new Map(
state.folders.map((f) => [`${f.parentId ?? ""}::${(f.name || "").toLowerCase()}`, f.id])
);
const oldFolderIdToActual = new Map();
let foldersImported = 0;
for (const f of parsed.folders || []) {
const parentId = f.parentFolderId ? oldFolderIdToActual.get(f.parentFolderId) || null : null;
const name = (f.name || "").trim();
const key = `${parentId ?? ""}::${name.toLowerCase()}`;
let id = folderKeyToId.get(key);
if (!id) {
id = folderIdMap.get(f.id);
state.folders.push({
id,
userId: null,
parentId,
name,
visibility,
updatedAt: now
});
folderKeyToId.set(key, id);
foldersImported++;
} else {
// Keep existing, but ensure it has a recent updatedAt
const existing = state.folders.find((x) => x.id === id);
if (existing && (!existing.updatedAt || existing.updatedAt < now)) existing.updatedAt = now;
}
oldFolderIdToActual.set(f.id, id);
}
// Dedupe bookmarks by urlHash
const existingByHash = new Map();
for (const b of state.bookmarks.map(ensureBookmarkHashes)) {
if (!b.deletedAt && b.urlHash) existingByHash.set(b.urlHash, b);
}
let imported = 0;
let merged = 0;
for (const b of parsed.bookmarks || []) {
const title = (b.title || "").trim();
const url = (b.url || "").trim();
if (!url) continue;
const urlNormalized = normalizeUrl(url);
const urlHash = computeUrlHash(urlNormalized);
const folderId = b.parentFolderId ? oldFolderIdToActual.get(b.parentFolderId) || null : null;
const existing = existingByHash.get(urlHash);
if (existing) {
existing.title = title || existing.title;
existing.updatedAt = now;
merged++;
continue;
}
state.bookmarks.unshift({
id: crypto.randomUUID(),
userId: null,
folderId: folderId ?? null,
title,
url,
urlNormalized,
urlHash,
visibility,
source: "import",
updatedAt: now,
deletedAt: null
});
existingByHash.set(urlHash, state.bookmarks[0]);
imported++;
}
await saveLocalState(state);
return { foldersImported, 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");
}
import { computeUrlHash, normalizeUrl, parseNetscapeBookmarkHtml } from "@browser-bookmark/shared";
import { loadLocalState, saveLocalState } from "./extStorage";
function nowIso() {
return new Date().toISOString();
}
function ensureBookmarkHashes(bookmark) {
const urlNormalized = bookmark.urlNormalized || normalizeUrl(bookmark.url || "");
const urlHash = bookmark.urlHash || computeUrlHash(urlNormalized);
return { ...bookmark, urlNormalized, urlHash };
}
export async function listLocalBookmarks({ includeDeleted = false } = {}) {
const state = await loadLocalState();
const items = (state.bookmarks || []).map(ensureBookmarkHashes);
const filtered = includeDeleted ? items : items.filter((b) => !b.deletedAt);
// Keep newest first
filtered.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
return filtered;
}
export async function upsertLocalBookmark({ title, url, visibility = "public", folderId = null, source = "manual" }) {
const state = await 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) {
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;
await saveLocalState(state);
return { bookmark: ensureBookmarkHashes(existing), merged: true };
}
const bookmark = {
id: crypto.randomUUID(),
userId: null,
folderId,
title: title || "",
url: url || "",
urlNormalized,
urlHash,
visibility,
source,
updatedAt: now,
deletedAt: null
};
state.bookmarks = state.bookmarks || [];
state.bookmarks.unshift(bookmark);
await saveLocalState(state);
return { bookmark: ensureBookmarkHashes(bookmark), merged: false };
}
export async function markLocalDeleted(id) {
const state = await loadLocalState();
const now = nowIso();
const item = (state.bookmarks || []).find((b) => b.id === id);
if (!item) return false;
item.deletedAt = now;
item.updatedAt = now;
await saveLocalState(state);
return true;
}
export async function clearLocalState() {
await saveLocalState({ folders: [], bookmarks: [] });
}
export async function mergeLocalToUser() {
const state = await loadLocalState();
return {
folders: (state.folders || []).map((f) => ({
id: f.id,
parentId: f.parentId ?? null,
name: f.name || "",
visibility: f.visibility || "private",
updatedAt: f.updatedAt || nowIso()
})),
bookmarks: (state.bookmarks || []).map((b) => {
const fixed = ensureBookmarkHashes(b);
return {
id: fixed.id,
folderId: fixed.folderId ?? null,
title: fixed.title || "",
url: fixed.url || "",
visibility: fixed.visibility || "private",
source: fixed.source || "manual",
updatedAt: fixed.updatedAt || nowIso(),
deletedAt: fixed.deletedAt || null
};
})
};
}
export async function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}) {
const parsed = parseNetscapeBookmarkHtml(html || "");
const state = await loadLocalState();
const now = nowIso();
state.folders = state.folders || [];
state.bookmarks = state.bookmarks || [];
// Folder id remap (avoid collisions with existing UUID ids)
const folderIdMap = new Map();
for (const f of parsed.folders || []) {
folderIdMap.set(f.id, crypto.randomUUID());
}
// Dedupe folders by (parentId,name)
const folderKeyToId = new Map(
state.folders.map((f) => [`${f.parentId ?? ""}::${(f.name || "").toLowerCase()}`, f.id])
);
const oldFolderIdToActual = new Map();
let foldersImported = 0;
for (const f of parsed.folders || []) {
const parentId = f.parentFolderId ? oldFolderIdToActual.get(f.parentFolderId) || null : null;
const name = (f.name || "").trim();
const key = `${parentId ?? ""}::${name.toLowerCase()}`;
let id = folderKeyToId.get(key);
if (!id) {
id = folderIdMap.get(f.id);
state.folders.push({
id,
userId: null,
parentId,
name,
visibility,
updatedAt: now
});
folderKeyToId.set(key, id);
foldersImported++;
} else {
// Keep existing, but ensure it has a recent updatedAt
const existing = state.folders.find((x) => x.id === id);
if (existing && (!existing.updatedAt || existing.updatedAt < now)) existing.updatedAt = now;
}
oldFolderIdToActual.set(f.id, id);
}
// Dedupe bookmarks by urlHash
const existingByHash = new Map();
for (const b of state.bookmarks.map(ensureBookmarkHashes)) {
if (!b.deletedAt && b.urlHash) existingByHash.set(b.urlHash, b);
}
let imported = 0;
let merged = 0;
for (const b of parsed.bookmarks || []) {
const title = (b.title || "").trim();
const url = (b.url || "").trim();
if (!url) continue;
const urlNormalized = normalizeUrl(url);
const urlHash = computeUrlHash(urlNormalized);
const folderId = b.parentFolderId ? oldFolderIdToActual.get(b.parentFolderId) || null : null;
const existing = existingByHash.get(urlHash);
if (existing) {
existing.title = title || existing.title;
existing.updatedAt = now;
merged++;
continue;
}
state.bookmarks.unshift({
id: crypto.randomUUID(),
userId: null,
folderId: folderId ?? null,
title,
url,
urlNormalized,
urlHash,
visibility,
source: "import",
updatedAt: now,
deletedAt: null
});
existingByHash.set(urlHash, state.bookmarks[0]);
imported++;
}
await saveLocalState(state);
return { foldersImported, 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");
}

View File

@@ -1,45 +1,45 @@
<script setup>
import { RouterView } from "vue-router";
</script>
<template>
<div class="shell">
<header class="nav">
<div class="brand">云书签 · 更多操作</div>
</header>
<main class="content">
<RouterView />
</main>
</div>
</template>
<style>
body { margin: 0; }
.shell { min-height: 100vh; }
.nav {
position: sticky;
top: 0;
background: rgba(248, 250, 252, 0.82);
border-bottom: 1px solid var(--bb-border);
backdrop-filter: blur(10px);
display: flex;
justify-content: flex-start;
padding: 10px 14px;
gap: 10px;
}
.brand { font-weight: 800; }
.content { max-width: 1100px; margin: 0 auto; padding: 14px; }
a:focus-visible,
button:focus-visible,
input:focus-visible {
outline: 2px solid rgba(59, 130, 246, 0.6);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; scroll-behavior: auto !important; }
}
</style>
<script setup>
import { RouterView } from "vue-router";
</script>
<template>
<div class="shell">
<header class="nav">
<div class="brand">云书签 · 更多操作</div>
</header>
<main class="content">
<RouterView />
</main>
</div>
</template>
<style>
body { margin: 0; }
.shell { min-height: 100vh; }
.nav {
position: sticky;
top: 0;
background: rgba(248, 250, 252, 0.82);
border-bottom: 1px solid var(--bb-border);
backdrop-filter: blur(10px);
display: flex;
justify-content: flex-start;
padding: 10px 14px;
gap: 10px;
}
.brand { font-weight: 800; }
.content { max-width: 1100px; margin: 0 auto; padding: 14px; }
a:focus-visible,
button:focus-visible,
input:focus-visible {
outline: 2px solid rgba(59, 130, 246, 0.6);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; scroll-behavior: auto !important; }
}
</style>

View File

@@ -1,7 +1,7 @@
import { createApp } from "vue";
import OptionsApp from "./OptionsApp.vue";
import { router } from "./router";
import "../style.css";
createApp(OptionsApp).use(router).mount("#app");
import { createApp } from "vue";
import OptionsApp from "./OptionsApp.vue";
import { router } from "./router";
import "../style.css";
createApp(OptionsApp).use(router).mount("#app");

View File

@@ -1,135 +1,135 @@
<script setup>
import { ref } from "vue";
import { apiFetch } from "../../lib/api";
import { getToken } from "../../lib/extStorage";
import { exportLocalToNetscapeHtml, importLocalFromNetscapeHtml, listLocalBookmarks } from "../../lib/localData";
const file = ref(null);
const status = ref("");
const error = ref("");
function onFileChange(e) {
file.value = e?.target?.files?.[0] || null;
}
async function importToLocal() {
status.value = "";
error.value = "";
if (!file.value) return;
try {
const text = await file.value.text();
const res = await importLocalFromNetscapeHtml(text, { visibility: "public" });
status.value = `本地导入完成:文件夹新增 ${res.foldersImported},书签新增 ${res.imported},合并 ${res.merged}`;
} catch (e) {
error.value = e.message || String(e);
}
}
async function importFile() {
status.value = "";
error.value = "";
const token = await getToken();
if (!token) {
await importToLocal();
return;
}
if (!file.value) return;
try {
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}`;
} catch (e) {
error.value = e.message || String(e);
}
}
async function exportCloud() {
status.value = "";
error.value = "";
try {
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);
}
}
async function exportLocal() {
status.value = "";
error.value = "";
try {
const bookmarks = await 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);
}
}
</script>
<template>
<section>
<h1>导入 / 导出</h1>
<div class="card">
<h2>导入书签 HTML写入云端</h2>
<input
type="file"
accept="text/html,.html"
@change="onFileChange"
/>
<button class="btn" @click="importFile">开始导入</button>
<p v-if="status" class="ok">{{ status }}</p>
<p v-if="error" class="error">{{ error }}</p>
</div>
<div class="card">
<h2>导出本地</h2>
<button class="btn" @click="exportLocal">导出本地为 HTML</button>
</div>
<div class="card">
<h2>导出云端</h2>
<button class="btn" @click="exportCloud">导出为 HTML</button>
</div>
</section>
</template>
<style scoped>
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; margin: 12px 0; }
.btn { margin-top: 10px; padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.ok { color: #065f46; }
.error { color: #b91c1c; }
</style>
<script setup>
import { ref } from "vue";
import { apiFetch } from "../../lib/api";
import { getToken } from "../../lib/extStorage";
import { exportLocalToNetscapeHtml, importLocalFromNetscapeHtml, listLocalBookmarks } from "../../lib/localData";
const file = ref(null);
const status = ref("");
const error = ref("");
function onFileChange(e) {
file.value = e?.target?.files?.[0] || null;
}
async function importToLocal() {
status.value = "";
error.value = "";
if (!file.value) return;
try {
const text = await file.value.text();
const res = await importLocalFromNetscapeHtml(text, { visibility: "public" });
status.value = `本地导入完成:文件夹新增 ${res.foldersImported},书签新增 ${res.imported},合并 ${res.merged}`;
} catch (e) {
error.value = e.message || String(e);
}
}
async function importFile() {
status.value = "";
error.value = "";
const token = await getToken();
if (!token) {
await importToLocal();
return;
}
if (!file.value) return;
try {
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}`;
} catch (e) {
error.value = e.message || String(e);
}
}
async function exportCloud() {
status.value = "";
error.value = "";
try {
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);
}
}
async function exportLocal() {
status.value = "";
error.value = "";
try {
const bookmarks = await 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);
}
}
</script>
<template>
<section>
<h1>导入 / 导出</h1>
<div class="card">
<h2>导入书签 HTML写入云端</h2>
<input
type="file"
accept="text/html,.html"
@change="onFileChange"
/>
<button class="btn" @click="importFile">开始导入</button>
<p v-if="status" class="ok">{{ status }}</p>
<p v-if="error" class="error">{{ error }}</p>
</div>
<div class="card">
<h2>导出本地</h2>
<button class="btn" @click="exportLocal">导出本地为 HTML</button>
</div>
<div class="card">
<h2>导出云端</h2>
<button class="btn" @click="exportCloud">导出为 HTML</button>
</div>
</section>
</template>
<style scoped>
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; margin: 12px 0; }
.btn { margin-top: 10px; padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.ok { color: #065f46; }
.error { color: #b91c1c; }
</style>

View File

@@ -1,77 +1,77 @@
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { apiFetch } from "../../lib/api";
import { setToken } from "../../lib/extStorage";
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 })
});
await setToken(res.token);
// Push local state to server on login
const payload = await mergeLocalToUser();
await apiFetch("/sync/push", {
method: "POST",
body: JSON.stringify(payload)
});
// After merge, keep extension in cloud mode
await clearLocalState();
await router.replace("/");
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
</script>
<template>
<section>
<h1>登录 / 注册</h1>
<div class="row">
<button class="tab" :class="{ active: mode === 'login' }" @click="mode = 'login'">登录</button>
<button class="tab" :class="{ active: mode === 'register' }" @click="mode = 'register'">注册</button>
</div>
<div class="form">
<input v-model="email" class="input" placeholder="邮箱" autocomplete="email" />
<input v-model="password" class="input" placeholder="密码(至少 8 位)" type="password" autocomplete="current-password" />
<button class="btn" :disabled="loading" @click="submit">提交</button>
<p v-if="error" class="error">{{ error }}</p>
</div>
<p class="muted">扩展内的本地书签存储在 chrome.storage.local</p>
</section>
</template>
<style scoped>
.row { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }
.tab { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f8fafc; cursor: pointer; }
.tab.active { border-color: #111827; background: #111827; color: white; }
.form { display: grid; gap: 10px; max-width: 560px; }
.input { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.error { color: #b91c1c; }
.muted { color: #475569; font-size: 12px; margin-top: 10px; }
</style>
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { apiFetch } from "../../lib/api";
import { setToken } from "../../lib/extStorage";
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 })
});
await setToken(res.token);
// Push local state to server on login
const payload = await mergeLocalToUser();
await apiFetch("/sync/push", {
method: "POST",
body: JSON.stringify(payload)
});
// After merge, keep extension in cloud mode
await clearLocalState();
await router.replace("/");
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
</script>
<template>
<section>
<h1>登录 / 注册</h1>
<div class="row">
<button class="tab" :class="{ active: mode === 'login' }" @click="mode = 'login'">登录</button>
<button class="tab" :class="{ active: mode === 'register' }" @click="mode = 'register'">注册</button>
</div>
<div class="form">
<input v-model="email" class="input" placeholder="邮箱" autocomplete="email" />
<input v-model="password" class="input" placeholder="密码(至少 8 位)" type="password" autocomplete="current-password" />
<button class="btn" :disabled="loading" @click="submit">提交</button>
<p v-if="error" class="error">{{ error }}</p>
</div>
<p class="muted">扩展内的本地书签存储在 chrome.storage.local</p>
</section>
</template>
<style scoped>
.row { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }
.tab { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f8fafc; cursor: pointer; }
.tab.active { border-color: #111827; background: #111827; color: white; }
.form { display: grid; gap: 10px; max-width: 560px; }
.input { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.error { color: #b91c1c; }
.muted { color: #475569; font-size: 12px; margin-top: 10px; }
</style>

View File

@@ -1,104 +1,104 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { getToken, setToken } from "../../lib/extStorage";
import BbConfirmModal from "../../components/BbConfirmModal.vue";
const router = useRouter();
const token = ref("");
const loggedIn = computed(() => Boolean(token.value));
const webBaseUrl = import.meta.env.VITE_WEB_BASE_URL || "http://localhost:5173";
async function refresh() {
token.value = await getToken();
}
function openWeb() {
const url = String(webBaseUrl || "").trim();
if (!url) return;
if (typeof chrome !== "undefined" && chrome.tabs?.create) chrome.tabs.create({ url });
else window.open(url, "_blank", "noopener,noreferrer");
}
const logoutModalOpen = ref(false);
const logoutStep = ref(1);
function startLogout() {
logoutStep.value = 1;
logoutModalOpen.value = true;
}
async function confirmLogout() {
if (logoutStep.value === 1) {
logoutStep.value = 2;
return;
}
await setToken("");
logoutModalOpen.value = false;
await refresh();
await router.replace("/login");
}
function cancelLogout() {
logoutModalOpen.value = false;
logoutStep.value = 1;
}
onMounted(refresh);
</script>
<template>
<section class="page">
<h1 class="h1">更多操作</h1>
<p v-if="!loggedIn" class="muted">当前未登录将跳转到登录页</p>
<div class="card">
<button class="btn" type="button" @click="openWeb">跳转 Web</button>
<button class="btn btn--secondary" type="button" @click="startLogout">退出登录</button>
<p class="hint">Web 地址来自环境变量VITE_WEB_BASE_URL</p>
</div>
<BbConfirmModal
v-model="logoutModalOpen"
title="退出登录"
:message="logoutStep === 1 ? '退出以后无法同步书签。' : '你确定要退出吗?'"
:confirm-text="logoutStep === 1 ? '继续' : '确定退出'"
cancel-text="取消"
:danger="logoutStep === 2"
@confirm="confirmLogout"
@cancel="cancelLogout"
/>
</section>
</template>
<style scoped>
.page { padding: 14px; }
.h1 { margin: 0 0 10px; font-size: 18px; }
.card {
max-width: 560px;
border: 1px solid rgba(255,255,255,0.65);
background: var(--bb-card);
border-radius: 18px;
padding: 12px;
display: grid;
gap: 10px;
}
.btn {
border: 1px solid rgba(255,255,255,0.25);
border-radius: 14px;
padding: 10px 12px;
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
color: white;
cursor: pointer;
}
.btn--secondary {
background: rgba(255,255,255,0.55);
color: var(--bb-text);
border-color: var(--bb-border);
}
.muted { color: rgba(19, 78, 74, 0.72); font-size: 12px; }
.hint { color: rgba(19, 78, 74, 0.72); font-size: 12px; margin: 0; }
</style>
<script setup>
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { getToken, setToken } from "../../lib/extStorage";
import BbConfirmModal from "../../components/BbConfirmModal.vue";
const router = useRouter();
const token = ref("");
const loggedIn = computed(() => Boolean(token.value));
const webBaseUrl = import.meta.env.VITE_WEB_BASE_URL || "http://localhost:5173";
async function refresh() {
token.value = await getToken();
}
function openWeb() {
const url = String(webBaseUrl || "").trim();
if (!url) return;
if (typeof chrome !== "undefined" && chrome.tabs?.create) chrome.tabs.create({ url });
else window.open(url, "_blank", "noopener,noreferrer");
}
const logoutModalOpen = ref(false);
const logoutStep = ref(1);
function startLogout() {
logoutStep.value = 1;
logoutModalOpen.value = true;
}
async function confirmLogout() {
if (logoutStep.value === 1) {
logoutStep.value = 2;
return;
}
await setToken("");
logoutModalOpen.value = false;
await refresh();
await router.replace("/login");
}
function cancelLogout() {
logoutModalOpen.value = false;
logoutStep.value = 1;
}
onMounted(refresh);
</script>
<template>
<section class="page">
<h1 class="h1">更多操作</h1>
<p v-if="!loggedIn" class="muted">当前未登录将跳转到登录页</p>
<div class="card">
<button class="btn" type="button" @click="openWeb">跳转 Web</button>
<button class="btn btn--secondary" type="button" @click="startLogout">退出登录</button>
<p class="hint">Web 地址来自环境变量VITE_WEB_BASE_URL</p>
</div>
<BbConfirmModal
v-model="logoutModalOpen"
title="退出登录"
:message="logoutStep === 1 ? '退出以后无法同步书签。' : '你确定要退出吗?'"
:confirm-text="logoutStep === 1 ? '继续' : '确定退出'"
cancel-text="取消"
:danger="logoutStep === 2"
@confirm="confirmLogout"
@cancel="cancelLogout"
/>
</section>
</template>
<style scoped>
.page { padding: 14px; }
.h1 { margin: 0 0 10px; font-size: 18px; }
.card {
max-width: 560px;
border: 1px solid rgba(255,255,255,0.65);
background: var(--bb-card);
border-radius: 18px;
padding: 12px;
display: grid;
gap: 10px;
}
.btn {
border: 1px solid rgba(255,255,255,0.25);
border-radius: 14px;
padding: 10px 12px;
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
color: white;
cursor: pointer;
}
.btn--secondary {
background: rgba(255,255,255,0.55);
color: var(--bb-text);
border-color: var(--bb-border);
}
.muted { color: rgba(19, 78, 74, 0.72); font-size: 12px; }
.hint { color: rgba(19, 78, 74, 0.72); font-size: 12px; margin: 0; }
</style>

View File

@@ -1,134 +1,134 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { apiFetch } from "../../lib/api";
import { getToken } from "../../lib/extStorage";
import { listLocalBookmarks, markLocalDeleted, upsertLocalBookmark } from "../../lib/localData";
import BbConfirmModal from "../../components/BbConfirmModal.vue";
const token = ref("");
const loggedIn = computed(() => Boolean(token.value));
const items = ref([]);
const error = ref("");
const mode = computed(() => (loggedIn.value ? "cloud" : "local"));
const title = ref("");
const url = ref("");
async function load() {
error.value = "";
try {
token.value = await getToken();
if (!token.value) {
items.value = await listLocalBookmarks();
return;
}
items.value = await apiFetch("/bookmarks");
} catch (e) {
error.value = e.message || String(e);
}
}
async function add() {
if (!title.value || !url.value) return;
if (mode.value === "cloud") {
await apiFetch("/bookmarks", {
method: "POST",
body: JSON.stringify({ folderId: null, title: title.value, url: url.value, visibility: "public" })
});
} else {
await upsertLocalBookmark({ title: title.value, url: url.value, visibility: "public" });
}
title.value = "";
url.value = "";
await load();
}
async function remove(id) {
if (mode.value !== "local") return;
pendingDeleteId.value = id;
deleteConfirmOpen.value = true;
}
const deleteConfirmOpen = ref(false);
const pendingDeleteId = ref("");
async function confirmDelete() {
const id = pendingDeleteId.value;
if (!id) {
deleteConfirmOpen.value = false;
return;
}
await markLocalDeleted(id);
pendingDeleteId.value = "";
deleteConfirmOpen.value = false;
await load();
}
function cancelDelete() {
pendingDeleteId.value = "";
deleteConfirmOpen.value = false;
}
onMounted(load);
</script>
<template>
<section>
<h1>我的书签{{ mode === 'cloud' ? '云端' : '本地' }}</h1>
<p v-if="!loggedIn" class="muted">未登录时书签保存在扩展本地可在登录后自动合并上云</p>
<div class="form">
<input v-model="title" class="input" placeholder="标题" />
<input v-model="url" class="input" placeholder="链接" />
<button class="btn" @click="add">添加</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<ul class="list">
<li v-for="b in items" :key="b.id" class="card">
<div class="row">
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
<button v-if="mode === 'local'" class="ghost" @click.prevent="remove(b.id)">删除</button>
</div>
<div class="muted">{{ b.url }}</div>
</li>
</ul>
<BbConfirmModal
v-model="deleteConfirmOpen"
title="删除书签"
message="确定删除该书签?"
confirm-text="删除"
cancel-text="取消"
:danger="true"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</section>
</template>
<style scoped>
.form { display: grid; gap: 10px; grid-template-columns: 1fr; margin: 12px 0; }
@media (min-width: 900px) { .form { grid-template-columns: 2fr 3fr auto; } }
.input { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.row { display: flex; justify-content: space-between; align-items: start; gap: 10px; }
.ghost { border: 1px solid #e5e7eb; background: #f8fafc; border-radius: 10px; padding: 6px 10px; cursor: pointer; }
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } }
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; }
.title {
color: #111827;
font-weight: 700;
text-decoration: none;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
.error { color: #b91c1c; }
.muted { color: #475569; font-size: 12px; }
</style>
<script setup>
import { computed, onMounted, ref } from "vue";
import { apiFetch } from "../../lib/api";
import { getToken } from "../../lib/extStorage";
import { listLocalBookmarks, markLocalDeleted, upsertLocalBookmark } from "../../lib/localData";
import BbConfirmModal from "../../components/BbConfirmModal.vue";
const token = ref("");
const loggedIn = computed(() => Boolean(token.value));
const items = ref([]);
const error = ref("");
const mode = computed(() => (loggedIn.value ? "cloud" : "local"));
const title = ref("");
const url = ref("");
async function load() {
error.value = "";
try {
token.value = await getToken();
if (!token.value) {
items.value = await listLocalBookmarks();
return;
}
items.value = await apiFetch("/bookmarks");
} catch (e) {
error.value = e.message || String(e);
}
}
async function add() {
if (!title.value || !url.value) return;
if (mode.value === "cloud") {
await apiFetch("/bookmarks", {
method: "POST",
body: JSON.stringify({ folderId: null, title: title.value, url: url.value, visibility: "public" })
});
} else {
await upsertLocalBookmark({ title: title.value, url: url.value, visibility: "public" });
}
title.value = "";
url.value = "";
await load();
}
async function remove(id) {
if (mode.value !== "local") return;
pendingDeleteId.value = id;
deleteConfirmOpen.value = true;
}
const deleteConfirmOpen = ref(false);
const pendingDeleteId = ref("");
async function confirmDelete() {
const id = pendingDeleteId.value;
if (!id) {
deleteConfirmOpen.value = false;
return;
}
await markLocalDeleted(id);
pendingDeleteId.value = "";
deleteConfirmOpen.value = false;
await load();
}
function cancelDelete() {
pendingDeleteId.value = "";
deleteConfirmOpen.value = false;
}
onMounted(load);
</script>
<template>
<section>
<h1>我的书签{{ mode === 'cloud' ? '云端' : '本地' }}</h1>
<p v-if="!loggedIn" class="muted">未登录时书签保存在扩展本地可在登录后自动合并上云</p>
<div class="form">
<input v-model="title" class="input" placeholder="标题" />
<input v-model="url" class="input" placeholder="链接" />
<button class="btn" @click="add">添加</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<ul class="list">
<li v-for="b in items" :key="b.id" class="card">
<div class="row">
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
<button v-if="mode === 'local'" class="ghost" @click.prevent="remove(b.id)">删除</button>
</div>
<div class="muted">{{ b.url }}</div>
</li>
</ul>
<BbConfirmModal
v-model="deleteConfirmOpen"
title="删除书签"
message="确定删除该书签?"
confirm-text="删除"
cancel-text="取消"
:danger="true"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</section>
</template>
<style scoped>
.form { display: grid; gap: 10px; grid-template-columns: 1fr; margin: 12px 0; }
@media (min-width: 900px) { .form { grid-template-columns: 2fr 3fr auto; } }
.input { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.row { display: flex; justify-content: space-between; align-items: start; gap: 10px; }
.ghost { border: 1px solid #e5e7eb; background: #f8fafc; border-radius: 10px; padding: 6px 10px; cursor: pointer; }
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } }
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; }
.title {
color: #111827;
font-weight: 700;
text-decoration: none;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
.error { color: #b91c1c; }
.muted { color: #475569; font-size: 12px; }
</style>

View File

@@ -1,92 +1,92 @@
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { apiFetch } from "../../lib/api";
const items = ref([]);
const q = ref("");
const loading = ref(false);
const error = ref("");
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);
</script>
<template>
<section>
<h1>公开书签</h1>
<div class="row">
<div class="searchWrap">
<input v-model="q" class="input input--withClear" placeholder="搜索" />
<button v-if="q.trim()" class="clearBtn" type="button" aria-label="清空搜索" @click="q = ''">×</button>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<ul class="list">
<li v-for="b in items" :key="b.id" class="card">
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
<div class="muted">{{ b.url }}</div>
</li>
</ul>
</section>
</template>
<style scoped>
.row { display: flex; gap: 8px; margin: 12px 0; }
.searchWrap { flex: 1; position: relative; }
.input { width: 100%; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
.input.input--withClear { padding-right: 40px; }
.clearBtn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 28px;
border-radius: 999px;
border: 1px solid #e5e7eb;
background: white;
cursor: pointer;
}
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } }
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; }
.title {
color: #111827;
font-weight: 700;
text-decoration: none;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
.error { color: #b91c1c; }
</style>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { apiFetch } from "../../lib/api";
const items = ref([]);
const q = ref("");
const loading = ref(false);
const error = ref("");
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);
</script>
<template>
<section>
<h1>公开书签</h1>
<div class="row">
<div class="searchWrap">
<input v-model="q" class="input input--withClear" placeholder="搜索" />
<button v-if="q.trim()" class="clearBtn" type="button" aria-label="清空搜索" @click="q = ''">×</button>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<ul class="list">
<li v-for="b in items" :key="b.id" class="card">
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
<div class="muted">{{ b.url }}</div>
</li>
</ul>
</section>
</template>
<style scoped>
.row { display: flex; gap: 8px; margin: 12px 0; }
.searchWrap { flex: 1; position: relative; }
.input { width: 100%; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
.input.input--withClear { padding-right: 40px; }
.clearBtn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 28px;
border-radius: 999px;
border: 1px solid #e5e7eb;
background: white;
cursor: pointer;
}
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } }
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; }
.title {
color: #111827;
font-weight: 700;
text-decoration: none;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
.error { color: #b91c1c; }
</style>

View File

@@ -1,22 +1,22 @@
import { createRouter, createWebHashHistory } from "vue-router";
import LoginPage from "./pages/LoginPage.vue";
import MorePage from "./pages/MorePage.vue";
import { getToken } from "../lib/extStorage";
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: "/", component: MorePage },
{ path: "/login", component: LoginPage }
]
});
router.beforeEach(async (to) => {
const token = await getToken();
const authed = Boolean(token);
if (!authed && to.path !== "/login") return "/login";
if (authed && to.path === "/login") return "/";
return true;
});
import { createRouter, createWebHashHistory } from "vue-router";
import LoginPage from "./pages/LoginPage.vue";
import MorePage from "./pages/MorePage.vue";
import { getToken } from "../lib/extStorage";
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: "/", component: MorePage },
{ path: "/login", component: LoginPage }
]
});
router.beforeEach(async (to) => {
const token = await getToken();
const authed = Boolean(token);
if (!authed && to.path !== "/login") return "/login";
if (authed && to.path === "/login") return "/";
return true;
});

View File

@@ -1,494 +1,494 @@
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import { apiFetch } from "../lib/api";
import { getToken } from "../lib/extStorage";
const view = ref("list"); // add | list
const token = ref("");
const loggedIn = computed(() => Boolean(token.value));
const loading = ref(false);
const error = ref("");
function openOptions() {
if (typeof chrome !== "undefined" && chrome.runtime?.openOptionsPage) {
chrome.runtime.openOptionsPage();
}
}
function openUrl(url) {
if (!url) return;
if (typeof chrome !== "undefined" && chrome.tabs?.create) {
chrome.tabs.create({ url });
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
}
async function refreshAuth() {
token.value = await getToken();
}
// folders + bookmarks
const q = ref("");
const folders = ref([]);
const items = ref([]);
const openFolderIds = ref(new Set());
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);
}
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);
}
async function loadFolders() {
const list = await apiFetch("/folders");
folders.value = Array.isArray(list) ? list : [];
}
async function loadBookmarks() {
const qs = q.value.trim() ? `?q=${encodeURIComponent(q.value.trim())}` : "";
const list = await apiFetch(`/bookmarks${qs}`);
items.value = Array.isArray(list) ? list : [];
}
async function loadAll() {
if (!loggedIn.value) return;
loading.value = true;
error.value = "";
try {
await Promise.all([loadFolders(), loadBookmarks()]);
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
// add current page
const addBusy = ref(false);
const addStatus = ref("");
const addFolderId = ref(null);
const addTitle = ref("");
const addUrl = ref("");
async function getActiveTabPage() {
try {
if (typeof chrome !== "undefined" && chrome.tabs?.query) {
const tabs = await new Promise((resolve) => {
chrome.tabs.query({ active: true, currentWindow: true }, (result) => resolve(result || []));
});
const tab = tabs?.[0];
return { title: tab?.title || "", url: tab?.url || "" };
}
} catch {
// ignore
}
return { title: "", url: "" };
}
async function prepareAddCurrent() {
addStatus.value = "";
error.value = "";
addFolderId.value = null;
const page = await getActiveTabPage();
addTitle.value = String(page.title || "").trim() || String(page.url || "").trim();
addUrl.value = String(page.url || "").trim();
if (loggedIn.value) {
await loadFolders().catch(() => {});
}
}
async function submitAddCurrent() {
addStatus.value = "";
error.value = "";
if (!loggedIn.value) {
error.value = "请先在『更多操作』里登录";
return;
}
const t = addTitle.value.trim() || addUrl.value.trim();
const u = addUrl.value.trim();
if (!u) return;
try {
addBusy.value = true;
await apiFetch("/bookmarks", {
method: "POST",
body: JSON.stringify({
folderId: addFolderId.value ?? null,
title: t,
url: u,
visibility: "private"
})
});
addStatus.value = "已添加";
if (view.value === "list") await loadBookmarks();
} catch (e) {
error.value = e.message || String(e);
} finally {
addBusy.value = false;
}
}
// create folder (cloud only)
const folderName = ref("");
const folderBusy = ref(false);
const folderModalOpen = ref(false);
async function createFolder() {
error.value = "";
const name = folderName.value.trim();
if (!name) return;
if (!loggedIn.value) {
error.value = "请先登录";
return;
}
try {
folderBusy.value = true;
await apiFetch("/folders", {
method: "POST",
body: JSON.stringify({ parentId: null, name, visibility: "private" })
});
folderName.value = "";
folderModalOpen.value = false;
await loadFolders();
} catch (e) {
error.value = e.message || String(e);
} finally {
folderBusy.value = false;
}
}
onMounted(async () => {
await refreshAuth();
await prepareAddCurrent();
if (loggedIn.value) await loadAll();
});
watch(
() => q.value,
async () => {
if (!loggedIn.value) return;
await loadBookmarks();
}
);
</script>
<template>
<div class="wrap">
<header class="top">
<div class="brand">云书签</div>
<button class="btn btn--secondary" type="button" @click="openOptions">更多操作</button>
</header>
<div class="seg">
<button class="segBtn" :class="view === 'add' ? 'is-active' : ''" type="button" @click="view = 'add'">
一键添加书签
</button>
<button class="segBtn" :class="view === 'list' ? 'is-active' : ''" type="button" @click="view = 'list'">
书签目录
</button>
</div>
<p v-if="!loggedIn" class="hint">未登录请点右上角更多操作先登录</p>
<p v-if="error" class="alert">{{ error }}</p>
<p v-if="addStatus" class="ok">{{ addStatus }}</p>
<section v-if="view === 'add'" class="card">
<div class="cardTitle">一键添加书签</div>
<div class="muted">会自动读取标题和链接你也可以手动修改</div>
<label class="label">标题</label>
<input v-model="addTitle" class="input" placeholder="标题" />
<label class="label">链接</label>
<input v-model="addUrl" class="input" placeholder="https://..." />
<label class="label">文件夹不选则未分组</label>
<select v-model="addFolderId" class="input" :disabled="!loggedIn">
<option :value="null">未分组</option>
<option v-for="f in folders" :key="f.id" :value="f.id">{{ f.name }}</option>
</select>
<div class="row">
<button class="btn btn--secondary" type="button" @click="prepareAddCurrent" :disabled="addBusy">
重新读取
</button>
<button class="btn" type="button" @click="submitAddCurrent" :disabled="addBusy || !addUrl">
{{ addBusy ? '添加中…' : '添加' }}
</button>
</div>
</section>
<section v-else class="card">
<div class="titleRow">
<div class="cardTitle">书签目录</div>
<button class="miniBtn" type="button" :disabled="!loggedIn" @click="folderModalOpen = true">新增文件夹</button>
</div>
<div class="row" style="margin-top: 8px;">
<div class="searchWrap">
<input v-model="q" class="input input--withClear" placeholder="搜索标题/链接" :disabled="!loggedIn" />
<button
v-if="q.trim()"
class="clearBtn"
type="button"
aria-label="清空搜索"
@click="q = ''"
>
×
</button>
</div>
<button class="btn btn--secondary" type="button" @click="loadAll" :disabled="!loggedIn || loading">刷新</button>
</div>
<div v-if="folderModalOpen" class="modal" @click.self="folderModalOpen = false">
<div class="dialog" role="dialog" aria-modal="true" aria-label="新增文件夹">
<div class="dialogTitle">新增文件夹</div>
<input
v-model="folderName"
class="input"
placeholder="文件夹名称"
:disabled="!loggedIn || folderBusy"
@keyup.enter="createFolder"
/>
<div class="dialogActions">
<button class="btn btn--secondary" type="button" @click="folderModalOpen = false" :disabled="folderBusy">取消</button>
<button class="btn" type="button" @click="createFolder" :disabled="!loggedIn || folderBusy">
{{ folderBusy ? '创建中…' : '创建' }}
</button>
</div>
</div>
</div>
<div v-if="loading" class="muted" style="margin-top: 10px;">加载中</div>
<div v-if="loggedIn" class="tree">
<div class="folder" :class="isFolderOpen('ROOT') ? 'is-open' : ''">
<button class="folderHeader" type="button" @click="toggleFolder('ROOT')">
<span class="folderName">未分组</span>
<span class="folderMeta">{{ folderCount(null) }} </span>
</button>
<div v-if="isFolderOpen('ROOT')" class="folderBody">
<button
v-for="b in bookmarksByFolderId.get(null) || []"
:key="b.id"
type="button"
class="bm"
@click="openUrl(b.url)"
>
<div class="bmTitle">{{ b.title || b.url }}</div>
<div class="bmUrl">{{ b.url }}</div>
</button>
</div>
</div>
<div v-for="f in folders" :key="f.id" class="folder" :class="isFolderOpen(f.id) ? 'is-open' : ''">
<button class="folderHeader" type="button" @click="toggleFolder(f.id)">
<span class="folderName">{{ f.name }}</span>
<span class="folderMeta">{{ folderCount(f.id) }} </span>
</button>
<div v-if="isFolderOpen(f.id)" class="folderBody">
<button
v-for="b in bookmarksByFolderId.get(f.id) || []"
:key="b.id"
type="button"
class="bm"
@click="openUrl(b.url)"
>
<div class="bmTitle">{{ b.title || b.url }}</div>
<div class="bmUrl">{{ b.url }}</div>
</button>
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.wrap{
width: 380px;
padding: 12px;
font-family: ui-sans-serif, system-ui;
color: var(--bb-text);
}
.top{ display:flex; justify-content:space-between; align-items:center; gap:10px; }
.brand{ font-weight: 900; letter-spacing: 0.5px; }
.seg{ display:flex; gap:8px; margin-top: 10px; }
.segBtn{
flex:1;
border: 1px solid var(--bb-border);
background: rgba(255,255,255,0.85);
padding: 8px 10px;
border-radius: 14px;
cursor:pointer;
}
.segBtn.is-active{
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
color: white;
border-color: rgba(255,255,255,0.35);
}
.btn{
border: 1px solid rgba(15, 23, 42, 0.10);
border-radius: 14px;
padding: 8px 12px;
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
color: white;
cursor: pointer;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.12);
}
.btn--secondary{
background: rgba(255,255,255,0.92);
color: var(--bb-text);
border-color: var(--bb-border);
box-shadow: none;
}
button:disabled{ opacity: 0.6; cursor: not-allowed; }
.hint{ margin: 10px 0 0; color: var(--bb-muted); font-size: 12px; }
.alert{ margin: 10px 0 0; padding: 8px 10px; border-radius: 12px; border: 1px solid rgba(248,113,113,0.35); background: rgba(248,113,113,0.08); color: #7f1d1d; font-size: 12px; }
.ok{ margin: 10px 0 0; padding: 8px 10px; border-radius: 12px; border: 1px solid rgba(34,197,94,0.35); background: rgba(34,197,94,0.10); color: #14532d; font-size: 12px; }
.card{
margin-top: 10px;
border: 1px solid rgba(255,255,255,0.65);
background: var(--bb-card);
border-radius: 18px;
padding: 12px;
backdrop-filter: blur(10px);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.10);
}
.cardTitle{ font-weight: 900; }
.muted{ color: var(--bb-muted); font-size: 12px; margin-top: 4px; }
.label{ display:block; margin-top: 10px; font-size: 12px; color: rgba(19, 78, 74, 0.72); }
.input{
width: 100%;
margin-top: 6px;
padding: 8px 10px;
border-radius: 14px;
border: 1px solid var(--bb-border);
background: rgba(255,255,255,0.92);
}
.searchWrap{ position: relative; flex: 1; min-width: 0; }
.input.input--withClear{ padding-right: 40px; }
.clearBtn{
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 26px;
height: 26px;
border-radius: 999px;
border: 1px solid var(--bb-border);
background: rgba(255,255,255,0.92);
cursor: pointer;
display: grid;
place-items: center;
color: rgba(15, 23, 42, 0.7);
padding: 0;
}
.clearBtn:hover{ background: rgba(255,255,255,1); }
.row{ display:flex; gap: 8px; align-items:center; margin-top: 10px; }
.row .input{ margin-top: 0; }
.subCard{ margin-top: 10px; padding: 10px; border-radius: 16px; border: 1px dashed rgba(19,78,74,0.22); background: rgba(255,255,255,0.55); }
.subTitle{ font-weight: 800; }
.titleRow{ display:flex; align-items:center; justify-content:space-between; gap:10px; }
.miniBtn{
padding: 6px 10px;
border-radius: 12px;
border: 1px solid var(--bb-border);
background: rgba(255,255,255,0.92);
cursor: pointer;
font-size: 12px;
font-weight: 700;
box-shadow: none;
}
.miniBtn:disabled{ opacity: 0.6; cursor: not-allowed; }
.modal{
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
display: flex;
align-items: center;
justify-content: center;
padding: 14px;
z-index: 50;
}
.dialog{
width: 100%;
max-width: 340px;
border-radius: 18px;
border: 1px solid rgba(255,255,255,0.65);
background: var(--bb-card-solid);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.25);
padding: 12px;
}
.dialogTitle{ font-weight: 900; margin-bottom: 8px; }
.dialogActions{ display:flex; justify-content:flex-end; gap: 8px; margin-top: 10px; }
.tree{ margin-top: 10px; display: grid; gap: 10px; }
.folder{ border: 1px solid rgba(255,255,255,0.55); border-radius: 16px; background: rgba(255,255,255,0.55); }
.folderHeader{
width: 100%;
text-align: left;
padding: 8px 10px;
border: 0;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.folderName{ font-weight: 900; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.folderMeta{ font-size: 12px; color: rgba(19, 78, 74, 0.72); }
.folderBody{ padding: 8px 10px 10px; display: grid; gap: 8px; }
.bm{
border: 1px solid rgba(255,255,255,0.65);
background: rgba(255,255,255,0.92);
border-radius: 14px;
padding: 8px 10px;
cursor: pointer;
text-align: left;
}
.bmTitle{ font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bmUrl{ font-size: 12px; color: rgba(19, 78, 74, 0.72); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 4px; }
</style>
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import { apiFetch } from "../lib/api";
import { getToken } from "../lib/extStorage";
const view = ref("list"); // add | list
const token = ref("");
const loggedIn = computed(() => Boolean(token.value));
const loading = ref(false);
const error = ref("");
function openOptions() {
if (typeof chrome !== "undefined" && chrome.runtime?.openOptionsPage) {
chrome.runtime.openOptionsPage();
}
}
function openUrl(url) {
if (!url) return;
if (typeof chrome !== "undefined" && chrome.tabs?.create) {
chrome.tabs.create({ url });
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
}
async function refreshAuth() {
token.value = await getToken();
}
// folders + bookmarks
const q = ref("");
const folders = ref([]);
const items = ref([]);
const openFolderIds = ref(new Set());
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);
}
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);
}
async function loadFolders() {
const list = await apiFetch("/folders");
folders.value = Array.isArray(list) ? list : [];
}
async function loadBookmarks() {
const qs = q.value.trim() ? `?q=${encodeURIComponent(q.value.trim())}` : "";
const list = await apiFetch(`/bookmarks${qs}`);
items.value = Array.isArray(list) ? list : [];
}
async function loadAll() {
if (!loggedIn.value) return;
loading.value = true;
error.value = "";
try {
await Promise.all([loadFolders(), loadBookmarks()]);
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
// add current page
const addBusy = ref(false);
const addStatus = ref("");
const addFolderId = ref(null);
const addTitle = ref("");
const addUrl = ref("");
async function getActiveTabPage() {
try {
if (typeof chrome !== "undefined" && chrome.tabs?.query) {
const tabs = await new Promise((resolve) => {
chrome.tabs.query({ active: true, currentWindow: true }, (result) => resolve(result || []));
});
const tab = tabs?.[0];
return { title: tab?.title || "", url: tab?.url || "" };
}
} catch {
// ignore
}
return { title: "", url: "" };
}
async function prepareAddCurrent() {
addStatus.value = "";
error.value = "";
addFolderId.value = null;
const page = await getActiveTabPage();
addTitle.value = String(page.title || "").trim() || String(page.url || "").trim();
addUrl.value = String(page.url || "").trim();
if (loggedIn.value) {
await loadFolders().catch(() => {});
}
}
async function submitAddCurrent() {
addStatus.value = "";
error.value = "";
if (!loggedIn.value) {
error.value = "请先在『更多操作』里登录";
return;
}
const t = addTitle.value.trim() || addUrl.value.trim();
const u = addUrl.value.trim();
if (!u) return;
try {
addBusy.value = true;
await apiFetch("/bookmarks", {
method: "POST",
body: JSON.stringify({
folderId: addFolderId.value ?? null,
title: t,
url: u,
visibility: "private"
})
});
addStatus.value = "已添加";
if (view.value === "list") await loadBookmarks();
} catch (e) {
error.value = e.message || String(e);
} finally {
addBusy.value = false;
}
}
// create folder (cloud only)
const folderName = ref("");
const folderBusy = ref(false);
const folderModalOpen = ref(false);
async function createFolder() {
error.value = "";
const name = folderName.value.trim();
if (!name) return;
if (!loggedIn.value) {
error.value = "请先登录";
return;
}
try {
folderBusy.value = true;
await apiFetch("/folders", {
method: "POST",
body: JSON.stringify({ parentId: null, name, visibility: "private" })
});
folderName.value = "";
folderModalOpen.value = false;
await loadFolders();
} catch (e) {
error.value = e.message || String(e);
} finally {
folderBusy.value = false;
}
}
onMounted(async () => {
await refreshAuth();
await prepareAddCurrent();
if (loggedIn.value) await loadAll();
});
watch(
() => q.value,
async () => {
if (!loggedIn.value) return;
await loadBookmarks();
}
);
</script>
<template>
<div class="wrap">
<header class="top">
<div class="brand">云书签</div>
<button class="btn btn--secondary" type="button" @click="openOptions">更多操作</button>
</header>
<div class="seg">
<button class="segBtn" :class="view === 'add' ? 'is-active' : ''" type="button" @click="view = 'add'">
一键添加书签
</button>
<button class="segBtn" :class="view === 'list' ? 'is-active' : ''" type="button" @click="view = 'list'">
书签目录
</button>
</div>
<p v-if="!loggedIn" class="hint">未登录请点右上角更多操作先登录</p>
<p v-if="error" class="alert">{{ error }}</p>
<p v-if="addStatus" class="ok">{{ addStatus }}</p>
<section v-if="view === 'add'" class="card">
<div class="cardTitle">一键添加书签</div>
<div class="muted">会自动读取标题和链接你也可以手动修改</div>
<label class="label">标题</label>
<input v-model="addTitle" class="input" placeholder="标题" />
<label class="label">链接</label>
<input v-model="addUrl" class="input" placeholder="https://..." />
<label class="label">文件夹不选则未分组</label>
<select v-model="addFolderId" class="input" :disabled="!loggedIn">
<option :value="null">未分组</option>
<option v-for="f in folders" :key="f.id" :value="f.id">{{ f.name }}</option>
</select>
<div class="row">
<button class="btn btn--secondary" type="button" @click="prepareAddCurrent" :disabled="addBusy">
重新读取
</button>
<button class="btn" type="button" @click="submitAddCurrent" :disabled="addBusy || !addUrl">
{{ addBusy ? '添加中…' : '添加' }}
</button>
</div>
</section>
<section v-else class="card">
<div class="titleRow">
<div class="cardTitle">书签目录</div>
<button class="miniBtn" type="button" :disabled="!loggedIn" @click="folderModalOpen = true">新增文件夹</button>
</div>
<div class="row" style="margin-top: 8px;">
<div class="searchWrap">
<input v-model="q" class="input input--withClear" placeholder="搜索标题/链接" :disabled="!loggedIn" />
<button
v-if="q.trim()"
class="clearBtn"
type="button"
aria-label="清空搜索"
@click="q = ''"
>
×
</button>
</div>
<button class="btn btn--secondary" type="button" @click="loadAll" :disabled="!loggedIn || loading">刷新</button>
</div>
<div v-if="folderModalOpen" class="modal" @click.self="folderModalOpen = false">
<div class="dialog" role="dialog" aria-modal="true" aria-label="新增文件夹">
<div class="dialogTitle">新增文件夹</div>
<input
v-model="folderName"
class="input"
placeholder="文件夹名称"
:disabled="!loggedIn || folderBusy"
@keyup.enter="createFolder"
/>
<div class="dialogActions">
<button class="btn btn--secondary" type="button" @click="folderModalOpen = false" :disabled="folderBusy">取消</button>
<button class="btn" type="button" @click="createFolder" :disabled="!loggedIn || folderBusy">
{{ folderBusy ? '创建中…' : '创建' }}
</button>
</div>
</div>
</div>
<div v-if="loading" class="muted" style="margin-top: 10px;">加载中</div>
<div v-if="loggedIn" class="tree">
<div class="folder" :class="isFolderOpen('ROOT') ? 'is-open' : ''">
<button class="folderHeader" type="button" @click="toggleFolder('ROOT')">
<span class="folderName">未分组</span>
<span class="folderMeta">{{ folderCount(null) }} </span>
</button>
<div v-if="isFolderOpen('ROOT')" class="folderBody">
<button
v-for="b in bookmarksByFolderId.get(null) || []"
:key="b.id"
type="button"
class="bm"
@click="openUrl(b.url)"
>
<div class="bmTitle">{{ b.title || b.url }}</div>
<div class="bmUrl">{{ b.url }}</div>
</button>
</div>
</div>
<div v-for="f in folders" :key="f.id" class="folder" :class="isFolderOpen(f.id) ? 'is-open' : ''">
<button class="folderHeader" type="button" @click="toggleFolder(f.id)">
<span class="folderName">{{ f.name }}</span>
<span class="folderMeta">{{ folderCount(f.id) }} </span>
</button>
<div v-if="isFolderOpen(f.id)" class="folderBody">
<button
v-for="b in bookmarksByFolderId.get(f.id) || []"
:key="b.id"
type="button"
class="bm"
@click="openUrl(b.url)"
>
<div class="bmTitle">{{ b.title || b.url }}</div>
<div class="bmUrl">{{ b.url }}</div>
</button>
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.wrap{
width: 380px;
padding: 12px;
font-family: ui-sans-serif, system-ui;
color: var(--bb-text);
}
.top{ display:flex; justify-content:space-between; align-items:center; gap:10px; }
.brand{ font-weight: 900; letter-spacing: 0.5px; }
.seg{ display:flex; gap:8px; margin-top: 10px; }
.segBtn{
flex:1;
border: 1px solid var(--bb-border);
background: rgba(255,255,255,0.85);
padding: 8px 10px;
border-radius: 14px;
cursor:pointer;
}
.segBtn.is-active{
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
color: white;
border-color: rgba(255,255,255,0.35);
}
.btn{
border: 1px solid rgba(15, 23, 42, 0.10);
border-radius: 14px;
padding: 8px 12px;
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
color: white;
cursor: pointer;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.12);
}
.btn--secondary{
background: rgba(255,255,255,0.92);
color: var(--bb-text);
border-color: var(--bb-border);
box-shadow: none;
}
button:disabled{ opacity: 0.6; cursor: not-allowed; }
.hint{ margin: 10px 0 0; color: var(--bb-muted); font-size: 12px; }
.alert{ margin: 10px 0 0; padding: 8px 10px; border-radius: 12px; border: 1px solid rgba(248,113,113,0.35); background: rgba(248,113,113,0.08); color: #7f1d1d; font-size: 12px; }
.ok{ margin: 10px 0 0; padding: 8px 10px; border-radius: 12px; border: 1px solid rgba(34,197,94,0.35); background: rgba(34,197,94,0.10); color: #14532d; font-size: 12px; }
.card{
margin-top: 10px;
border: 1px solid rgba(255,255,255,0.65);
background: var(--bb-card);
border-radius: 18px;
padding: 12px;
backdrop-filter: blur(10px);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.10);
}
.cardTitle{ font-weight: 900; }
.muted{ color: var(--bb-muted); font-size: 12px; margin-top: 4px; }
.label{ display:block; margin-top: 10px; font-size: 12px; color: rgba(19, 78, 74, 0.72); }
.input{
width: 100%;
margin-top: 6px;
padding: 8px 10px;
border-radius: 14px;
border: 1px solid var(--bb-border);
background: rgba(255,255,255,0.92);
}
.searchWrap{ position: relative; flex: 1; min-width: 0; }
.input.input--withClear{ padding-right: 40px; }
.clearBtn{
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 26px;
height: 26px;
border-radius: 999px;
border: 1px solid var(--bb-border);
background: rgba(255,255,255,0.92);
cursor: pointer;
display: grid;
place-items: center;
color: rgba(15, 23, 42, 0.7);
padding: 0;
}
.clearBtn:hover{ background: rgba(255,255,255,1); }
.row{ display:flex; gap: 8px; align-items:center; margin-top: 10px; }
.row .input{ margin-top: 0; }
.subCard{ margin-top: 10px; padding: 10px; border-radius: 16px; border: 1px dashed rgba(19,78,74,0.22); background: rgba(255,255,255,0.55); }
.subTitle{ font-weight: 800; }
.titleRow{ display:flex; align-items:center; justify-content:space-between; gap:10px; }
.miniBtn{
padding: 6px 10px;
border-radius: 12px;
border: 1px solid var(--bb-border);
background: rgba(255,255,255,0.92);
cursor: pointer;
font-size: 12px;
font-weight: 700;
box-shadow: none;
}
.miniBtn:disabled{ opacity: 0.6; cursor: not-allowed; }
.modal{
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
display: flex;
align-items: center;
justify-content: center;
padding: 14px;
z-index: 50;
}
.dialog{
width: 100%;
max-width: 340px;
border-radius: 18px;
border: 1px solid rgba(255,255,255,0.65);
background: var(--bb-card-solid);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.25);
padding: 12px;
}
.dialogTitle{ font-weight: 900; margin-bottom: 8px; }
.dialogActions{ display:flex; justify-content:flex-end; gap: 8px; margin-top: 10px; }
.tree{ margin-top: 10px; display: grid; gap: 10px; }
.folder{ border: 1px solid rgba(255,255,255,0.55); border-radius: 16px; background: rgba(255,255,255,0.55); }
.folderHeader{
width: 100%;
text-align: left;
padding: 8px 10px;
border: 0;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.folderName{ font-weight: 900; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.folderMeta{ font-size: 12px; color: rgba(19, 78, 74, 0.72); }
.folderBody{ padding: 8px 10px 10px; display: grid; gap: 8px; }
.bm{
border: 1px solid rgba(255,255,255,0.65);
background: rgba(255,255,255,0.92);
border-radius: 14px;
padding: 8px 10px;
cursor: pointer;
text-align: left;
}
.bmTitle{ font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bmUrl{ font-size: 12px; color: rgba(19, 78, 74, 0.72); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 4px; }
</style>

View File

@@ -1,6 +1,6 @@
import { createApp } from "vue";
import PopupApp from "./PopupApp.vue";
import "../style.css";
createApp(PopupApp).mount("#app");
import { createApp } from "vue";
import PopupApp from "./PopupApp.vue";
import "../style.css";
createApp(PopupApp).mount("#app");