mirror of
https://github.com/stardrophere/InsightRadar.git
synced 2026-06-05 23:07:51 +08:00
667 lines
16 KiB
Vue
667 lines
16 KiB
Vue
<!-- 事件追踪分析页:关键词搜索、时间热度图表、关联事件列表 -->
|
||
<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'
|
||
import type { ApexOptions } from 'apexcharts'
|
||
|
||
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<ApexOptions>({
|
||
chart: {
|
||
type: 'area',
|
||
height: 350,
|
||
background: 'transparent',
|
||
toolbar: {
|
||
show: true
|
||
},
|
||
animations: {
|
||
enabled: true,
|
||
// easing: 'easeinout',
|
||
speed: 800,
|
||
},
|
||
// 点击图表数据点:切换选中时间,再次点击则取消筛选
|
||
events: {
|
||
markerClick: function(event: unknown, chartContext: unknown, { dataPointIndex }: never) {
|
||
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;
|
||
position: relative;
|
||
z-index: 2;
|
||
}
|
||
|
||
.tips-box {
|
||
flex: 1;
|
||
padding: 24px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.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>
|