提交0.1.0版本

- 完成了书签的基本功能和插件
This commit is contained in:
2026-01-21 23:09:33 +08:00
parent 3e2d1456eb
commit 1a3bbac9ff
95 changed files with 12431 additions and 12445 deletions

View File

@@ -1,135 +1,135 @@
<script setup>
import { ref } from "vue";
import { apiFetch } from "../../lib/api";
import { getToken } from "../../lib/extStorage";
import { exportLocalToNetscapeHtml, importLocalFromNetscapeHtml, listLocalBookmarks } from "../../lib/localData";
const file = ref(null);
const status = ref("");
const error = ref("");
function onFileChange(e) {
file.value = e?.target?.files?.[0] || null;
}
async function importToLocal() {
status.value = "";
error.value = "";
if (!file.value) return;
try {
const text = await file.value.text();
const res = await importLocalFromNetscapeHtml(text, { visibility: "public" });
status.value = `本地导入完成:文件夹新增 ${res.foldersImported},书签新增 ${res.imported},合并 ${res.merged}`;
} catch (e) {
error.value = e.message || String(e);
}
}
async function importFile() {
status.value = "";
error.value = "";
const token = await getToken();
if (!token) {
await importToLocal();
return;
}
if (!file.value) return;
try {
const fd = new FormData();
fd.append("file", file.value);
const res = await apiFetch("/bookmarks/import/html", { method: "POST", body: fd });
status.value = `导入完成:新增 ${res.imported},合并 ${res.merged}`;
} catch (e) {
error.value = e.message || String(e);
}
}
async function exportCloud() {
status.value = "";
error.value = "";
try {
const html = await apiFetch("/bookmarks/export/html", {
method: "GET",
headers: { Accept: "text/html" }
});
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks-cloud.html";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
status.value = "云端导出完成";
} catch (e) {
error.value = e.message || String(e);
}
}
async function exportLocal() {
status.value = "";
error.value = "";
try {
const bookmarks = await listLocalBookmarks({ includeDeleted: false });
const html = exportLocalToNetscapeHtml(bookmarks);
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks.html";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
status.value = `本地导出完成:${bookmarks.length}`;
} catch (e) {
error.value = e.message || String(e);
}
}
</script>
<template>
<section>
<h1>导入 / 导出</h1>
<div class="card">
<h2>导入书签 HTML写入云端</h2>
<input
type="file"
accept="text/html,.html"
@change="onFileChange"
/>
<button class="btn" @click="importFile">开始导入</button>
<p v-if="status" class="ok">{{ status }}</p>
<p v-if="error" class="error">{{ error }}</p>
</div>
<div class="card">
<h2>导出本地</h2>
<button class="btn" @click="exportLocal">导出本地为 HTML</button>
</div>
<div class="card">
<h2>导出云端</h2>
<button class="btn" @click="exportCloud">导出为 HTML</button>
</div>
</section>
</template>
<style scoped>
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; margin: 12px 0; }
.btn { margin-top: 10px; padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.ok { color: #065f46; }
.error { color: #b91c1c; }
</style>
<script setup>
import { ref } from "vue";
import { apiFetch } from "../../lib/api";
import { getToken } from "../../lib/extStorage";
import { exportLocalToNetscapeHtml, importLocalFromNetscapeHtml, listLocalBookmarks } from "../../lib/localData";
const file = ref(null);
const status = ref("");
const error = ref("");
function onFileChange(e) {
file.value = e?.target?.files?.[0] || null;
}
async function importToLocal() {
status.value = "";
error.value = "";
if (!file.value) return;
try {
const text = await file.value.text();
const res = await importLocalFromNetscapeHtml(text, { visibility: "public" });
status.value = `本地导入完成:文件夹新增 ${res.foldersImported},书签新增 ${res.imported},合并 ${res.merged}`;
} catch (e) {
error.value = e.message || String(e);
}
}
async function importFile() {
status.value = "";
error.value = "";
const token = await getToken();
if (!token) {
await importToLocal();
return;
}
if (!file.value) return;
try {
const fd = new FormData();
fd.append("file", file.value);
const res = await apiFetch("/bookmarks/import/html", { method: "POST", body: fd });
status.value = `导入完成:新增 ${res.imported},合并 ${res.merged}`;
} catch (e) {
error.value = e.message || String(e);
}
}
async function exportCloud() {
status.value = "";
error.value = "";
try {
const html = await apiFetch("/bookmarks/export/html", {
method: "GET",
headers: { Accept: "text/html" }
});
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks-cloud.html";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
status.value = "云端导出完成";
} catch (e) {
error.value = e.message || String(e);
}
}
async function exportLocal() {
status.value = "";
error.value = "";
try {
const bookmarks = await listLocalBookmarks({ includeDeleted: false });
const html = exportLocalToNetscapeHtml(bookmarks);
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks.html";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
status.value = `本地导出完成:${bookmarks.length}`;
} catch (e) {
error.value = e.message || String(e);
}
}
</script>
<template>
<section>
<h1>导入 / 导出</h1>
<div class="card">
<h2>导入书签 HTML写入云端</h2>
<input
type="file"
accept="text/html,.html"
@change="onFileChange"
/>
<button class="btn" @click="importFile">开始导入</button>
<p v-if="status" class="ok">{{ status }}</p>
<p v-if="error" class="error">{{ error }}</p>
</div>
<div class="card">
<h2>导出本地</h2>
<button class="btn" @click="exportLocal">导出本地为 HTML</button>
</div>
<div class="card">
<h2>导出云端</h2>
<button class="btn" @click="exportCloud">导出为 HTML</button>
</div>
</section>
</template>
<style scoped>
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; margin: 12px 0; }
.btn { margin-top: 10px; padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.ok { color: #065f46; }
.error { color: #b91c1c; }
</style>

View File

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

View File

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

View File

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

View File

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