2025-12-04 10:04:21 +08:00

245 lines
5.4 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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