Files
InsightRadar/frontend/src/components/UnifiedEventCard.vue
T
2026-04-02 13:48:33 +08:00

489 lines
13 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 } 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 },
events: {
mounted: (chartContext: any) => {
chartContext.el?.querySelector('.apexcharts-svg > title')?.remove()
},
updated: (chartContext: any) => {
chartContext.el?.querySelector('.apexcharts-svg > title')?.remove()
}
}
},
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>