Files
InsightRadar/frontend/src/views/SearchView.vue
T
stardrophere 51ec2f16da 界面优化
2026-03-14 00:35:24 +08:00

662 lines
16 KiB
Vue
Raw 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.
<!-- 事件追踪分析页关键词搜索时间热度图表关联事件列表 -->
<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'
import CustomSelect from '@/components/CustomSelect.vue'
const keyword = ref('')
const searchResult = ref<SearchTimelineResponse | null>(null)
const loading = ref(false)
const hours = ref(2)
const searchMode = ref<'exact' | 'semantic' | 'hybrid'>('exact')
const selectedTimeLabel = ref<string | null>(null)
const timeOptions = [
{ label: '最近 2 小时', value: 2 },
{ label: '最近 12 小时', value: 12 },
{ label: '最近 24 小时', value: 24 },
{ label: '最近 48 小时', value: 48 },
{ label: '最近 7 天', value: 168 },
{ label: '最近 15 天', value: 360 }
]
const modeOptions = [
{ label: '混合匹配', value: 'hybrid' },
{ label: '关键词匹配', value: 'exact' },
{ label: '语义匹配', value: 'semantic' }
]
// 根据选中的时间点筛选事件:点击图表节点时只显示该时间点关联的事件
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>
<CustomSelect
v-model="hours"
:options="timeOptions"
icon="fa-regular fa-clock"
/>
<CustomSelect
v-model="searchMode"
:options="modeOptions"
icon="fa-solid fa-filter"
/>
<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;
position: relative;
z-index: 10; /* 确保搜索框及下拉选择框显示在下方图表之上 */
}
.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);
}
.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;
position: relative;
z-index: 1; /* 低于 top-panels,避免图表覆盖搜索框下拉 */
}
.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>