373 lines
7.8 KiB
Vue
373 lines
7.8 KiB
Vue
<template>
|
||
<div class="search-results">
|
||
<!-- 搜索头部 -->
|
||
<div class="search-header">
|
||
<div class="search-info">
|
||
<h1 class="search-title">
|
||
<Icon icon="tabler:search" class="title-icon" />
|
||
{{ t('Search Results') }}
|
||
</h1>
|
||
<p class="search-subtitle" v-if="query">
|
||
{{ t('Search keyword') }}: "{{ query }}"
|
||
</p>
|
||
<p class="search-subtitle" v-if="!loading && results.length > 0">
|
||
{{ t('Found') }} {{ results.length }} {{ t('relevant results') }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 加载状态 -->
|
||
<div v-if="loading" class="loading-container">
|
||
<n-spin size="large">
|
||
<div class="loading-text">{{ t('Searching...') }}</div>
|
||
</n-spin>
|
||
</div>
|
||
|
||
<!-- 搜索结果 -->
|
||
<div v-else-if="results.length > 0" class="results-container">
|
||
<div class="results-grid">
|
||
<div
|
||
v-for="result in results"
|
||
:key="result.id"
|
||
class="result-card"
|
||
@click="handleResultClick(result)"
|
||
>
|
||
<div class="result-header">
|
||
<div class="result-meta">
|
||
<span class="result-type">{{ result.payload.pagetype }}</span>
|
||
<span class="result-score">{{ t('Similarity') }}: {{ Math.round(result.score * 100) }}%</span>
|
||
</div>
|
||
<div class="result-title">
|
||
{{ result.payload.title || result.payload.name }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-content">
|
||
<p class="result-summary" v-if="result.payload.content">
|
||
{{ truncateText(result.payload.content, 200) }}
|
||
</p>
|
||
<div class="result-tags" v-if="result.payload.category">
|
||
<n-tag size="small" type="info">{{ result.payload.category }}</n-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-footer">
|
||
<span class="result-date" v-if="result.payload.modified">
|
||
{{ formatDate(result.payload.modified) }}
|
||
</span>
|
||
<Icon icon="tabler:arrow-right" class="result-arrow" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 无结果状态 -->
|
||
<div v-else-if="!loading && query" class="empty-state">
|
||
<div class="empty-icon">
|
||
<Icon icon="tabler:search-off" />
|
||
</div>
|
||
<h3 class="empty-title">{{ t('No relevant results found') }}</h3>
|
||
<p class="empty-description">
|
||
{{ t('Try using different keywords or check spelling') }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 初始状态 -->
|
||
<div v-else class="initial-state">
|
||
<div class="initial-icon">
|
||
<Icon icon="tabler:search" />
|
||
</div>
|
||
<h3 class="initial-title">{{ t('Start searching') }}</h3>
|
||
<p class="initial-description">
|
||
{{ t('Enter keywords in the search box above to start searching') }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { NSpin, NTag, useMessage } from 'naive-ui'
|
||
import { Icon } from '@iconify/vue'
|
||
import { searchText } from '../shared/api/embedding'
|
||
import { t, locale } from '../shared/i18n'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const message = useMessage()
|
||
|
||
// 响应式数据
|
||
const query = ref('')
|
||
const results = ref<Array<{
|
||
id: string
|
||
score: number
|
||
payload: Record<string, any>
|
||
}>>([])
|
||
const loading = ref(false)
|
||
|
||
// 执行搜索
|
||
const performSearch = async (searchQuery: string) => {
|
||
if (!searchQuery.trim()) {
|
||
results.value = []
|
||
return
|
||
}
|
||
|
||
loading.value = true
|
||
try {
|
||
const response = await searchText(searchQuery, 'knowledge_base', 20, 0.5)
|
||
|
||
if (response.success && response.data) {
|
||
results.value = response.data
|
||
} else {
|
||
message.error(response.message || t('Search failed'))
|
||
results.value = []
|
||
}
|
||
} catch (error: any) {
|
||
console.error('搜索错误:', error)
|
||
message.error(t('An error occurred during search'))
|
||
results.value = []
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 监听路由变化
|
||
watch(() => route.query.q, (newQuery) => {
|
||
if (newQuery && typeof newQuery === 'string') {
|
||
query.value = newQuery
|
||
performSearch(newQuery)
|
||
}
|
||
}, { immediate: true })
|
||
|
||
// 处理结果点击
|
||
const handleResultClick = (result: any) => {
|
||
const { pagetype, name } = result.payload
|
||
if (pagetype && name) {
|
||
router.push(`/app/${pagetype}/${name}`)
|
||
}
|
||
}
|
||
|
||
// 文本截断
|
||
const truncateText = (text: string, maxLength: number): string => {
|
||
if (!text) return ''
|
||
if (text.length <= maxLength) return text
|
||
return text.substring(0, maxLength) + '...'
|
||
}
|
||
|
||
// 日期格式化
|
||
const formatDate = (dateString: string): string => {
|
||
try {
|
||
const date = new Date(dateString)
|
||
if (locale.value === 'zh-CN') {
|
||
// 中文格式:2025-10-08
|
||
return date.toISOString().split('T')[0]
|
||
} else {
|
||
// 英文格式:Oct 8, 2025
|
||
return date.toLocaleDateString('en-US', {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: 'numeric'
|
||
})
|
||
}
|
||
} catch {
|
||
return dateString
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.search-results {
|
||
padding: 24px;
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.search-header {
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.search-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
font-size: 28px;
|
||
font-weight: 600;
|
||
color: #1f2937;
|
||
margin: 0 0 8px 0;
|
||
}
|
||
|
||
.title-icon {
|
||
color: #3b82f6;
|
||
}
|
||
|
||
.search-subtitle {
|
||
color: #6b7280;
|
||
font-size: 16px;
|
||
margin: 0;
|
||
}
|
||
|
||
.loading-container {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
min-height: 200px;
|
||
}
|
||
|
||
.loading-text {
|
||
margin-top: 16px;
|
||
color: #6b7280;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.results-container {
|
||
margin-top: 24px;
|
||
}
|
||
|
||
.results-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||
gap: 24px;
|
||
}
|
||
|
||
.result-card {
|
||
background: white;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.result-card:hover {
|
||
border-color: #3b82f6;
|
||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.result-header {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.result-meta {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.result-type {
|
||
background: #f3f4f6;
|
||
color: #374151;
|
||
padding: 4px 8px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.result-score {
|
||
color: #10b981;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.result-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #1f2937;
|
||
line-height: 1.4;
|
||
margin: 0;
|
||
}
|
||
|
||
.result-content {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.result-summary {
|
||
color: #6b7280;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
margin: 0 0 12px 0;
|
||
}
|
||
|
||
.result-tags {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.result-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.result-date {
|
||
color: #9ca3af;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.result-arrow {
|
||
color: #d1d5db;
|
||
transition: color 0.2s ease;
|
||
}
|
||
|
||
.result-card:hover .result-arrow {
|
||
color: #3b82f6;
|
||
}
|
||
|
||
.empty-state,
|
||
.initial-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 400px;
|
||
text-align: center;
|
||
}
|
||
|
||
.empty-icon,
|
||
.initial-icon {
|
||
font-size: 64px;
|
||
color: #d1d5db;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.empty-title,
|
||
.initial-title {
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
color: #374151;
|
||
margin: 0 0 12px 0;
|
||
}
|
||
|
||
.empty-description,
|
||
.initial-description {
|
||
color: #6b7280;
|
||
font-size: 16px;
|
||
margin: 0;
|
||
max-width: 400px;
|
||
}
|
||
|
||
/* 响应式设计 */
|
||
@media (max-width: 768px) {
|
||
.search-results {
|
||
padding: 16px;
|
||
}
|
||
|
||
.results-grid {
|
||
grid-template-columns: 1fr;
|
||
gap: 16px;
|
||
}
|
||
|
||
.result-card {
|
||
padding: 16px;
|
||
}
|
||
|
||
.search-title {
|
||
font-size: 24px;
|
||
}
|
||
}
|
||
</style>
|