feat: 新增 BbSelect 组件,实现下拉选择功能

This commit is contained in:
2026-01-18 13:07:54 +08:00
parent 00ca4c1b0d
commit 6eb3c730bb
21 changed files with 1346 additions and 163 deletions

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BrowserBookmark Options</title>
<title>云书签 选项</title>
</head>
<body>
<div id="app"></div>

View File

@@ -2,7 +2,7 @@ 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 || {});
headers.set("Accept", "application/json");
if (!headers.has("Accept")) headers.set("Accept", "application/json");
if (!(options.body instanceof FormData) && options.body != null) {
headers.set("Content-Type", "application/json");

View File

@@ -5,7 +5,7 @@ import { RouterLink, RouterView } from "vue-router";
<template>
<div class="shell">
<header class="nav">
<div class="brand">BrowserBookmark 扩展</div>
<div class="brand">云书签</div>
<nav class="links">
<RouterLink to="/" class="link">公开</RouterLink>
<RouterLink to="/my" class="link">我的</RouterLink>

View File

@@ -8,6 +8,10 @@ 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 = "";
@@ -44,8 +48,30 @@ async function importFile() {
}
}
function exportCloud() {
window.open("http://localhost:3001/bookmarks/export/html", "_blank");
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() {
@@ -82,7 +108,7 @@ async function exportLocal() {
<input
type="file"
accept="text/html,.html"
@change="(e) => (file.value = e.target.files?.[0] || null)"
@change="onFileChange"
/>
<button class="btn" @click="importFile">开始导入</button>
<p v-if="status" class="ok">{{ status }}</p>

View File

@@ -12,6 +12,16 @@ const mode = ref("local");
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;
@@ -36,11 +46,89 @@ function openOptions() {
}
}
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: document.title || "", url: window.location?.href || "" };
}
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 = "";
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;
}
const page = await getActiveTabPage();
const t = String(page.title || "").trim() || String(page.url || "").trim();
const u = String(page.url || "").trim();
if (!u) return;
try {
addCurrentBusy.value = true;
await apiFetch("/bookmarks", {
method: "POST",
body: JSON.stringify({
folderId: selectedFolderId.value ?? null,
title: t,
url: u,
visibility: "private"
})
});
addCurrentStatus.value = "已添加到云书签";
await load();
} catch (e) {
error.value = e.message || String(e);
} finally {
addCurrentBusy.value = false;
}
}
async function load() {
loading.value = true;
error.value = "";
try {
const token = await getToken();
cloudAvailable.value = Boolean(token);
mode.value = token ? "cloud" : "local";
if (mode.value === "cloud") {
@@ -109,6 +197,37 @@ onMounted(load);
<button class="btn primary" @click="quickAdd">添加</button>
</div>
<div class="card">
<div class="cardTitle">添加当前页面到云书签</div>
<div class="muted">只需选一个文件夹不选默认根目录</div>
<button class="btn primary" :disabled="addCurrentBusy" @click="openAddCurrent">添加当前页面</button>
<div v-if="addCurrentOpen" class="subCard">
<div class="subTitle">当前页面</div>
<div class="subText">{{ activePage.title || '(无标题)' }}</div>
<div class="subUrl">{{ activePage.url || '(无法获取链接)' }}</div>
<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>
<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>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="loading" class="muted">加载中</div>
@@ -138,6 +257,11 @@ onMounted(load);
.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); }