feat: 实现文件夹和书签的持久排序与拖拽功能

This commit is contained in:
2026-01-18 23:33:31 +08:00
parent 6eb3c730bb
commit dbeb181e5d
49 changed files with 3141 additions and 507 deletions

View File

@@ -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,

View File

@@ -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");

View File

@@ -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">

View 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>

View File

@@ -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; }

View File

@@ -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>

View File

@@ -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;
});

View File

@@ -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>

View File

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

View File

@@ -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; }
}