AI-News/frontend/app/pages/search.vue

245 lines
5.4 KiB
Vue
Raw Normal View History

2025-12-04 10:04:21 +08:00
<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>