提交0.1.0版本
- 完成了书签的基本功能和插件
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": "^_" }]
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
BIN
apps/extension/extension-dist.zip
Normal file
BIN
apps/extension/extension-dist.zip
Normal file
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>"]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
|
||||
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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user