245 lines
5.4 KiB
Vue
245 lines
5.4 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="search-page">
|
|||
|
|
<div class="search-shell">
|
|||
|
|
<header class="search-header">
|
|||
|
|
<div class="title-wrap">
|
|||
|
|
<p class="eyebrow">Search</p>
|
|||
|
|
<h1 class="title">搜索你想要的文章</h1>
|
|||
|
|
<p class="desc">支持按标题、描述、标签与作者筛选</p>
|
|||
|
|
</div>
|
|||
|
|
<div class="input-wrap">
|
|||
|
|
<input
|
|||
|
|
v-model.trim="keyword"
|
|||
|
|
class="input"
|
|||
|
|
type="search"
|
|||
|
|
placeholder="输入关键词,如:RAG / GPT / 教程"
|
|||
|
|
@keyup.enter="runSearch"
|
|||
|
|
/>
|
|||
|
|
<button class="btn" type="button" @click="runSearch" :disabled="loading">
|
|||
|
|
{{ loading ? '搜索中...' : '搜索' }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<div v-if="loading" class="state">搜索中...</div>
|
|||
|
|
<div v-else-if="error" class="state error">{{ error }}</div>
|
|||
|
|
<div v-else-if="articles.length === 0" class="state">暂无结果</div>
|
|||
|
|
<div v-else class="cards-grid">
|
|||
|
|
<MarketCard
|
|||
|
|
v-for="it in articles"
|
|||
|
|
:key="it.slug"
|
|||
|
|
:cover="it.cover || defaultCover"
|
|||
|
|
:title="it.title || '未命名文章'"
|
|||
|
|
:tags="it.tagList || []"
|
|||
|
|
:created-at="it.createdAt"
|
|||
|
|
:owner-name="it.author?.username || '官方'"
|
|||
|
|
:owner-avatar="it.author?.image || ''"
|
|||
|
|
:views="it.views ?? 0"
|
|||
|
|
:likes="getLikes(it)"
|
|||
|
|
:favorited="!!it.favorited"
|
|||
|
|
:detail-href="`/articles/${it.slug}`"
|
|||
|
|
@toggle-like="() => toggleFavorite(it)"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { onMounted, ref, watch } from 'vue'
|
|||
|
|
import { useRoute, useRouter } from 'vue-router'
|
|||
|
|
import { useApi, useAuthToken } from '@/composables/useApi'
|
|||
|
|
import { useAuth } from '@/composables/useAuth'
|
|||
|
|
import MarketCard from '@/components/home/MarketCard.vue'
|
|||
|
|
|
|||
|
|
interface ArticleItem {
|
|||
|
|
slug: string
|
|||
|
|
title: string
|
|||
|
|
description?: string | null
|
|||
|
|
cover?: string | null
|
|||
|
|
tagList?: string[]
|
|||
|
|
createdAt?: string
|
|||
|
|
views?: number
|
|||
|
|
favorited?: boolean
|
|||
|
|
favoritesCount?: number
|
|||
|
|
favorites_count?: number
|
|||
|
|
author?: {
|
|||
|
|
username?: string
|
|||
|
|
image?: string | null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface ArticlesResponse {
|
|||
|
|
articles?: ArticleItem[]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const api = useApi()
|
|||
|
|
const route = useRoute()
|
|||
|
|
const router = useRouter()
|
|||
|
|
const { token } = useAuthToken()
|
|||
|
|
const { user: authUser, fetchMe } = useAuth() as any
|
|||
|
|
|
|||
|
|
const keyword = ref<string>('')
|
|||
|
|
const articles = ref<ArticleItem[]>([])
|
|||
|
|
const loading = ref(false)
|
|||
|
|
const error = ref('')
|
|||
|
|
const defaultCover = '/cover.jpg'
|
|||
|
|
|
|||
|
|
const getLikes = (it: ArticleItem) => Number(it.favoritesCount ?? it.favorites_count ?? 0)
|
|||
|
|
|
|||
|
|
async function ensureLogin() {
|
|||
|
|
if (token.value && !authUser.value) {
|
|||
|
|
try {
|
|||
|
|
await fetchMe()
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn('[Search] fetch me failed', err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function runSearch() {
|
|||
|
|
loading.value = true
|
|||
|
|
error.value = ''
|
|||
|
|
try {
|
|||
|
|
const res = (await api.get('/articles', {
|
|||
|
|
search: keyword.value || undefined,
|
|||
|
|
limit: 60,
|
|||
|
|
offset: 0,
|
|||
|
|
})) as ArticlesResponse
|
|||
|
|
articles.value = Array.isArray(res.articles) ? res.articles : []
|
|||
|
|
} catch (err: any) {
|
|||
|
|
console.error('[Search] failed', err)
|
|||
|
|
error.value = err?.statusMessage || err?.message || '搜索失败'
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function toggleFavorite(it: ArticleItem): Promise<void> {
|
|||
|
|
if (!token.value) {
|
|||
|
|
await router.push('/login')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
const before = it.favorited
|
|||
|
|
const beforeCount = getLikes(it)
|
|||
|
|
try {
|
|||
|
|
if (before) {
|
|||
|
|
await api.del(`/articles/${it.slug}/favorite`)
|
|||
|
|
it.favorited = false
|
|||
|
|
it.favoritesCount = Math.max(0, beforeCount - 1)
|
|||
|
|
} else {
|
|||
|
|
await api.post(`/articles/${it.slug}/favorite`)
|
|||
|
|
it.favorited = true
|
|||
|
|
it.favoritesCount = beforeCount + 1
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('[Search] toggle favorite failed', err)
|
|||
|
|
it.favorited = before
|
|||
|
|
it.favoritesCount = beforeCount
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
await ensureLogin()
|
|||
|
|
const q = (route.query.q as string) || ''
|
|||
|
|
keyword.value = q
|
|||
|
|
await runSearch()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
watch(
|
|||
|
|
() => route.query.q,
|
|||
|
|
(q) => {
|
|||
|
|
keyword.value = (q as string) || ''
|
|||
|
|
runSearch()
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.search-page {
|
|||
|
|
min-height: 100vh;
|
|||
|
|
background: #f5f7fb;
|
|||
|
|
padding: 100px 16px 60px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.search-shell {
|
|||
|
|
max-width: 1200px;
|
|||
|
|
margin: 0 auto;
|
|||
|
|
background: #fff;
|
|||
|
|
border-radius: 20px;
|
|||
|
|
padding: 20px;
|
|||
|
|
border: 1px solid #e5e7eb;
|
|||
|
|
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.search-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 12px;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.title-wrap .eyebrow {
|
|||
|
|
margin: 0;
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: #6b7280;
|
|||
|
|
letter-spacing: 0.3px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.title-wrap .title {
|
|||
|
|
margin: 4px 0 0;
|
|||
|
|
font-size: 26px;
|
|||
|
|
font-weight: 800;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.title-wrap .desc {
|
|||
|
|
margin: 6px 0 0;
|
|||
|
|
color: #6b7280;
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input-wrap {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 10px;
|
|||
|
|
flex: 1;
|
|||
|
|
min-width: 320px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input {
|
|||
|
|
flex: 1;
|
|||
|
|
padding: 10px 12px;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
border: 1px solid #d1d5db;
|
|||
|
|
background: #fff;
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn {
|
|||
|
|
padding: 10px 14px;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
border: 1px solid #d1d5db;
|
|||
|
|
background: #111827;
|
|||
|
|
color: #fff;
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-weight: 700;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.state {
|
|||
|
|
padding: 16px 10px;
|
|||
|
|
text-align: center;
|
|||
|
|
color: #6b7280;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.state.error {
|
|||
|
|
color: #b91c1c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cards-grid {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
|||
|
|
gap: 14px;
|
|||
|
|
}
|
|||
|
|
</style>
|