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