搜索功能加入

This commit is contained in:
stardrophere
2026-03-13 18:25:38 +08:00
parent 9440b7f590
commit 6aee65af6c
18 changed files with 1545 additions and 103 deletions
+704
View File
@@ -0,0 +1,704 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import VueApexCharts from 'vue3-apexcharts'
import { searchEventsTimeline } from '@/api/events'
import type { SearchTimelineResponse } from '@/types/event'
import UnifiedEventCard from '@/components/UnifiedEventCard.vue'
const keyword = ref('')
const searchResult = ref<SearchTimelineResponse | null>(null)
const loading = ref(false)
const hours = ref(2)
const searchMode = ref<'exact' | 'semantic' | 'hybrid'>('hybrid')
const selectedTimeLabel = ref<string | null>(null)
const filteredEvents = computed(() => {
if (!searchResult.value) return []
if (!selectedTimeLabel.value) return searchResult.value.events
const selectedPoint = searchResult.value.timeline.find(p => p.time_label === selectedTimeLabel.value)
if (!selectedPoint) return []
const pointEventIds = selectedPoint.event_ids ?? []
if (pointEventIds.length > 0) {
const relatedIds = new Set(pointEventIds)
return searchResult.value.events.filter(event => relatedIds.has(event.event_id))
}
// 向后兼容:旧版后端未返回 event_ids 时,退化为时间范围过滤。
return searchResult.value.events.filter(event => {
const createdStr = event.created_at.replace('T', ' ')
const activeStr = event.last_active_at.replace('T', ' ')
const len = selectedTimeLabel.value!.length
const start = createdStr.substring(0, len)
const end = activeStr.substring(0, len)
const target = selectedTimeLabel.value!
return (start <= target && target <= end) || createdStr.startsWith(target) || activeStr.startsWith(target)
})
})
// 热度时间线图表配置。
const chartOptions = ref({
chart: {
type: 'area',
height: 350,
background: 'transparent',
toolbar: {
show: true
},
animations: {
enabled: true,
easing: 'easeinout',
speed: 800,
},
events: {
markerClick: function(event: any, chartContext: any, { dataPointIndex }: any) {
if (searchResult.value && searchResult.value.timeline[dataPointIndex]) {
const clickedTime = searchResult.value.timeline[dataPointIndex].time_label
if (selectedTimeLabel.value === clickedTime) {
selectedTimeLabel.value = null
} else {
selectedTimeLabel.value = clickedTime
// 点击时间点后平滑滚动到事件列表。
setTimeout(() => {
document.getElementById('events-section-anchor')?.scrollIntoView({ behavior: 'smooth' })
}, 100)
}
}
}
}
},
theme: {
mode: 'dark' // 默认使用暗色图表主题,保证深浅主题下对比度都清晰。
},
dataLabels: {
enabled: false
},
stroke: {
curve: 'smooth',
width: 3
},
xaxis: {
type: 'category',
categories: [] as string[],
labels: {
style: {
colors: '#9ca3af'
}
},
axisBorder: {
show: false
},
axisTicks: {
show: false
}
},
yaxis: {
title: {
text: '热点数量',
style: {
color: '#9ca3af',
fontWeight: 500
}
},
labels: {
style: {
colors: '#9ca3af'
}
}
},
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.6,
opacityTo: 0.1,
stops: [0, 90, 100]
}
},
grid: {
borderColor: 'rgba(156, 163, 175, 0.1)',
strokeDashArray: 4,
},
colors: ['#6366f1'],
tooltip: {
theme: 'dark'
}
})
const series = ref([{
name: '热点分布',
data: [] as number[]
}])
async function handleSearch() {
if (!keyword.value.trim()) return
loading.value = true
searchResult.value = null
selectedTimeLabel.value = null
try {
const res = await searchEventsTimeline(keyword.value.trim(), hours.value, searchMode.value)
searchResult.value = res
// 查询成功后同步刷新图表横轴与序列。
chartOptions.value = {
...chartOptions.value,
xaxis: {
...chartOptions.value.xaxis,
categories: res.timeline.map(p => p.time_label)
}
}
series.value = [{
name: '热点数量',
data: res.timeline.map(p => p.count)
}]
} catch (error) {
console.error('搜索失败', error)
} finally {
loading.value = false
}
}
</script>
<template>
<div class="search-view">
<div class="content-wrapper">
<header class="page-header">
<div class="header-title-row">
<h1 class="page-title">
<i class="fa-solid fa-chart-line" style="color: var(--brand-primary)"></i>
事件追踪分析
</h1>
</div>
<p class="page-desc">基于语义匹配与正则检索分析关键词在时间维度上的热度变化与关联聚合事件</p>
</header>
<div class="top-panels">
<div class="search-box glass-panel">
<h2 class="panel-title"><i class="fa-solid fa-magnifying-glass-chart"></i> 关键词搜索</h2>
<div class="search-controls">
<div class="input-wrapper">
<i class="fa-solid fa-magnifying-glass search-icon"></i>
<input
v-model="keyword"
@keyup.enter="handleSearch"
type="text"
placeholder="请输入关键词"
class="search-input"
/>
</div>
<div class="time-select-wrapper">
<i class="fa-regular fa-clock select-icon"></i>
<select v-model="hours" class="time-select">
<option :value="2">最近 2 小时</option>
<option :value="12">最近 12 小时</option>
<option :value="24">最近 24 小时</option>
<option :value="48">最近 48 小时</option>
<option :value="168">最近 7 </option>
<option :value="360">最近 15 </option>
</select>
<i class="fa-solid fa-chevron-down select-arrow"></i>
</div>
<div class="time-select-wrapper">
<i class="fa-solid fa-filter select-icon"></i>
<select v-model="searchMode" class="time-select">
<option value="hybrid">混合匹配</option>
<option value="exact">关键词匹配</option>
<option value="semantic">语义匹配</option>
</select>
<i class="fa-solid fa-chevron-down select-arrow"></i>
</div>
<button class="btn btn-primary" @click="handleSearch" :disabled="loading || !keyword.trim()">
<i class="fa-solid fa-bolt" v-if="!loading"></i>
<i class="fa-solid fa-spinner fa-spin" v-else></i>
追踪计算
</button>
</div>
</div>
<div class="tips-box glass-panel">
<h2 class="panel-title"><i class="fa-regular fa-lightbulb"></i> 搜索建议</h2>
<div class="tips-content">
<button class="tip-tag" @click="keyword='新能源汽车'; hours=168; handleSearch()">
<i class="fa-solid fa-rocket"></i> 新能源汽车
</button>
<button class="tip-tag" @click="keyword='苹果公司'; hours=168; handleSearch()">
<i class="fa-brands fa-apple"></i> 苹果产业链
</button>
<button class="tip-tag regex-tag" @click="keyword='AI|LLM'; hours=168; handleSearch()">
<i class="fa-solid fa-code-branch"></i> AI / 大模型
</button>
<button class="tip-tag regex-tag" @click="keyword='美国关税'; hours=168; handleSearch()">
<i class="fa-solid fa-flag-usa"></i> 美国关税
</button>
</div>
</div>
</div>
<div v-if="loading" class="loading-state glass-panel">
<div class="spinner-wrapper">
<i class="fa-solid fa-circle-notch fa-spin"></i>
</div>
<p>引擎正在计算热度模型并提取时间节点请稍候...</p>
</div>
<div v-else-if="searchResult" class="results-container">
<section class="chart-section glass-panel">
<div class="section-header">
<h2 class="section-title">
<i class="fa-solid fa-wave-square"></i> 时间热度脉络
</h2>
<span class="meta-info"> {{ searchResult.timeline.length }} 个时间节点 · 覆盖 {{ searchResult.events.length }} 个聚合事件</span>
</div>
<div class="chart-container" v-if="searchResult.timeline.length > 0">
<VueApexCharts
type="area"
height="350"
:options="chartOptions"
:series="series"
/>
</div>
<div v-else class="empty-state">
<i class="fa-regular fa-folder-open"></i>
<p>该时间范围暂无热度节点</p>
</div>
</section>
<section id="events-section-anchor" class="events-section">
<div class="section-header">
<h2 class="section-title">
<i class="fa-solid fa-layer-group"></i> 关联深度聚合事件
<span v-if="selectedTimeLabel" class="time-filter-badge">
筛选: {{ selectedTimeLabel }}
<i class="fa-solid fa-xmark" @click="selectedTimeLabel = null"></i>
</span>
</h2>
<span class="meta-info">共检索到 {{ filteredEvents.length }} 个聚合事件</span>
</div>
<div class="events-grid" v-if="filteredEvents.length > 0">
<UnifiedEventCard
v-for="event in filteredEvents"
:key="event.event_id"
:event="event"
/>
</div>
<div v-else class="empty-state glass-panel">
<i class="fa-solid fa-satellite-dish"></i>
<p v-if="selectedTimeLabel">该时间点未找到匹配事件请尝试点击其他节点</p>
<p v-else>暂无关联聚合事件可尝试扩大时间范围或更换关键词</p>
</div>
</section>
</div>
</div>
</div>
</template>
<style scoped>
.search-view {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
}
.content-wrapper {
display: flex;
flex-direction: column;
gap: 24px;
}
.page-header {
margin-bottom: 8px;
}
.header-title-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.page-title {
font-size: 24px;
font-weight: 700;
color: var(--text-color);
margin: 0;
display: flex;
align-items: center;
gap: 10px;
letter-spacing: 0.02em;
}
.page-desc {
color: var(--text-secondary);
font-size: 14px;
margin: 0;
font-weight: 500;
}
/* 毛玻璃面板 */
.glass-panel {
background: var(--bg-surface);
backdrop-filter: var(--backdrop-blur);
-webkit-backdrop-filter: var(--backdrop-blur);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-sm);
padding: 24px;
transition: all 0.3s ease;
}
.glass-panel:hover {
box-shadow: var(--shadow-md);
border-color: rgba(99, 102, 241, 0.2);
}
.top-panels {
display: flex;
gap: 24px;
align-items: stretch;
}
.search-box {
flex: 2;
padding: 24px;
display: flex;
flex-direction: column;
justify-content: center;
}
.tips-box {
flex: 1;
padding: 24px;
display: flex;
flex-direction: column;
}
.panel-title {
font-size: 16px;
font-weight: 600;
color: var(--text-color);
margin: 0 0 20px 0;
display: flex;
align-items: center;
gap: 8px;
}
.panel-title i {
color: var(--brand-primary);
}
.tips-content {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.tip-tag {
background: var(--bg-input);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
padding: 8px 14px;
border-radius: var(--radius-lg);
font-size: 13px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 8px;
}
.tip-tag i {
font-size: 14px;
opacity: 0.8;
}
.tip-tag:hover {
background: var(--brand-primary-alpha);
color: var(--brand-primary);
border-color: var(--brand-primary);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15);
}
.tip-tag.regex-tag {
border-style: dashed;
}
.search-controls {
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
.input-wrapper {
flex: 1;
min-width: 250px;
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 16px;
color: var(--text-placeholder);
font-size: 16px;
z-index: 1;
}
.search-input {
width: 100%;
padding: 14px 16px 14px 44px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background-color: var(--bg-input);
color: var(--text-primary);
font-size: 15px;
font-weight: 500;
transition: all 0.2s;
}
.search-input:focus {
outline: none;
border-color: var(--brand-primary);
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
background-color: var(--bg-surface);
}
.time-select-wrapper {
position: relative;
display: flex;
align-items: center;
min-width: 160px;
}
.select-icon {
position: absolute;
left: 14px;
color: var(--text-placeholder);
font-size: 15px;
pointer-events: none;
z-index: 1;
}
.select-arrow {
position: absolute;
right: 14px;
color: var(--text-placeholder);
font-size: 12px;
pointer-events: none;
}
.time-select {
width: 100%;
padding: 14px 36px 14px 40px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background-color: var(--bg-input);
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
appearance: none;
-webkit-appearance: none;
}
.time-select:focus {
outline: none;
border-color: var(--brand-primary);
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
background-color: var(--bg-surface);
}
.time-select-wrapper:hover .time-select {
border-color: var(--brand-primary);
}
.time-select-wrapper:hover .select-icon,
.time-select-wrapper:hover .select-arrow {
color: var(--brand-primary);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 14px 28px;
background-color: var(--brand-primary);
color: white;
border: none;
border-radius: var(--radius-lg);
font-weight: 600;
font-size: 15px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}
.btn-primary:hover:not(:disabled) {
background-color: var(--brand-primary-hover);
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.3);
}
.btn-primary:active:not(:disabled) {
transform: translateY(1px);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
}
.results-container {
display: flex;
flex-direction: column;
gap: 24px;
animation: fadeIn 0.4s ease-out;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 20px;
}
.section-title {
font-size: 18px;
font-weight: 700;
color: var(--text-color);
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.section-title i {
color: var(--brand-primary);
}
.time-filter-badge {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 12px;
padding: 4px 10px;
background: rgba(99, 102, 241, 0.15);
color: var(--brand-primary);
border-radius: var(--radius-full);
font-size: 13px;
font-weight: 500;
vertical-align: middle;
}
.time-filter-badge i {
cursor: pointer;
font-size: 14px;
color: var(--brand-primary) !important;
opacity: 0.7;
transition: opacity 0.2s;
}
.time-filter-badge i:hover {
opacity: 1;
}
.meta-info {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.chart-section {
padding: 24px;
}
.chart-container {
margin-top: 16px;
margin-left: -10px; /* 视觉上抵消 apexcharts 的默认左侧留白。 */
}
.events-section {
margin-top: 8px;
}
.events-grid {
display: flex;
flex-direction: column;
/* 与 DashboardView 保持一致,列表按纵向堆叠展示。 */
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--text-secondary);
gap: 16px;
}
.spinner-wrapper {
font-size: 32px;
color: var(--brand-primary);
}
.loading-state p {
font-size: 15px;
font-weight: 500;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-secondary);
}
.empty-state i {
font-size: 36px;
opacity: 0.4;
margin-bottom: 12px;
display: block;
}
.empty-state p {
font-size: 14px;
margin: 0;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.top-panels {
flex-direction: column;
}
}
@media (max-width: 640px) {
.search-controls {
flex-direction: column;
align-items: stretch;
}
.btn-primary {
justify-content: center;
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
</style>