搜索功能加入

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
+8 -6
View File
@@ -1,17 +1,17 @@
/**
* 通用 HTTP 客户端:自动注入 Bearer Token,统一处理错误
* 通用 HTTP 客户端:自动注入 Bearer Token统一处理错误返回。
*/
import { useAuthStore } from '@/stores/auth'
import { pinia } from '@/stores'
import { fetchApi } from '@/config/apiBase'
// 后端返回的错误消息中英映射
// 后端错误消息中英映射
const MESSAGE_MAP: Record<string, string> = {
'You can only operate your own resources': '只能操作自己的资源',
'Preference keyword already exists for this user': '该关键词已订阅',
'You can only operate your own resources': '只能操作自己的资源',
'Preference keyword already exists for this user': '该关键词已订阅过了',
'Keyword cannot be empty': '关键词不能为空',
'This delivery time already exists': '该推送时间已存在',
'This channel type already exists for the user': '该渠道类型已存在',
'This channel type already exists for the user': '该推送渠道类型已存在',
'Schedule not found': '推送时间不存在',
'Push endpoint not found': '推送渠道不存在',
'Preference not found': '偏好不存在',
@@ -101,7 +101,9 @@ export async function apiDelete(path: string): Promise<void> {
try {
const data = JSON.parse(raw) as Record<string, unknown>
if (typeof data.detail === 'string') detail = localizeMessage(data.detail)
} catch { /* ignore */ }
} catch {
/* 忽略非 JSON 错误体解析失败 */
}
throw new Error(detail)
}
}
+18 -2
View File
@@ -1,7 +1,7 @@
import { apiGet } from './client'
import type { PaginatedEvents, UnifiedEvent, HeadlineRevision, SystemStats } from '@/types/event'
import type { PaginatedEvents, UnifiedEvent, HeadlineRevision, SystemStats, SearchTimelineResponse } from '@/types/event'
/** 按 ID 查询单个统一事件(用于推荐跳转聚光灯展示) */
/** 按 ID 查询单个统一事件(用于推荐跳转后聚焦展示) */
export function fetchEventById(eventId: number): Promise<UnifiedEvent> {
return apiGet<UnifiedEvent>(`/events/unified/${eventId}`)
}
@@ -29,3 +29,19 @@ export function fetchHeadlineRevisions(params?: {
export function fetchSystemStats(): Promise<SystemStats> {
return apiGet<SystemStats>('/system/stats')
}
/** 按关键词查询热度时间线 */
export function searchEventsTimeline(
keyword: string,
hours: number = 168,
mode: 'exact' | 'semantic' | 'hybrid' = 'hybrid'
): Promise<SearchTimelineResponse> {
// JS 的 getTimezoneOffset: 本地 - UTC(东八区是 -480),这里转成 UTC+ 偏移分钟。
const utcOffsetMinutes = -new Date().getTimezoneOffset()
return apiGet<SearchTimelineResponse>('/events/search_timeline', {
keyword,
hours: hours.toString(),
mode,
utc_offset_minutes: utcOffsetMinutes.toString(),
})
}
+3 -2
View File
@@ -1,4 +1,4 @@
/* color palette from <https://github.com/vuejs/theme> */
/* 颜色变量基于 Vue 官方主题并按项目需求调整 */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
@@ -21,7 +21,7 @@
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
/* 项目语义化颜色变量 */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
@@ -81,3 +81,4 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+1 -1
View File
@@ -26,7 +26,7 @@ function handleToggle(event: MouseEvent) {
Math.max(y, innerHeight - y)
)
// @ts-ignore: TypeScript 类型可能较旧,忽略 startViewTransition 报错
// @ts-expect-error: TypeScript 类型可能较旧,忽略 startViewTransition 报错
const transition = document.startViewTransition(() => {
themeStore.toggleTheme()
})
@@ -0,0 +1,479 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import VueApexCharts from 'vue3-apexcharts'
import type { UnifiedEvent } from '@/types/event'
const props = defineProps<{
event: UnifiedEvent
}>()
const router = useRouter()
// 当前鼠标悬停的平台行标识
const hoveredPlatformKey = ref<string | null>(null)
function formatRelativeTime(dateStr: string) {
if (!dateStr) return ''
if (!dateStr.endsWith('Z') && !dateStr.includes('+')) {
dateStr += 'Z'
}
const now = Date.now()
const target = new Date(dateStr).getTime()
const diff = now - target
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes} 分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
return `${days} 天前`
}
function getHotLevel(score: number): { label: string; color: string; bg: string } {
if (score >= 10) return { label: '全网沸腾', color: '#ef4444', bg: 'rgba(239,68,68,0.15)' }
if (score >= 5) return { label: '高度关注', color: '#f97316', bg: 'rgba(249,115,22,0.15)' }
if (score >= 3) return { label: '上升中', color: '#3b82f6', bg: 'rgba(59,130,246,0.15)' }
return { label: '一般关注', color: '#6b7280', bg: 'rgba(107,114,128,0.15)' }
}
const platformIconMap: Record<string, string> = {
微博热搜: 'fa-brands fa-weibo',
微博: 'fa-brands fa-weibo',
知乎热榜: 'fa-brands fa-zhihu',
知乎: 'fa-brands fa-zhihu',
百度热搜: 'fa-solid fa-b',
今日头条: 'fa-solid fa-newspaper',
抖音热榜: 'fa-brands fa-tiktok',
抖音: 'fa-brands fa-tiktok',
'B站热搜': 'fa-brands fa-bilibili',
'bilibili 热搜': 'fa-brands fa-bilibili',
华尔街见闻: 'fa-solid fa-chart-line',
澎湃新闻: 'fa-solid fa-water',
财联社热门: 'fa-solid fa-coins',
凤凰网: 'fa-solid fa-feather',
贴吧: 'fa-solid fa-comments',
}
const platformColorMap: Record<string, string> = {
微博热搜: '#e6162d',
微博: '#e6162d',
知乎热榜: '#0066ff',
知乎: '#0066ff',
百度热搜: '#306cff',
今日头条: '#ff0000',
抖音热榜: '#000000',
抖音: '#000000',
'B站热搜': '#fb7299',
'bilibili 热搜': '#fb7299',
华尔街见闻: '#d4a853',
澎湃新闻: '#1e6cff',
财联社热门: '#c41230',
凤凰网: '#f8b500',
贴吧: '#4e6ef2',
}
function getPlatformIcon(name: string): string {
return platformIconMap[name] || 'fa-solid fa-globe'
}
function getPlatformColor(name: string): string {
return platformColorMap[name] || 'var(--text-secondary)'
}
function getRankingTrend(p: { current_ranking: number | null; ranking_history: number[] }) {
const rank = p.current_ranking
if (!rank) return { icon: 'fa-solid fa-minus', color: 'var(--text-secondary)', text: '' }
const h = p.ranking_history
if (h.length < 2) return { icon: 'fa-solid fa-minus', color: '#f97316', text: `TOP ${rank}` }
const prev = h[h.length - 2]
const curr = h[h.length - 1]
if (prev === undefined || curr === undefined) return { icon: 'fa-solid fa-minus', color: '#f97316', text: `TOP ${rank}` }
const diff = prev - curr
if (diff > 0) {
return { icon: 'fa-solid fa-arrow-trend-up', color: '#ef4444', text: `TOP ${rank}${diff}` }
}
if (diff < 0) {
return { icon: 'fa-solid fa-arrow-trend-down', color: '#10b981', text: `TOP ${rank}${Math.abs(diff)}` }
}
return { icon: 'fa-solid fa-equals', color: '#f97316', text: `TOP ${rank}` }
}
function getRankingChartOptions(history: number[], platformColor: string) {
return {
series: [{ name: '排名', data: history }],
chart: {
type: 'area' as const,
height: 56,
sparkline: { enabled: true },
animations: { enabled: true, easing: 'easeinout' as const, speed: 400 },
},
stroke: { curve: 'smooth' as const, width: 2 },
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.4,
opacityTo: 0.05,
stops: [0, 90, 100],
colorStops: [
{ offset: 0, color: platformColor, opacity: 0.3 },
{ offset: 100, color: platformColor, opacity: 0 },
],
},
},
yaxis: { reversed: true },
colors: [platformColor],
tooltip: {
theme: 'dark',
fixed: { enabled: false },
x: { show: false },
y: { title: { formatter: () => '第' }, formatter: (val: number) => `${val}` },
},
}
}
function platformKey(eventId: number, index: number, prefix: string = ''): string {
return prefix ? `${prefix}-${eventId}-${index}` : `${eventId}-${index}`
}
function goToDashboard() {
router.push({
name: 'dashboard',
query: { event: props.event.event_id }
})
}
</script>
<template>
<article
class="event-card"
:class="{ 'is-hot': event.hot_score >= 50 }"
>
<div class="card-top">
<div class="tags-row">
<span
class="hot-badge"
:style="{ color: getHotLevel(event.hot_score).color, background: getHotLevel(event.hot_score).bg }"
>
{{ getHotLevel(event.hot_score).label }} ({{ event.hot_score }})
</span>
<span v-for="tag in event.tags.slice(0, 3)" :key="tag" class="topic-tag">{{ tag }}</span>
</div>
<div class="event-time-range" title="左侧为首次发现时间,右侧为最后活跃时间">
<span>{{ formatRelativeTime(event.created_at) }}</span>
<template v-if="formatRelativeTime(event.created_at) !== formatRelativeTime(event.last_active_at)">
<i class="fa-solid fa-arrow-right time-arrow"></i>
<span class="active-time">{{ formatRelativeTime(event.last_active_at) }}</span>
</template>
</div>
</div>
<h3 class="card-title" @click="goToDashboard" style="cursor: pointer;">
{{ event.unified_title }}
</h3>
<div v-if="event.summary" class="ai-summary">
<i class="fa-solid fa-wand-magic-sparkles summary-icon"></i>
<p>
<span class="summary-label">AI 全局洞察</span>
{{ event.summary }}
</p>
</div>
<div v-if="event.platforms.length > 0" class="platforms-list">
<div
v-for="(p, pIdx) in event.platforms"
:key="pIdx"
class="platform-block"
@mouseenter="hoveredPlatformKey = platformKey(event.event_id, pIdx)"
@mouseleave="hoveredPlatformKey = null"
>
<div class="platform-row">
<div class="platform-info">
<i
:class="getPlatformIcon(p.platform_name)"
:style="{ color: getPlatformColor(p.platform_name) }"
></i>
<a
v-if="p.url"
:href="p.url"
target="_blank"
rel="noopener"
class="platform-headline platform-link"
>
{{ p.platform_name }}{{ p.headline }}
</a>
<span v-else class="platform-headline">{{ p.platform_name }}{{ p.headline }}</span>
</div>
<span v-if="p.current_ranking" class="ranking-badge" :style="{ color: getRankingTrend(p).color }">
<i :class="getRankingTrend(p).icon"></i> {{ getRankingTrend(p).text }}
</span>
</div>
<transition name="chart-expand">
<div
v-if="
hoveredPlatformKey === platformKey(event.event_id, pIdx) &&
p.ranking_history && p.ranking_history.length > 1
"
class="inline-chart"
>
<p class="chart-label">
<i
:class="getPlatformIcon(p.platform_name)"
:style="{ color: getPlatformColor(p.platform_name) }"
></i>
{{ p.platform_name }}「{{ p.headline.length > 16 ? p.headline.slice(0, 16) + '...' : p.headline }}」排名轨迹
</p>
<VueApexCharts
type="area"
height="56"
:options="getRankingChartOptions(p.ranking_history, getPlatformColor(p.platform_name))"
:series="getRankingChartOptions(p.ranking_history, getPlatformColor(p.platform_name)).series"
/>
</div>
</transition>
</div>
</div>
</article>
</template>
<style scoped>
.event-card {
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);
padding: 24px;
margin-bottom: 20px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
}
.event-card:hover {
border-color: var(--brand-primary-alpha);
box-shadow: var(--shadow-xl);
transform: translateY(-2px);
}
.event-card.is-hot {
border-left: 4px solid #ef4444;
}
.card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.tags-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.hot-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
}
.topic-tag {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
background: var(--bg-input);
color: var(--text-secondary);
}
.event-time-range {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.time-arrow {
font-size: 10px;
color: var(--text-placeholder);
}
.active-time {
color: var(--brand-primary);
font-weight: 500;
}
.card-title {
font-size: 20px;
font-weight: 700;
margin: 0 0 12px;
line-height: 1.4;
color: var(--text-color);
transition: color 0.2s;
}
.card-title:hover {
color: var(--brand-primary);
}
.ai-summary {
display: flex;
gap: 12px;
padding: 16px;
border-radius: var(--radius-lg);
background: linear-gradient(145deg, var(--brand-primary-alpha), transparent);
border: 1px solid rgba(99, 102, 241, 0.1);
margin-bottom: 20px;
box-shadow: inset 0 2px 4px rgba(255,255,255,0.05);
}
.summary-icon {
color: var(--brand-primary);
margin-top: 2px;
flex-shrink: 0;
font-size: 16px;
}
.ai-summary p {
font-size: 14px;
line-height: 1.8;
color: var(--text-secondary);
margin: 0;
font-weight: 500;
}
.summary-label {
font-weight: 600;
background: linear-gradient(to right, #a78bfa, #818cf8);
-webkit-background-clip: text;
color: transparent;
}
.platforms-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.platform-block {
border-radius: var(--radius-md);
transition: background 0.15s;
}
.platform-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-radius: var(--radius-md);
background: var(--bg-input);
border: 1px solid transparent;
font-size: 14px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.platform-block:hover .platform-row {
background: var(--bg-surface);
border-color: var(--border-subtle);
transform: scale(1.01);
box-shadow: var(--shadow-sm);
}
.platform-info {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
font-size: 14px;
font-weight: 500;
}
.platform-info i {
font-size: 16px;
width: 20px;
text-align: center;
flex-shrink: 0;
}
.platform-headline {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-color);
}
.platform-link {
text-decoration: none;
color: inherit;
transition: color 0.15s;
}
.platform-link:hover {
color: var(--brand-primary);
}
.ranking-badge {
font-size: 12px;
font-weight: 700;
white-space: nowrap;
margin-left: 8px;
display: inline-flex;
align-items: center;
gap: 4px;
transition: color 0.2s;
}
.inline-chart {
padding: 8px 12px 10px;
background: var(--bg-input);
border-radius: 0 0 var(--radius-md) var(--radius-md);
margin-top: -2px;
}
.chart-label {
font-size: 11px;
color: var(--text-secondary);
margin: 0 0 4px;
display: flex;
align-items: center;
gap: 6px;
}
.chart-label i {
font-size: 12px;
}
.chart-expand-enter-active {
transition: all 0.25s ease-out;
}
.chart-expand-leave-active {
transition: all 0.15s ease-in;
}
.chart-expand-enter-from,
.chart-expand-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
overflow: hidden;
}
.chart-expand-enter-to,
.chart-expand-leave-from {
opacity: 1;
max-height: 120px;
}
</style>
@@ -1,4 +1,4 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<!-- 图标来源<https://github.com/Templarian/MaterialDesign>遵循 Apache 2.0 许可 -->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -17,3 +17,4 @@
></path>
</svg>
</template>
+1
View File
@@ -20,6 +20,7 @@ const avatarUrl = computed(
const navItems = [
{ name: '全局热点池', icon: 'fa-solid fa-fire', route: '/' },
{ name: '事件追溯分析', icon: 'fa-solid fa-chart-line', route: '/search' },
{ name: '公关修改追踪', icon: 'fa-solid fa-mask', route: '/revisions' },
{ name: '我的泛订阅', icon: 'fa-solid fa-rss', route: '/topics' },
{ name: 'AI 简报设置', icon: 'fa-solid fa-paper-plane', route: '/delivery' },
+5
View File
@@ -49,6 +49,11 @@ const router = createRouter({
name: 'delivery',
component: () => import('@/views/DeliveryView.vue'),
},
{
path: 'search',
name: 'search',
component: () => import('@/views/SearchView.vue'),
},
],
},
],
+15 -1
View File
@@ -20,7 +20,7 @@ export interface UnifiedEvent {
tags: string[]
}
/** 分页包装 */
/** 分页响应 */
export interface PaginatedEvents {
total: number
has_more: boolean
@@ -48,3 +48,17 @@ export interface SystemStats {
error_tasks_today: number
last_sync_at: string | null
}
/** 搜索时间点数据 */
export interface TimelineDataPoint {
time_label: string
count: number
event_ids: number[]
}
/** 时间线搜索结果 */
export interface SearchTimelineResponse {
keyword: string
timeline: TimelineDataPoint[]
events: UnifiedEvent[]
}
+2
View File
@@ -36,10 +36,12 @@ function showSuccess(msg: string) {
}
function getChannelLabel(_type: string): string {
void _type
return '邮箱'
}
function getChannelIcon(_type: string): string {
void _type
return 'fa-solid fa-envelope'
}
+30 -42
View File
@@ -4,13 +4,13 @@ import { computed, onMounted, ref } from 'vue'
import { fetchHeadlineRevisions } from '@/api/events'
import type { HeadlineRevision } from '@/types/event'
/** 按事件分组后的修改链 */
/** 按事件分组后的标题修改链 */
interface RevisionChain {
event_id: number
source_name: string | null
/** 标题演变链:从最早 previous 到最 revised已去重 */
/** 标题演变链:从最早 previous 到最 revised已去重 */
titles: string[]
/** 每次修改对应的时间(与 titles[i+1] 对应) */
/** 每一步标题对应的修改时间(与 titles[i+1] 对应) */
change_times: string[]
first_at: string
last_at: string
@@ -23,7 +23,7 @@ const loading = ref(true)
const error = ref('')
const hoursRange = ref(48)
// 平台名到图标的映射(与首页保持一致,避免同一平台在不同页面图标不一
// 平台名到图标的映射(与首页保持一致,避免同一平台图标不一)
const platformIconMap: Record<string, string> = {
微博热搜: 'fa-brands fa-weibo',
微博: 'fa-brands fa-weibo',
@@ -33,7 +33,7 @@ const platformIconMap: Record<string, string> = {
今日头条: 'fa-solid fa-newspaper',
抖音热榜: 'fa-brands fa-tiktok',
抖音: 'fa-brands fa-tiktok',
B站热搜: 'fa-brands fa-bilibili',
'B站热搜': 'fa-brands fa-bilibili',
'bilibili 热搜': 'fa-brands fa-bilibili',
华尔街见闻: 'fa-solid fa-chart-line',
澎湃新闻: 'fa-solid fa-water',
@@ -46,7 +46,7 @@ function getPlatformIcon(name: string): string {
return platformIconMap[name] || 'fa-solid fa-globe'
}
/** 格式化时间 */
/** 安全解析时间:兼容后端返回未携带时区标记的字符串 */
function safeParseTime(dateStr: string): number {
if (!dateStr.endsWith('Z') && !dateStr.includes('+')) {
dateStr += 'Z'
@@ -54,6 +54,7 @@ function safeParseTime(dateStr: string): number {
return new Date(dateStr).getTime()
}
/** 格式化为相对时间 */
function formatTime(dateStr: string): string {
const d = new Date(safeParseTime(dateStr))
const now = Date.now()
@@ -67,8 +68,8 @@ function formatTime(dateStr: string): string {
}
/**
* 将原始修改记录按 event_id 分组,并在每组内拼接完整标题演变链。
* 规则:组内按 created_at 升序排列,然后依次 previous/revised 串成链
* 将修改记录按 event_id 分组,拼接完整标题演变链。
* 规则:组内按 created_at 升序依次 previous/revised 串成链,并去掉重复节点
*/
const revisionChains = computed<RevisionChain[]>(() => {
// 按 event_id 分组
@@ -81,17 +82,20 @@ const revisionChains = computed<RevisionChain[]>(() => {
const chains: RevisionChain[] = []
for (const [event_id, items] of groups) {
const first = items[0]
const last = items[items.length - 1]
if (!first || !last) continue
if (items.length === 0) continue
// 组内按时间升序
items.sort((a, b) => safeParseTime(a.created_at) - safeParseTime(b.created_at))
// 拼接标题链,避免重复(相邻记录的 revised 与下一条 previous 通常相同)
const first = items[0]
const last = items[items.length - 1]
// 拼接标题链,避免相邻记录重复
const titles: string[] = [first.previous_headline]
const change_times: string[] = []
for (const item of items) {
// 若链条末尾与本条 previous 不,说明有断层,仍然追加
// 如果链尾与当前 previous 不一致,说明链条中间有断层,补入 previous
if (titles[titles.length - 1] !== item.previous_headline) {
titles.push(item.previous_headline)
change_times.push(item.created_at)
@@ -112,12 +116,12 @@ const revisionChains = computed<RevisionChain[]>(() => {
})
}
// 最终按最新修改时间降序
// 按最新修改时间降序
chains.sort((a, b) => safeParseTime(b.last_at) - safeParseTime(a.last_at))
return chains
})
/** 加载数据 */
/** 加载修改记录 */
async function loadRevisions() {
loading.value = true
error.value = ''
@@ -130,7 +134,7 @@ async function loadRevisions() {
}
}
/** 切换时间范围 */
/** 切换时间范围并重载 */
function changeRange(hours: number) {
hoursRange.value = hours
loadRevisions()
@@ -148,14 +152,13 @@ onMounted(loadRevisions)
公关修改追踪
</h1>
<p class="page-desc">
实时监控各平台热搜标题被改的记录当爬虫检测到标题变更时会自动记录修改前后的差异
实时监控各平台热搜标题被悄悄修改的记录当爬虫检测到标题变化时系统会自动保留修改前后的差异轨迹
</p>
</div>
</div>
<!-- 时间范围选择 -->
<div class="filter-bar">
<span class="filter-label">查看范围</span>
<span class="filter-label">查看范围:</span>
<div class="filter-tabs">
<button
v-for="opt in [{ label: '24小时', value: 24 }, { label: '48小时', value: 48 }, { label: '7天', value: 168 }]"
@@ -170,26 +173,22 @@ onMounted(loadRevisions)
<span class="result-count"> {{ revisionChains.length }} 个事件 · {{ revisions.length }} 次修改</span>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<i class="fa-solid fa-spinner fa-spin"></i>
<span>加载中...</span>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<i class="fa-solid fa-circle-exclamation"></i>
<span>{{ error }}</span>
</div>
<!-- 空状态 -->
<div v-else-if="revisions.length === 0" class="empty-state">
<i class="fa-solid fa-shield-check"></i>
<p>该时段内未检测到标题修改</p>
<p class="empty-hint">这是个好消息说明各平台暂无异常公关操作</p>
<p class="empty-hint">这是个好消息说明当前没有明显的异常公关操作</p>
</div>
<!-- 修改记录列表按事件分组展示完整标题演变链 -->
<div v-else class="revision-list">
<div v-for="chain in revisionChains" :key="chain.event_id" class="revision-card">
<div class="revision-header">
@@ -218,10 +217,8 @@ onMounted(loadRevisions)
</div>
</div>
<!-- 标题演变链 -->
<div class="chain-area">
<template v-for="(title, idx) in chain.titles" :key="idx">
<!-- 标题节点 -->
<div
class="chain-title"
:class="{
@@ -238,7 +235,6 @@ onMounted(loadRevisions)
{{ formatTime(chain.change_times[idx]) }}
</span>
</div>
<!-- 箭头分隔最后一个标题后不需要 -->
<div v-if="idx < chain.titles.length - 1" class="chain-arrow">
<i class="fa-solid fa-arrow-down"></i>
</div>
@@ -275,9 +271,7 @@ onMounted(loadRevisions)
margin: 0;
}
/* ==========================================
过滤栏
========================================== */
/* 过滤栏 */
.filter-bar {
display: flex;
align-items: center;
@@ -328,9 +322,7 @@ onMounted(loadRevisions)
margin-left: auto;
}
/* ==========================================
状态
========================================== */
/* 状态区 */
.loading-state,
.error-state {
display: flex;
@@ -370,9 +362,7 @@ onMounted(loadRevisions)
color: var(--text-placeholder);
}
/* ==========================================
修改记录卡片
========================================== */
/* 修改记录卡片 */
.revision-list {
display: flex;
flex-direction: column;
@@ -451,9 +441,7 @@ onMounted(loadRevisions)
border: 1px solid rgba(239, 68, 68, 0.2);
}
/* ==========================================
标题演变链
========================================== */
/* 标题演变链 */
.chain-area {
display: flex;
flex-direction: column;
@@ -469,7 +457,7 @@ onMounted(loadRevisions)
border: 1px solid transparent;
}
/* 原始标题 —— 红色删除线风格 */
/* 原始标题红色删除线 */
.chain-title--original {
background: rgba(239, 68, 68, 0.05);
border-color: rgba(239, 68, 68, 0.12);
@@ -486,7 +474,7 @@ onMounted(loadRevisions)
text-decoration-color: var(--status-error);
}
/* 中间过渡版本 —— 橙/琥珀色风格 */
/* 中间版本:橙色提示 */
.chain-title--middle {
background: rgba(245, 158, 11, 0.05);
border-color: rgba(245, 158, 11, 0.15);
@@ -501,7 +489,7 @@ onMounted(loadRevisions)
color: var(--text-primary);
}
/* 当前最新版本 —— 绿色高亮风格 */
/* 当前版本:绿色高亮 */
.chain-title--current {
background: rgba(16, 185, 129, 0.05);
border-color: rgba(16, 185, 129, 0.12);
+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>