mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 11:37:31 +08:00
325 lines
9.9 KiB
Vue
325 lines
9.9 KiB
Vue
<template>
|
|
<Teleport to="body">
|
|
<Transition name="cfg-modal">
|
|
<div v-if="open" class="cfg-modal-overlay" @click.self="emit('close')">
|
|
<div class="cfg-modal" style="max-width:700px">
|
|
|
|
<!-- Header -->
|
|
<div class="cfg-modal__header">
|
|
<div class="cfg-modal__title-group">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
</svg>
|
|
<span>{{ t('wiki.configPanel.searchPreview') }}</span>
|
|
<span v-if="kbName" class="cfg-modal__kb">{{ kbName }}</span>
|
|
</div>
|
|
<div class="cfg-modal__actions">
|
|
<button class="btn-cfg-close" @click="emit('close')">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Body -->
|
|
<div class="sp-body">
|
|
|
|
<!-- Input row -->
|
|
<div class="sp-input-row">
|
|
<input
|
|
ref="inputEl"
|
|
v-model="query"
|
|
type="text"
|
|
class="sp-input"
|
|
:placeholder="t('wiki.configPanel.searchPreviewPlaceholder')"
|
|
@keyup.enter="runSearch"
|
|
/>
|
|
<button class="sp-btn" @click="runSearch" :disabled="searching || !query.trim()">
|
|
<svg v-if="searching" class="sp-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
|
</svg>
|
|
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
</svg>
|
|
{{ searching ? t('wiki.configPanel.searching') : t('wiki.configPanel.searchPreviewRun') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Mode chips -->
|
|
<div class="sp-modes">
|
|
<button
|
|
v-for="m in modes"
|
|
:key="m.value"
|
|
:class="['sp-mode-chip', { active: searchMode === m.value }]"
|
|
@click="searchMode = m.value"
|
|
>{{ m.label }}</button>
|
|
</div>
|
|
|
|
<!-- Results -->
|
|
<div v-if="results.length > 0" class="sp-results">
|
|
<div class="sp-results-header">
|
|
<span>找到 {{ results.length }} 条结果</span>
|
|
<button class="sp-clear" @click="results = []">清除</button>
|
|
</div>
|
|
<div v-for="(r, idx) in results" :key="r.slug" class="sp-item">
|
|
<div class="sp-item__rank">{{ idx + 1 }}</div>
|
|
<div class="sp-item__body">
|
|
<div class="sp-item__head">
|
|
<span class="sp-item__title">{{ r.title }}</span>
|
|
<span class="sp-item__slug">[[{{ r.slug }}]]</span>
|
|
<span v-if="r.score != null" class="sp-item__score">{{ (r.score * 100).toFixed(0) }}%</span>
|
|
</div>
|
|
<div v-if="r.snippet" class="sp-item__snippet">{{ r.snippet }}</div>
|
|
<div v-if="r.matchedBy?.length || r.reason" class="sp-item__meta">
|
|
<span v-if="r.matchedBy?.length" class="sp-item__matched">
|
|
{{ r.matchedBy.join(' · ') }}
|
|
</span>
|
|
<span v-if="r.reason" class="sp-item__reason">{{ r.reason }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty state -->
|
|
<div v-else-if="searched && !searching" class="sp-empty">
|
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
<line x1="8" y1="11" x2="14" y2="11"/>
|
|
</svg>
|
|
<p>无匹配结果</p>
|
|
</div>
|
|
|
|
<!-- Hint (before first search) -->
|
|
<div v-else-if="!searched" class="sp-hint">
|
|
输入问题或关键词,测试知识库的检索效果
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, watch, nextTick } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { wikiApi } from '@/api/index'
|
|
|
|
const { t } = useI18n()
|
|
|
|
const props = defineProps<{
|
|
open: boolean
|
|
kbId: number
|
|
kbName?: string
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'close'): void
|
|
}>()
|
|
|
|
interface SearchResult {
|
|
slug: string
|
|
title: string
|
|
snippet?: string
|
|
matchedBy?: string[]
|
|
reason?: string
|
|
score: number
|
|
}
|
|
|
|
const modes = [
|
|
{ value: 'hybrid', label: '混合' },
|
|
{ value: 'semantic', label: '语义' },
|
|
{ value: 'keyword', label: '关键词' },
|
|
]
|
|
|
|
const query = ref('')
|
|
const searchMode = ref('hybrid')
|
|
const results = ref<SearchResult[]>([])
|
|
const searching = ref(false)
|
|
const searched = ref(false)
|
|
const inputEl = ref<HTMLInputElement | null>(null)
|
|
|
|
watch(() => props.open, (v) => {
|
|
if (v) {
|
|
results.value = []
|
|
searched.value = false
|
|
nextTick(() => inputEl.value?.focus())
|
|
}
|
|
})
|
|
|
|
async function runSearch() {
|
|
if (!query.value.trim() || !props.kbId) return
|
|
searching.value = true
|
|
searched.value = true
|
|
try {
|
|
const res: any = await wikiApi.searchPreview(props.kbId, {
|
|
query: query.value.trim(),
|
|
mode: searchMode.value,
|
|
topK: 8,
|
|
})
|
|
results.value = res.data || res || []
|
|
} catch (e) {
|
|
console.error('[SearchPreview] Failed:', e)
|
|
results.value = []
|
|
} finally {
|
|
searching.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.sp-body {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
padding: 18px 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
}
|
|
|
|
.sp-input-row { display: flex; gap: 8px; }
|
|
.sp-input {
|
|
flex: 1;
|
|
padding: 10px 14px;
|
|
border: 1px solid var(--mc-border);
|
|
border-radius: 10px;
|
|
font-size: 14px;
|
|
background: var(--mc-bg-elevated);
|
|
color: var(--mc-text-primary);
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.sp-input:focus { border-color: var(--mc-primary); }
|
|
|
|
.sp-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 10px 18px;
|
|
border: none;
|
|
border-radius: 10px;
|
|
background: var(--mc-primary);
|
|
color: white;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
.sp-btn:hover { opacity: 0.88; }
|
|
.sp-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.sp-spin {
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
.sp-modes { display: flex; gap: 6px; }
|
|
.sp-mode-chip {
|
|
padding: 4px 12px;
|
|
border-radius: 99px;
|
|
border: 1px solid var(--mc-border);
|
|
background: var(--mc-bg-elevated);
|
|
color: var(--mc-text-tertiary);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.sp-mode-chip.active { border-color: var(--mc-primary); color: var(--mc-primary); background: var(--mc-primary-bg); font-weight: 500; }
|
|
.sp-mode-chip:not(.active):hover { border-color: var(--mc-border); color: var(--mc-text-secondary); background: var(--mc-bg-sunken); }
|
|
|
|
.sp-results { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--mc-border-light); border-radius: 12px; overflow: hidden; }
|
|
.sp-results-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 14px;
|
|
background: var(--mc-bg-sunken);
|
|
font-size: 11px;
|
|
color: var(--mc-text-tertiary);
|
|
border-bottom: 1px solid var(--mc-border-light);
|
|
}
|
|
.sp-clear { background: none; border: none; cursor: pointer; font-size: 11px; color: var(--mc-text-tertiary); padding: 0; }
|
|
.sp-clear:hover { color: var(--mc-danger, #e53); }
|
|
|
|
.sp-item {
|
|
display: flex;
|
|
gap: 12px;
|
|
padding: 14px 16px;
|
|
border-bottom: 1px solid var(--mc-border-light);
|
|
transition: background 0.1s;
|
|
}
|
|
.sp-item:last-child { border-bottom: none; }
|
|
.sp-item:hover { background: var(--mc-bg-muted); }
|
|
|
|
.sp-item__rank {
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 50%;
|
|
background: var(--mc-bg-sunken);
|
|
border: 1px solid var(--mc-border-light);
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
color: var(--mc-text-tertiary);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
margin-top: 1px;
|
|
}
|
|
|
|
.sp-item__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 5px; }
|
|
.sp-item__head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
|
.sp-item__title { font-size: 14px; font-weight: 600; color: var(--mc-text-primary); }
|
|
.sp-item__slug { font-size: 11px; font-family: 'JetBrains Mono', monospace; color: var(--mc-primary); opacity: 0.8; }
|
|
.sp-item__score {
|
|
margin-left: auto;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--mc-primary);
|
|
background: var(--mc-primary-bg);
|
|
padding: 1px 7px;
|
|
border-radius: 99px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.sp-item__snippet {
|
|
font-size: 13px;
|
|
color: var(--mc-text-secondary);
|
|
line-height: 1.6;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.sp-item__meta { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
.sp-item__matched {
|
|
font-size: 11px;
|
|
color: var(--mc-text-tertiary);
|
|
background: var(--mc-bg-sunken);
|
|
padding: 1px 7px;
|
|
border-radius: 6px;
|
|
}
|
|
.sp-item__reason { font-size: 11px; color: var(--mc-text-tertiary); font-style: italic; }
|
|
|
|
/* Empty / hint */
|
|
.sp-empty {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
padding: 48px 0;
|
|
color: var(--mc-text-tertiary);
|
|
}
|
|
.sp-empty p { font-size: 13px; margin: 0; }
|
|
|
|
.sp-hint {
|
|
padding: 40px 0;
|
|
text-align: center;
|
|
font-size: 13px;
|
|
color: var(--mc-text-tertiary);
|
|
line-height: 1.6;
|
|
}
|
|
</style>
|