feat: 实现文件夹和书签的持久排序与拖拽功能
This commit is contained in:
@@ -1,17 +1,11 @@
|
||||
<script setup>
|
||||
import { RouterLink, RouterView } from "vue-router";
|
||||
import { RouterView } from "vue-router";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<header class="nav">
|
||||
<div class="brand">云书签</div>
|
||||
<nav class="links">
|
||||
<RouterLink to="/" class="link">公开</RouterLink>
|
||||
<RouterLink to="/my" class="link">我的</RouterLink>
|
||||
<RouterLink to="/import" class="link">导入导出</RouterLink>
|
||||
<RouterLink to="/login" class="link primary">登录</RouterLink>
|
||||
</nav>
|
||||
<div class="brand">云书签 · 更多操作</div>
|
||||
</header>
|
||||
<main class="content">
|
||||
<RouterView />
|
||||
@@ -20,43 +14,22 @@ import { RouterLink, RouterView } from "vue-router";
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bb-bg: #f8fafc;
|
||||
--bb-text: #1e293b;
|
||||
--bb-primary: #3b82f6;
|
||||
--bb-primary-weak: #60a5fa;
|
||||
--bb-cta: #f97316;
|
||||
--bb-border: #e5e7eb;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bb-bg);
|
||||
color: var(--bb-text);
|
||||
font-family: ui-sans-serif, system-ui;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
|
||||
.shell { min-height: 100vh; }
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
border-bottom: 1px solid var(--bb-border);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
padding: 10px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
.brand { font-weight: 800; }
|
||||
.links { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.link { text-decoration: none; color: var(--bb-text); padding: 8px 10px; border-radius: 10px; transition: background 150ms, color 150ms, border-color 150ms; }
|
||||
.link:hover { background: rgba(59, 130, 246, 0.08); }
|
||||
.link.router-link-active { background: rgba(59, 130, 246, 0.12); }
|
||||
.primary { background: var(--bb-primary); color: white; }
|
||||
.primary:hover { background: var(--bb-primary-weak); }
|
||||
.content { max-width: 1100px; margin: 0 auto; padding: 14px; }
|
||||
|
||||
a:focus-visible,
|
||||
|
||||
@@ -2,4 +2,6 @@ import { createApp } from "vue";
|
||||
import OptionsApp from "./OptionsApp.vue";
|
||||
import { router } from "./router";
|
||||
|
||||
import "../style.css";
|
||||
|
||||
createApp(OptionsApp).use(router).mount("#app");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiFetch } from "../../lib/api";
|
||||
import { getToken, setToken } from "../../lib/extStorage";
|
||||
import { setToken } from "../../lib/extStorage";
|
||||
import { clearLocalState, mergeLocalToUser } from "../../lib/localData";
|
||||
|
||||
const router = useRouter();
|
||||
@@ -35,20 +35,13 @@ async function submit() {
|
||||
// After merge, keep extension in cloud mode
|
||||
await clearLocalState();
|
||||
|
||||
await router.push("/my");
|
||||
await router.replace("/");
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const token = await getToken();
|
||||
if (!token) return;
|
||||
await setToken("");
|
||||
await router.push("/");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -58,7 +51,6 @@ async function logout() {
|
||||
<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>
|
||||
<button class="tab" @click="logout">退出</button>
|
||||
</div>
|
||||
|
||||
<div class="form">
|
||||
|
||||
74
apps/extension/src/options/pages/MorePage.vue
Normal file
74
apps/extension/src/options/pages/MorePage.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { getToken, setToken } from "../../lib/extStorage";
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await setToken("");
|
||||
await refresh();
|
||||
await router.replace("/login");
|
||||
}
|
||||
|
||||
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="logout">退出登录</button>
|
||||
<p class="hint">Web 地址来自环境变量:VITE_WEB_BASE_URL</p>
|
||||
</div>
|
||||
</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>
|
||||
@@ -44,6 +44,7 @@ async function add() {
|
||||
|
||||
async function remove(id) {
|
||||
if (mode.value !== "local") return;
|
||||
if (!confirm("确定删除该书签?")) return;
|
||||
await markLocalDeleted(id);
|
||||
await load();
|
||||
}
|
||||
@@ -86,7 +87,16 @@ onMounted(load);
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
@@ -47,7 +47,15 @@ onMounted(load);
|
||||
.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; }
|
||||
.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,15 +1,22 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import PublicPage from "./pages/PublicPage.vue";
|
||||
import LoginPage from "./pages/LoginPage.vue";
|
||||
import MyPage from "./pages/MyPage.vue";
|
||||
import ImportExportPage from "./pages/ImportExportPage.vue";
|
||||
import MorePage from "./pages/MorePage.vue";
|
||||
import { getToken } from "../lib/extStorage";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: PublicPage },
|
||||
{ path: "/login", component: LoginPage },
|
||||
{ path: "/my", component: MyPage },
|
||||
{ path: "/import", component: ImportExportPage }
|
||||
{ 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,51 +1,98 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { getToken } from "../lib/extStorage";
|
||||
import { listLocalBookmarks, upsertLocalBookmark } from "../lib/localData";
|
||||
|
||||
const items = ref([]);
|
||||
const q = ref("");
|
||||
const title = ref("");
|
||||
const url = ref("");
|
||||
const mode = ref("local");
|
||||
const view = ref("list"); // add | list
|
||||
|
||||
const token = ref("");
|
||||
const loggedIn = computed(() => Boolean(token.value));
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const cloudAvailable = ref(false);
|
||||
|
||||
const addCurrentOpen = ref(false);
|
||||
const foldersLoading = ref(false);
|
||||
const folders = ref([]);
|
||||
const selectedFolderId = ref(null);
|
||||
const addCurrentBusy = ref(false);
|
||||
const addCurrentStatus = ref("");
|
||||
const activePage = ref({ title: "", url: "" });
|
||||
|
||||
const filtered = computed(() => {
|
||||
const query = q.value.trim().toLowerCase();
|
||||
if (!query) return items.value;
|
||||
return items.value.filter((b) => {
|
||||
const t = String(b.title || "").toLowerCase();
|
||||
const u = String(b.url || "").toLowerCase();
|
||||
return t.includes(query) || u.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
function openUrl(url) {
|
||||
if (typeof chrome !== "undefined" && chrome.tabs?.create) {
|
||||
chrome.tabs.create({ url });
|
||||
} else {
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -58,215 +105,359 @@ async function getActiveTabPage() {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { title: document.title || "", url: window.location?.href || "" };
|
||||
return { title: "", url: "" };
|
||||
}
|
||||
|
||||
async function loadFolders() {
|
||||
foldersLoading.value = true;
|
||||
try {
|
||||
const list = await apiFetch("/folders");
|
||||
folders.value = Array.isArray(list) ? list : [];
|
||||
} finally {
|
||||
foldersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openAddCurrent() {
|
||||
addCurrentStatus.value = "";
|
||||
async function prepareAddCurrent() {
|
||||
addStatus.value = "";
|
||||
error.value = "";
|
||||
selectedFolderId.value = null;
|
||||
activePage.value = await getActiveTabPage();
|
||||
|
||||
const token = await getToken();
|
||||
cloudAvailable.value = Boolean(token);
|
||||
if (!token) {
|
||||
addCurrentOpen.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
addCurrentOpen.value = true;
|
||||
await loadFolders();
|
||||
}
|
||||
|
||||
async function addCurrentToCloud() {
|
||||
addCurrentStatus.value = "";
|
||||
error.value = "";
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
error.value = "请先登录后再添加到云书签";
|
||||
return;
|
||||
}
|
||||
addFolderId.value = null;
|
||||
|
||||
const page = await getActiveTabPage();
|
||||
const t = String(page.title || "").trim() || String(page.url || "").trim();
|
||||
const u = String(page.url || "").trim();
|
||||
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 {
|
||||
addCurrentBusy.value = true;
|
||||
addBusy.value = true;
|
||||
await apiFetch("/bookmarks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
folderId: selectedFolderId.value ?? null,
|
||||
folderId: addFolderId.value ?? null,
|
||||
title: t,
|
||||
url: u,
|
||||
visibility: "private"
|
||||
})
|
||||
});
|
||||
addCurrentStatus.value = "已添加到云书签";
|
||||
await load();
|
||||
addStatus.value = "已添加";
|
||||
if (view.value === "list") await loadBookmarks();
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
addCurrentBusy.value = false;
|
||||
addBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
// create folder (cloud only)
|
||||
const folderName = ref("");
|
||||
const folderBusy = ref(false);
|
||||
const folderModalOpen = ref(false);
|
||||
async function createFolder() {
|
||||
error.value = "";
|
||||
try {
|
||||
const token = await getToken();
|
||||
cloudAvailable.value = Boolean(token);
|
||||
mode.value = token ? "cloud" : "local";
|
||||
const name = folderName.value.trim();
|
||||
if (!name) return;
|
||||
if (!loggedIn.value) {
|
||||
error.value = "请先登录";
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode.value === "cloud") {
|
||||
const qs = q.value.trim() ? `?q=${encodeURIComponent(q.value.trim())}` : "";
|
||||
items.value = await apiFetch(`/bookmarks${qs}`);
|
||||
items.value = items.value.slice(0, 20);
|
||||
} else {
|
||||
items.value = await listLocalBookmarks();
|
||||
items.value = items.value.slice(0, 50);
|
||||
}
|
||||
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 {
|
||||
loading.value = false;
|
||||
folderBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function quickAdd() {
|
||||
error.value = "";
|
||||
const t = title.value.trim();
|
||||
const u = url.value.trim();
|
||||
if (!t || !u) return;
|
||||
onMounted(async () => {
|
||||
await refreshAuth();
|
||||
await prepareAddCurrent();
|
||||
if (loggedIn.value) await loadAll();
|
||||
});
|
||||
|
||||
try {
|
||||
const token = await getToken();
|
||||
if (token) {
|
||||
await apiFetch("/bookmarks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ folderId: null, title: t, url: u, visibility: "public" })
|
||||
});
|
||||
} else {
|
||||
await upsertLocalBookmark({ title: t, url: u, visibility: "public" });
|
||||
}
|
||||
title.value = "";
|
||||
url.value = "";
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
watch(
|
||||
() => q.value,
|
||||
async () => {
|
||||
if (!loggedIn.value) return;
|
||||
await loadBookmarks();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<div class="top">
|
||||
<div class="title">书签</div>
|
||||
<button class="btn" @click="openOptions">设置/登录</button>
|
||||
<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>
|
||||
|
||||
<div class="meta">
|
||||
<span class="pill">{{ mode === 'cloud' ? '云端' : '本地' }}</span>
|
||||
<button class="linkBtn" :disabled="loading" @click="load">刷新</button>
|
||||
</div>
|
||||
<p v-if="!loggedIn" class="hint">未登录:请点右上角“更多操作”先登录。</p>
|
||||
|
||||
<div class="row">
|
||||
<input v-model="q" class="input" placeholder="搜索" @keyup.enter="load" />
|
||||
<button class="btn primary" :disabled="loading" @click="load">搜</button>
|
||||
</div>
|
||||
<p v-if="error" class="alert">{{ error }}</p>
|
||||
<p v-if="addStatus" class="ok">{{ addStatus }}</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="cardTitle">快速添加</div>
|
||||
<input v-model="title" class="input" placeholder="标题" />
|
||||
<input v-model="url" class="input" placeholder="链接" @keyup.enter="quickAdd" />
|
||||
<button class="btn primary" @click="quickAdd">添加</button>
|
||||
</div>
|
||||
<section v-if="view === 'add'" class="card">
|
||||
<div class="cardTitle">一键添加书签</div>
|
||||
<div class="muted">会自动读取标题和链接,你也可以手动修改。</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="cardTitle">添加当前页面到云书签</div>
|
||||
<div class="muted">只需选一个文件夹(不选默认根目录)。</div>
|
||||
<button class="btn primary" :disabled="addCurrentBusy" @click="openAddCurrent">添加当前页面</button>
|
||||
<label class="label">标题</label>
|
||||
<input v-model="addTitle" class="input" placeholder="标题" />
|
||||
|
||||
<div v-if="addCurrentOpen" class="subCard">
|
||||
<div class="subTitle">当前页面</div>
|
||||
<div class="subText">{{ activePage.title || '(无标题)' }}</div>
|
||||
<div class="subUrl">{{ activePage.url || '(无法获取链接)' }}</div>
|
||||
<label class="label">链接</label>
|
||||
<input v-model="addUrl" class="input" placeholder="https://..." />
|
||||
|
||||
<template v-if="cloudAvailable">
|
||||
<div class="subTitle" style="margin-top: 10px;">选择文件夹</div>
|
||||
<select v-model="selectedFolderId" class="input" :disabled="foldersLoading || addCurrentBusy">
|
||||
<option :value="null">(根目录)</option>
|
||||
<option v-for="f in folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||
</select>
|
||||
<button class="btn primary" :disabled="addCurrentBusy || !activePage.url" @click="addCurrentToCloud">
|
||||
{{ addCurrentBusy ? '添加中…' : '确认添加到云端' }}
|
||||
</button>
|
||||
<div v-if="foldersLoading" class="muted">加载文件夹中…</div>
|
||||
</template>
|
||||
<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>
|
||||
|
||||
<template v-else>
|
||||
<div class="muted" style="margin-top: 10px;">当前未登录,无法添加到云端。</div>
|
||||
<button class="btn" @click="openOptions">去登录</button>
|
||||
</template>
|
||||
|
||||
<div v-if="addCurrentStatus" class="ok">{{ addCurrentStatus }}</div>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="loading" class="muted">加载中…</div>
|
||||
<section v-else class="card">
|
||||
<div class="titleRow">
|
||||
<div class="cardTitle">书签目录</div>
|
||||
<button class="miniBtn" type="button" :disabled="!loggedIn" @click="folderModalOpen = true">新增文件夹</button>
|
||||
</div>
|
||||
|
||||
<ul class="list">
|
||||
<li v-for="b in filtered" :key="b.id" class="item" @click="openUrl(b.url)">
|
||||
<div class="name">{{ b.title }}</div>
|
||||
<div class="url">{{ b.url }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="row" style="margin-top: 8px;">
|
||||
<input v-model="q" class="input" placeholder="搜索标题/链接" :disabled="!loggedIn" />
|
||||
<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: 360px; padding: 12px; font-family: ui-sans-serif, system-ui; background: #f8fafc; color: #1e293b; }
|
||||
.top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.title { font-weight: 800; }
|
||||
.btn { padding: 6px 10px; border: 1px solid #e5e7eb; background: white; border-radius: 10px; cursor: pointer; transition: background 150ms, border-color 150ms; }
|
||||
.btn:hover { background: rgba(59, 130, 246, 0.08); border-color: rgba(59, 130, 246, 0.35); }
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.primary { border-color: rgba(59, 130, 246, 0.6); background: rgba(59, 130, 246, 0.12); }
|
||||
.primary:hover { background: rgba(59, 130, 246, 0.18); }
|
||||
.linkBtn { border: none; background: transparent; color: #3b82f6; cursor: pointer; padding: 0; }
|
||||
.linkBtn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.meta { display: flex; align-items: center; justify-content: space-between; margin: 6px 0 10px; }
|
||||
.pill { font-size: 12px; padding: 2px 8px; border: 1px solid #e5e7eb; border-radius: 999px; background: white; color: #334155; }
|
||||
.row { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin: 10px 0; }
|
||||
.input { padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 10px; background: white; }
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 12px; padding: 10px; background: white; display: grid; gap: 8px; margin-bottom: 10px; }
|
||||
.cardTitle { font-weight: 700; font-size: 12px; color: #334155; }
|
||||
.subCard { border: 1px dashed #e5e7eb; border-radius: 12px; padding: 10px; background: #f8fafc; display: grid; gap: 8px; }
|
||||
.subTitle { font-weight: 700; font-size: 12px; color: #334155; }
|
||||
.subText { font-weight: 700; color: #0f172a; overflow-wrap: anywhere; }
|
||||
.subUrl { font-size: 12px; color: #475569; overflow-wrap: anywhere; }
|
||||
.ok { font-size: 12px; color: #065f46; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 10px; padding: 8px 10px; }
|
||||
.list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
|
||||
.item { border: 1px solid #e5e7eb; border-radius: 12px; padding: 10px; cursor: pointer; background: white; transition: background 150ms, border-color 150ms; }
|
||||
.item:hover { background: rgba(59, 130, 246, 0.06); border-color: rgba(59, 130, 246, 0.35); }
|
||||
.name { font-weight: 700; color: #111827; }
|
||||
.url { font-size: 12px; color: #475569; overflow-wrap: anywhere; margin-top: 4px; }
|
||||
.muted { font-size: 12px; color: #475569; }
|
||||
.error { color: #b91c1c; font-size: 12px; margin-bottom: 8px; }
|
||||
.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);
|
||||
}
|
||||
|
||||
.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,4 +1,6 @@
|
||||
import { createApp } from "vue";
|
||||
import PopupApp from "./PopupApp.vue";
|
||||
|
||||
import "../style.css";
|
||||
|
||||
createApp(PopupApp).mount("#app");
|
||||
|
||||
@@ -1,79 +1,63 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
--bb-bg: #f8fafc;
|
||||
--bb-text: #0f172a;
|
||||
--bb-muted: rgba(15, 23, 42, 0.70);
|
||||
--bb-primary: #2563eb;
|
||||
--bb-primary-weak: rgba(37, 99, 235, 0.12);
|
||||
--bb-cta: #f97316;
|
||||
--bb-border: rgba(15, 23, 42, 0.14);
|
||||
--bb-card: rgba(255, 255, 255, 0.88);
|
||||
--bb-card-solid: #ffffff;
|
||||
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(900px 520px at 10% 0%, rgba(37, 99, 235, 0.12), rgba(0,0,0,0) 60%),
|
||||
radial-gradient(900px 520px at 90% 0%, rgba(249, 115, 22, 0.12), rgba(0,0,0,0) 60%),
|
||||
var(--bb-bg);
|
||||
color: var(--bb-text);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
a { color: var(--bb-primary); text-decoration: none; }
|
||||
a:hover { color: #1d4ed8; }
|
||||
|
||||
button, input, select, textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
select, input, textarea {
|
||||
background: rgba(255,255,255,0.92);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
::placeholder {
|
||||
color: rgba(15, 23, 42, 0.45);
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.55);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user