Browse Source

feat: 添加搜索功能并优化侧边栏工具项

as
npmrun 2 weeks ago
parent
commit
87d14a0364
  1. 113
      app/components/TopNav.vue
  2. 2
      app/components/index/LeftSidebar.vue
  3. 193
      app/pages/collect/index.vue
  4. BIN
      packages/drizzle-pkg/db.sqlite

113
app/components/TopNav.vue

@ -4,7 +4,6 @@ const fontReady = ref(false)
const navHidden = ref(false)
const route = useRoute()
const router = useRouter()
const { loggedIn, user, clear, initialized } = useAuthSession()
@ -13,22 +12,6 @@ const links: any = [
{ to: "/test", label: "测试" },
]
const searchQuery = ref((route.query.q as string) ?? '')
const searchFocused = ref(false)
function onSearchSubmit() {
const q = searchQuery.value.trim()
if (!q) return
router.push({ path: '/', query: { q } })
}
// When input is cleared, remove the query param
watch(searchQuery, (val) => {
if (!val.trim() && route.query.q) {
router.replace({ path: '/', query: {} })
}
})
let lastScrollY = 0
const HIDE_OFFSET = 520
@ -58,10 +41,6 @@ watch(() => route.path, () => {
menuOpen.value = false
})
// Sync search input from URL
watch(() => route.query.q, (val) => {
searchQuery.value = (val as string) ?? ''
})
</script>
<template>
@ -71,22 +50,7 @@ watch(() => route.query.q, (val) => {
Dash
</NuxtLink>
<form
class="nav-search"
:class="{ focused: searchFocused }"
@submit.prevent="onSearchSubmit"
>
<Icon name="lucide:search" class="search-icon" />
<input
v-model="searchQuery"
type="search"
class="search-input"
placeholder="搜索卡片"
@focus="searchFocused = true"
@blur="searchFocused = false"
>
<kbd v-if="!searchFocused && !searchQuery" class="search-kbd"> K</kbd>
</form>
<div class="nav-spacer" />
<ul class="nav-links" :class="{ open: menuOpen }">
<li v-for="link in links" :key="link.to">
@ -181,68 +145,10 @@ watch(() => route.query.q, (val) => {
opacity: 1;
}
/* ── Search ── */
/* ── Spacer ── */
.nav-search {
position: relative;
.nav-spacer {
flex: 1;
max-width: 520px;
height: 38px;
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.6);
border: 1px solid var(--color-hairline);
border-radius: 9999px;
padding: 0 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
margin: 0 auto;
}
.nav-search.focused {
border-color: var(--color-primary);
background: var(--color-canvas);
box-shadow: 0 0 0 4px rgba(204, 120, 92, 0.12);
}
.search-icon {
width: 16px;
height: 16px;
color: var(--color-muted);
flex-shrink: 0;
}
.search-input {
flex: 1;
height: 100%;
border: none;
background: transparent;
outline: none;
padding: 0 10px;
font-family: var(--font-body);
font-size: 14px;
color: var(--color-ink);
}
.search-input::placeholder {
color: var(--color-muted-soft);
}
.search-input::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
}
.search-kbd {
font-family: var(--font-body);
font-size: 11px;
font-weight: 500;
color: var(--color-muted);
background: var(--color-surface-card);
border: 1px solid var(--color-hairline);
border-radius: 4px;
padding: 2px 6px;
letter-spacing: 0.5px;
flex-shrink: 0;
}
/* ── Desktop links ── */
@ -373,10 +279,6 @@ watch(() => route.query.q, (val) => {
.nav-links {
gap: 18px;
}
.nav-search {
max-width: 360px;
}
}
/* ── Mobile ── */
@ -386,15 +288,6 @@ watch(() => route.query.q, (val) => {
gap: 12px;
}
.nav-search {
height: 36px;
padding: 0 12px;
}
.search-kbd {
display: none;
}
.nav-links {
position: fixed;
top: 64px;

2
app/components/index/LeftSidebar.vue

@ -24,8 +24,8 @@ const props = withDefaults(defineProps<{
side: 'left',
tools: () => [
{ key: 'home', label: '全部卡片', icon: 'lucide:home' },
{ key: 'search', label: '搜索', icon: 'lucide:search' },
{ key: 'collect', label: '收藏', icon: 'lucide:star' },
// { key: 'search', label: '', icon: 'lucide:search' },
// { key: 'tags', label: '', icon: 'lucide:tag' },
// { key: 'highlights', label: '', icon: 'lucide:highlighter' },
// { key: 'archive', label: '', icon: 'lucide:archive' },

193
app/pages/collect/index.vue

@ -12,6 +12,7 @@ definePageMeta({
const { $toast } = useNuxtApp()
const { isFavorited, toggle: toggleFavorite, syncFromCards } = useFavorite()
const route = useRoute()
const router = useRouter()
const isMobile = useMediaQuery('(max-width: 767.98px)')
const drawerOpen = ref(false)
const sidebarMode = computed<'rail' | 'drawer'>(() => isMobile.value ? 'drawer' : 'rail')
@ -105,6 +106,8 @@ function mapCategoryTree(server: ServerCategory[]): CategoryNode[] {
const activeToolKey = ref<string>('home')
const activeCategoryId = ref<string>('all')
const searchQuery = ref('')
const searchInputRef = ref<HTMLInputElement | null>(null)
const categories = ref<CategoryNode[]>([])
const categoriesLoading = ref(true)
@ -183,9 +186,9 @@ function onSelectTool(key: string) {
fetchTags()
return
case 'search':
// Focus the TopNav search input
const input = document.querySelector<HTMLInputElement>('.search-input')
input?.focus()
activeCategoryId.value = 'all'
searchQuery.value = (route.query.q as string) ?? ''
nextTick(() => searchInputRef.value?.focus())
break
case 'highlights':
$toast.info('高亮即将上线')
@ -543,9 +546,21 @@ async function loadMore() {
if (activeToolKey.value === 'collect') {
query.favorited = 1
}
// '__uncategorized__' will be passed as-is to filter null categories
const q = (route.query.q as string)?.trim()
if (q) query.q = q
// search mode skip category / favorite filters
if (activeToolKey.value === 'search') {
const q = (route.query.q as string)?.trim()
if (q) query.q = q
else {
// No query don't load anything
loading.value = false
initLoading.value = false
return
}
} else {
// '__uncategorized__' will be passed as-is to filter null categories
const q = (route.query.q as string)?.trim()
if (q) query.q = q
}
const raw = await $fetch<{
code: number
data: {
@ -588,6 +603,21 @@ watch(activeToolKey, () => {
resetAndReload()
})
// Debounced sync from search input to route.query.q
let searchDebounce: ReturnType<typeof setTimeout> | null = null
watch(searchQuery, (val) => {
if (searchDebounce) clearTimeout(searchDebounce)
searchDebounce = setTimeout(() => {
const trimmed = val.trim()
if (trimmed) {
router.replace({ query: { ...route.query, q: trimmed } })
} else if (route.query.q) {
const { q, ...rest } = route.query
router.replace({ query: rest })
}
}, 300)
})
function resetAndReload() {
page.value = 1
hasMore.value = true
@ -688,11 +718,46 @@ onUnmounted(() => {
<Icon name="lucide:menu" />
<span>分类</span>
</button>
<span class="category-chip">{{ activeToolKey === 'collect' ? '我的收藏' : activeCategoryName }}</span>
<span class="category-chip">
<template v-if="activeToolKey === 'search'">搜索</template>
<template v-else>{{ activeToolKey === 'collect' ? '我的收藏' : activeCategoryName }}</template>
</span>
</div>
<!-- Search section -->
<section v-if="activeToolKey === 'search'" class="search-section">
<div class="search-header">
<h2 class="search-heading">搜索卡片</h2>
<p class="search-sub">按标题描述查找卡片</p>
</div>
<div class="search-bar">
<Icon name="lucide:search" class="search-bar-icon" />
<input
ref="searchInputRef"
v-model="searchQuery"
type="search"
class="search-bar-input"
placeholder="输入关键词…"
>
<button
v-if="searchQuery"
type="button"
class="search-bar-clear"
@click="searchQuery = ''"
>
<Icon name="lucide:x" />
</button>
</div>
<p
v-if="route.query.q && !initLoading && allItems.length > 0"
class="search-count"
>
找到 <strong>{{ allItems.length }}</strong> 条结果
</p>
</section>
<!-- Category heading -->
<h2 v-if="!isMobile" class="category-heading">{{ activeToolKey === 'collect' ? '我的收藏' : activeCategoryName }}</h2>
<h2 v-else-if="!isMobile" class="category-heading">{{ activeToolKey === 'collect' ? '我的收藏' : activeCategoryName }}</h2>
<div class="masonry">
<div
@ -752,8 +817,10 @@ onUnmounted(() => {
class="empty-state"
>
<Icon name="lucide:inbox" class="empty-icon" />
<h3>还没有内容</h3>
<p v-if="activeCategoryId !== 'all'">当前分类下暂无卡片</p>
<h3 v-if="activeToolKey === 'search' && !route.query.q">搜索卡片</h3>
<h3 v-else>还没有内容</h3>
<p v-if="activeToolKey === 'search' && !route.query.q">输入关键词搜索卡片标题和描述</p>
<p v-else-if="activeCategoryId !== 'all'">当前分类下暂无卡片</p>
<p v-else-if="route.query.q">没有找到匹配{{ route.query.q }}的卡片</p>
<p v-else>点击右下角 + 按钮创建第一张卡片</p>
</div>
@ -902,6 +969,112 @@ onUnmounted(() => {
letter-spacing: -0.3px;
}
/* ── Search section ── */
.search-section {
margin-bottom: 8px;
}
.search-header {
margin-bottom: 16px;
}
.search-heading {
font-family: var(--font-display);
font-size: 28px;
font-weight: 400;
line-height: 1.2;
letter-spacing: -0.3px;
color: var(--color-ink);
margin: 0 0 6px 0;
}
.search-sub {
font-family: var(--font-body);
font-size: 14px;
color: var(--color-muted);
margin: 0;
}
.search-bar {
position: relative;
display: flex;
align-items: center;
max-width: 560px;
height: 48px;
margin-bottom: 12px;
background: var(--color-canvas);
border: 1px solid var(--color-hairline);
border-radius: 12px;
padding: 0 16px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.search-bar:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 4px rgba(204, 120, 92, 0.12);
}
.search-bar-icon {
width: 20px;
height: 20px;
color: var(--color-muted);
flex-shrink: 0;
}
.search-bar-input {
flex: 1;
height: 100%;
border: none;
background: transparent;
outline: none;
padding: 0 12px;
font-family: var(--font-body);
font-size: 16px;
color: var(--color-ink);
}
.search-bar-input::placeholder {
color: var(--color-muted-soft);
}
.search-bar-input::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
}
.search-bar-clear {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 50%;
cursor: pointer;
color: var(--color-muted);
flex-shrink: 0;
transition: background 0.15s ease, color 0.15s ease;
}
.search-bar-clear:hover {
background: var(--color-surface-soft);
color: var(--color-ink);
}
.search-count {
font-family: var(--font-body);
font-size: 13px;
color: var(--color-muted);
margin: 0 0 4px 0;
}
.search-count strong {
font-weight: 500;
color: var(--color-ink);
}
/* ── Mobile bar ── */
.mobile-bar {

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.
Loading…
Cancel
Save