Files
InsightRadar/frontend/src/views/DashboardView.vue
T

1853 lines
49 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 { onMounted, ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import VueApexCharts from 'vue3-apexcharts'
import { fetchUnifiedEvents, fetchHeadlineRevisions, fetchSystemStats, fetchEventById } from '@/api/events'
import { fetchRecommendedEvents, fetchPreferences } from '@/api/preferences'
import { useAuthStore } from '@/stores/auth'
import type { UnifiedEvent, HeadlineRevision, SystemStats } from '@/types/event'
import type { MatchedEvent, UserTopicPreference } from '@/types/preference'
const route = useRoute()
const router = useRouter()
// ==========================================
// 聚光灯:从推荐页跳转过来时,按 ID 单独拉取目标事件
// ==========================================
const spotlightEvent = ref<UnifiedEvent | null>(null)
const loadingSpotlight = ref(false)
async function loadSpotlightEvent(eventId: number) {
if (!eventId) return
loadingSpotlight.value = true
spotlightEvent.value = null
try {
spotlightEvent.value = await fetchEventById(eventId)
} catch {
// 找不到时静默忽略
} finally {
loadingSpotlight.value = false
}
}
function dismissSpotlight() {
spotlightEvent.value = null
// 清除 URL 中的 event 参数,保持地址栏整洁
router.replace({ query: {} })
}
const authStore = useAuthStore()
const userId = computed(() => authStore.user?.id ?? 0)
// ==========================================
// 状态
// ==========================================
const events = ref<UnifiedEvent[]>([])
const revisions = ref<HeadlineRevision[]>([])
const stats = ref<SystemStats | null>(null)
const loading = ref(true)
const loadingMore = ref(false)
const error = ref('')
// 推荐匹配状态
const recommendedEvents = ref<MatchedEvent[]>([])
const userKeywords = ref<UserTopicPreference[]>([])
const loadingRecommend = ref(false)
// 分页状态
const PAGE_SIZE = 10
const totalEvents = ref(0)
const hasMore = ref(false)
// 用户可调节的热度阈值
const THRESHOLD_STORAGE_KEY = 'ir-hot-threshold'
const savedThreshold = localStorage.getItem(THRESHOLD_STORAGE_KEY)
const minHot = ref(savedThreshold !== null ? Number(savedThreshold) : 3)
// 增加时间筛选和排序
const hoursRange = ref(48)
const sortBy = ref('created_at')
const recSortBy = ref('match_score')
// 当前鼠标悬停的平台行标识
// 使用 "eventId-platformIndex" 作为唯一键,而非 sourceId
// 因为同一个平台在同一大事件下可能有多条不同的热搜条目
const hoveredPlatformKey = ref<string | null>(null)
// 预设的热度阈值选项
const thresholdOptions = [
{ label: '全部', value: 0 },
{ label: '≥ 3', value: 3 },
{ label: '≥ 5', value: 5 },
{ label: '≥ 10', value: 10 },
]
const hoursOptions = [
{ label: '24小时', value: 24 },
{ label: '48小时', value: 48 },
{ label: '7天', value: 168 },
]
const sortOptions = [
{ label: '时间排序', value: 'created_at' },
{ label: '热度排序', value: 'hot_score' },
]
const recSortOptions = [
{ label: '匹配度', value: 'match_score' },
{ label: '最新', value: 'created_at' },
]
// ==========================================
// 平台视觉映射
// ==========================================
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 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)' }
}
function formatRelativeTime(dateStr: string): string {
if (!dateStr.endsWith('Z') && !dateStr.includes('+')) {
dateStr += 'Z' // 补偿 SQLite 丢失的 UTC 时区标识
}
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 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 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}` }
}
/**
* 生成平台行的唯一键。
* 用数组索引代替 source_id,因为同一平台在同一大事件下可能挂载多条热搜。
*/
function platformKey(eventId: number, index: number, prefix: string = ''): string {
return prefix ? `${prefix}-${eventId}-${index}` : `${eventId}-${index}`
}
// ==========================================
// 数据加载
// ==========================================
async function loadEvents(append = false) {
if (!append) {
loading.value = true
events.value = []
} else {
loadingMore.value = true
}
error.value = ''
try {
const skip = append ? events.value.length : 0
const result = await fetchUnifiedEvents({
min_hot: minHot.value,
hours: hoursRange.value,
sort_by: sortBy.value,
skip,
limit: PAGE_SIZE,
})
if (append) {
events.value.push(...result.data)
} else {
events.value = result.data
}
totalEvents.value = result.total
hasMore.value = result.has_more
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
loadingMore.value = false
}
}
/** 加载推荐匹配数据 */
async function loadRecommendations() {
if (!authStore.isAuthenticated || !userId.value) return
loadingRecommend.value = true
try {
const [keywords, recommended] = await Promise.all([
fetchPreferences(userId.value),
fetchRecommendedEvents(userId.value, { min_hot: 1, hours: 72, limit: 8, sort_by: recSortBy.value }),
])
userKeywords.value = keywords
recommendedEvents.value = recommended.data
} catch {
// 推荐加载失败不阻塞主流程
} finally {
loadingRecommend.value = false
}
}
function onThresholdChange(value: number) {
minHot.value = value
localStorage.setItem(THRESHOLD_STORAGE_KEY, String(value))
loadEvents(false)
}
function onHoursChange(value: number) {
hoursRange.value = value
loadEvents(false)
}
function onSortChange(value: string) {
sortBy.value = value
loadEvents(false)
}
function onRecSortChange(value: string) {
recSortBy.value = value
loadRecommendations()
}
function loadMore() {
if (hasMore.value && !loadingMore.value) {
loadEvents(true)
}
}
const lastSyncText = computed(() => {
if (!stats.value?.last_sync_at) return '暂无'
return formatRelativeTime(stats.value.last_sync_at)
})
/** 用户是否配置了关键词 */
const hasKeywords = computed(() => userKeywords.value.length > 0)
onMounted(async () => {
// 如果携带了 event 参数,立即拉取聚光灯数据(不等主列表)
if (route.query.event) {
loadSpotlightEvent(Number(route.query.event))
}
const sidePromise = Promise.all([
fetchHeadlineRevisions({ hours: 48, limit: 5 }),
fetchSystemStats(),
])
await Promise.all([
loadEvents(false),
loadRecommendations(),
])
try {
const [revisionsData, statsData] = await sidePromise
revisions.value = revisionsData
stats.value = statsData
} catch {
// 侧栏数据加载失败不影响主内容
}
})
// 从其他页面跳转过来时,event 参数变化则重新加载聚光灯
watch(() => route.query.event, (newId) => {
if (newId) {
loadSpotlightEvent(Number(newId))
} else {
spotlightEvent.value = null
}
})
</script>
<template>
<div class="dashboard-page">
<div class="content-grid">
<!-- 左侧AI 统一大事件流 -->
<div class="events-column">
<!-- 区域头部 + 热度阈值控制 -->
<div class="section-header">
<div class="section-title-row">
<h2>
<i class="fa-solid fa-bolt" style="color: #facc15"></i>
突发事件深度聚合
</h2>
<span class="section-meta">基于语义聚类 · {{ totalEvents }} </span>
</div>
<div class="filters-bar">
<div class="filter-group">
<span class="filter-label">
<i class="fa-solid fa-fire"></i> 最低热度
</span>
<div class="filter-tabs">
<button
v-for="opt in thresholdOptions"
:key="opt.value"
class="filter-tab"
:class="{ active: minHot === opt.value }"
@click="onThresholdChange(opt.value)"
>
{{ opt.label }}
</button>
</div>
</div>
<div class="filter-group">
<span class="filter-label">
<i class="fa-regular fa-clock"></i> 时间范围
</span>
<div class="filter-tabs">
<button
v-for="opt in hoursOptions"
:key="opt.value"
class="filter-tab"
:class="{ active: hoursRange === opt.value }"
@click="onHoursChange(opt.value)"
>
{{ opt.label }}
</button>
</div>
</div>
<div class="filter-group">
<span class="filter-label">
<i class="fa-solid fa-arrow-down-wide-short"></i> 排序方式
</span>
<div class="filter-tabs">
<button
v-for="opt in sortOptions"
:key="opt.value"
class="filter-tab"
:class="{ active: sortBy === opt.value }"
@click="onSortChange(opt.value)"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
</div>
<!-- 聚光灯从推荐页跳转时展示目标事件独立于主列表不受分页影响 -->
<transition name="spotlight-fade">
<div v-if="loadingSpotlight || spotlightEvent" class="spotlight-wrap">
<div class="spotlight-label">
<i class="fa-solid fa-location-crosshairs"></i>
你正在查看的推荐事件
<button class="spotlight-close" @click="dismissSpotlight" title="关闭">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<!-- 加载骨架 -->
<div v-if="loadingSpotlight" class="spotlight-skeleton">
<div class="skel skel-title"></div>
<div class="skel skel-line"></div>
<div class="skel skel-line short"></div>
</div>
<!-- 事件内容复用 event-card 样式 -->
<article
v-else-if="spotlightEvent"
class="event-card spotlight-card"
:class="{ 'is-hot': spotlightEvent.hot_score >= 50 }"
>
<div class="card-top">
<div class="tags-row">
<span
class="hot-badge"
:style="{ color: getHotLevel(spotlightEvent.hot_score).color, background: getHotLevel(spotlightEvent.hot_score).bg }"
>
{{ getHotLevel(spotlightEvent.hot_score).label }} ({{ spotlightEvent.hot_score }})
</span>
<span v-for="tag in spotlightEvent.tags.slice(0, 3)" :key="tag" class="topic-tag">{{ tag }}</span>
</div>
<div class="event-time-range" title="左侧为首次发现时间,右侧为最后活跃时间">
<span>{{ formatRelativeTime(spotlightEvent.created_at) }}</span>
<template v-if="formatRelativeTime(spotlightEvent.created_at) !== formatRelativeTime(spotlightEvent.last_active_at)">
<i class="fa-solid fa-arrow-right time-arrow"></i>
<span class="active-time">{{ formatRelativeTime(spotlightEvent.last_active_at) }}</span>
</template>
</div>
</div>
<h3 class="card-title">{{ spotlightEvent.unified_title }}</h3>
<div v-if="spotlightEvent.summary" class="ai-summary">
<i class="fa-solid fa-wand-magic-sparkles summary-icon"></i>
<p>
<span class="summary-label">AI 全局洞察</span>
{{ spotlightEvent.summary }}
</p>
</div>
<div v-if="spotlightEvent.platforms.length > 0" class="platforms-list">
<div
v-for="(p, pIdx) in spotlightEvent.platforms"
:key="pIdx"
class="platform-block"
@mouseenter="hoveredPlatformKey = platformKey(spotlightEvent.event_id, pIdx, 'spotlight')"
@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(spotlightEvent.event_id, pIdx, 'spotlight') &&
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>
</div>
</transition>
<!-- 加载状态 -->
<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="events.length === 0" class="empty-state">
<i class="fa-solid fa-satellite-dish"></i>
<p>当前阈值下暂无聚合事件</p>
<p class="empty-hint">尝试降低热度阈值查看更多</p>
</div>
<!-- 事件卡片列表 -->
<template v-else>
<article
v-for="ev in events"
:key="ev.event_id"
class="event-card"
:class="{ 'is-hot': ev.hot_score >= 50 }"
>
<div class="card-top">
<div class="tags-row">
<span
class="hot-badge"
:style="{ color: getHotLevel(ev.hot_score).color, background: getHotLevel(ev.hot_score).bg }"
>
{{ getHotLevel(ev.hot_score).label }} ({{ ev.hot_score }})
</span>
<span v-for="tag in ev.tags.slice(0, 3)" :key="tag" class="topic-tag">{{ tag }}</span>
</div>
<div class="event-time-range" title="左侧为首次发现时间,右侧为最后活跃时间">
<span>{{ formatRelativeTime(ev.created_at) }}</span>
<template v-if="formatRelativeTime(ev.created_at) !== formatRelativeTime(ev.last_active_at)">
<i class="fa-solid fa-arrow-right time-arrow"></i>
<span class="active-time">{{ formatRelativeTime(ev.last_active_at) }}</span>
</template>
</div>
</div>
<h3 class="card-title">{{ ev.unified_title }}</h3>
<div v-if="ev.summary" class="ai-summary">
<i class="fa-solid fa-wand-magic-sparkles summary-icon"></i>
<p>
<span class="summary-label">AI 全局洞察:</span>
{{ ev.summary }}
</p>
</div>
<!-- 多平台挂载节点:用数组索引做唯一键 -->
<div v-if="ev.platforms.length > 0" class="platforms-list">
<div
v-for="(p, pIdx) in ev.platforms"
:key="pIdx"
class="platform-block"
@mouseenter="hoveredPlatformKey = platformKey(ev.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(ev.event_id, pIdx) &&
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>
<!-- 加载更多 -->
<div v-if="hasMore" class="load-more-wrapper">
<button class="load-more-btn" :disabled="loadingMore" @click="loadMore">
<i v-if="loadingMore" class="fa-solid fa-spinner fa-spin"></i>
<i v-else class="fa-solid fa-chevron-down"></i>
{{ loadingMore ? '加载中...' : '加载更多' }}
</button>
<span class="loaded-count">已加载 {{ events.length }} / {{ totalEvents }}</span>
</div>
<div v-else-if="events.length > 0" class="all-loaded">
<span>已展示全部 {{ events.length }} 条事件</span>
</div>
</template>
</div>
<!-- ==========================================
右侧:小组件面板
========================================== -->
<div class="widgets-column">
<!-- 为你推荐(基于用户关键词的匹配) -->
<section class="widget-panel recommend-widget" v-if="authStore.isAuthenticated">
<div class="widget-header recommend-header">
<h3>
<i class="fa-solid fa-wand-magic-sparkles"></i>
为你推荐
</h3>
<div class="recommend-actions">
<div class="mini-tabs">
<button
v-for="opt in recSortOptions"
:key="opt.value"
class="mini-tab"
:class="{ active: recSortBy === opt.value }"
@click="onRecSortChange(opt.value)"
>
{{ opt.label }}
</button>
</div>
<RouterLink to="/topics" class="widget-action-link">
<i class="fa-solid fa-gear"></i>
</RouterLink>
</div>
</div>
<div class="widget-body">
<!-- 加载中 -->
<div v-if="loadingRecommend" class="widget-empty">
<i class="fa-solid fa-spinner fa-spin"></i> 匹配中...
</div>
<!-- 未配置关键词 -->
<div v-else-if="!hasKeywords" class="recommend-empty">
<i class="fa-solid fa-bookmark"></i>
<p>还没有设置兴趣关键词</p>
<RouterLink to="/topics" class="recommend-go-btn">
<i class="fa-solid fa-plus"></i> 去添加关键词
</RouterLink>
</div>
<!-- 有关键词但没匹配到 -->
<div v-else-if="recommendedEvents.length === 0" class="recommend-empty">
<i class="fa-solid fa-search"></i>
<p>暂未匹配到相关事件</p>
<p class="empty-sub">系统将在下次 AI 摘要生成后重新匹配</p>
</div>
<!-- 推荐事件列表 -->
<template v-else>
<div class="recommend-keywords">
<span class="recommend-kw-label">当前订阅:</span>
<span v-for="kw in userKeywords.slice(0, 5)" :key="kw.id" class="recommend-kw-tag">
{{ kw.interested_keyword }}
</span>
<span v-if="userKeywords.length > 5" class="recommend-kw-more">
+{{ userKeywords.length - 5 }}
</span>
</div>
<RouterLink
v-for="rec in recommendedEvents"
:key="rec.event_id"
:to="{ path: '/', query: { event: rec.event_id } }"
class="recommend-item"
>
<div class="recommend-item-top">
<span
class="hot-badge mini"
:style="{ color: getHotLevel(rec.hot_score).color, background: getHotLevel(rec.hot_score).bg }"
>
{{ rec.hot_score }}
</span>
<span class="recommend-score">
匹配度 {{ rec.match_score.toFixed(0) }}
</span>
</div>
<p class="recommend-title">{{ rec.unified_title }}</p>
<div class="recommend-hits">
<span v-for="hit in rec.exact_hits.slice(0, 3)" :key="hit" class="hit-tag exact">
<i class="fa-solid fa-bullseye"></i> {{ hit }}
</span>
<span
v-for="sh in rec.semantic_hits.slice(0, 2)"
:key="sh.topic_keyword"
class="hit-tag semantic"
>
<i class="fa-solid fa-brain"></i> {{ sh.topic_keyword }}
<span class="sim-score">{{ (sh.similarity * 100).toFixed(0) }}%</span>
</span>
</div>
<span class="recommend-goto"><i class="fa-solid fa-arrow-right"></i></span>
</RouterLink>
</template>
</div>
</section>
<!-- 公关修改追踪 -->
<section class="widget-panel revision-widget">
<div class="widget-header revision-header">
<h3>
<i class="fa-solid fa-eye pulse-icon"></i>
公关修改追踪台
</h3>
<span class="realtime-badge">实时抓取</span>
</div>
<div class="widget-body">
<div v-if="revisions.length === 0" class="widget-empty">
<p>暂无检测到标题修改</p>
</div>
<div v-for="rev in revisions" :key="rev.id" class="revision-item">
<div class="revision-meta">
<span class="platform-info">
<i :class="getPlatformIcon(rev.source_name || '')" class="rev-platform-icon"></i>
<a
v-if="rev.url"
:href="rev.url"
target="_blank"
rel="noopener"
class="platform-link"
>
{{ rev.source_name || '未知平台' }}
</a>
<span v-else>{{ rev.source_name || '未知平台' }}</span>
<span class="meta-dot">·</span>
<span class="meta-time">{{ formatRelativeTime(rev.created_at) }}</span>
</span>
</div>
<div class="revision-diff">
<p class="old-headline">{{ rev.previous_headline }}</p>
<div class="arrow-down">
<i class="fa-solid fa-arrow-down"></i> 修改为
</div>
<p class="new-headline">{{ rev.revised_headline }}</p>
</div>
</div>
</div>
</section>
<!-- 系统状态 -->
<section v-if="stats" class="widget-panel stats-widget">
<div class="stats-grid">
<div class="stat-item">
<p class="stat-label">爬虫状态</p>
<p class="stat-value">
<span class="status-dot-green"></span>
{{ stats.active_sources }} 个源运行中
</p>
</div>
<div class="stat-item stat-right">
<p class="stat-label">今日已入库</p>
<p class="stat-value stat-number">
{{ stats.items_today.toLocaleString() }}
<span class="stat-unit">条</span>
</p>
</div>
</div>
<div class="stats-footer">
<span>
<i class="fa-regular fa-clock"></i>
最后同步: {{ lastSyncText }}
</span>
<!-- <span v-if="stats.error_tasks_today > 0" class="error-count">
<i class="fa-solid fa-triangle-exclamation"></i>
{{ stats.error_tasks_today }} 个异常
</span> -->
</div>
</section>
</div>
</div>
</div>
</template>
<style scoped>
.dashboard-page {
max-width: 1400px;
margin: 0 auto;
}
.loading-state,
.error-state {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 80px 20px;
color: var(--text-secondary);
font-size: 15px;
}
.error-state {
color: var(--status-error);
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state i {
font-size: 40px;
margin-bottom: 16px;
display: block;
opacity: 0.4;
}
.empty-hint {
font-size: 13px;
color: var(--text-placeholder);
margin-top: 6px;
}
/* ==========================================
网格布局
========================================== */
.content-grid {
display: flex;
flex-direction: column;
gap: 24px;
}
@media (min-width: 1024px) {
.content-grid {
flex-direction: row;
}
}
.events-column {
flex: 1;
min-width: 0;
}
.widgets-column {
width: 100%;
display: flex;
flex-direction: column;
gap: 20px;
}
@media (min-width: 1024px) {
.widgets-column {
width: 380px;
min-width: 380px;
}
}
/* ==========================================
区域标题 + 热度阈值 (高级磨砂透明风)
========================================== */
.section-header {
margin-bottom: 24px;
}
.section-title-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 16px;
}
.section-header h2 {
font-size: 22px;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 10px;
letter-spacing: 0.02em;
}
.section-meta {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.filters-bar {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 12px 16px;
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);
}
.filter-group {
display: flex;
align-items: center;
gap: 10px;
}
.filter-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
white-space: nowrap;
display: flex;
align-items: center;
gap: 6px;
}
.filter-tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
background: var(--bg-input);
padding: 4px;
border-radius: var(--radius-lg);
border: 1px solid var(--border-subtle);
}
.filter-tab {
padding: 6px 14px;
font-size: 13px;
font-weight: 600;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--text-secondary);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.filter-tab:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.filter-tab.active {
background: var(--bg-surface);
color: var(--brand-primary);
box-shadow: var(--shadow-sm);
}
/* ==========================================
事件卡片
========================================== */
/* 事件卡片,加入毛玻璃与高级阴影 */
.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);
}
.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;
}
.hot-badge.mini {
padding: 1px 6px;
font-size: 10px;
}
.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;
}
/* AI 摘要,调整为更细腻的展示效果 */
.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;
}
.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;
}
/* ==========================================
加载更多
========================================== */
.load-more-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 20px 0;
}
.load-more-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 28px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
transition: all 0.2s;
}
.load-more-btn:hover:not(:disabled) {
border-color: var(--brand-primary);
color: var(--brand-primary);
background: var(--brand-primary-alpha);
}
.load-more-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loaded-count {
font-size: 11px;
color: var(--text-placeholder);
}
.all-loaded {
text-align: center;
padding: 16px 0;
font-size: 12px;
color: var(--text-placeholder);
}
/* ==========================================
小组件面板(通用)- 玻璃拟态高级质感
========================================== */
.widget-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);
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.widget-panel:hover {
box-shadow: var(--shadow-md);
border-color: rgba(99, 102, 241, 0.2);
}
.widget-header {
padding: 14px 16px;
border-bottom: 1px solid var(--border-subtle);
display: flex;
justify-content: space-between;
align-items: center;
}
.widget-header h3 {
font-size: 14px;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.recommend-actions {
display: flex;
align-items: center;
gap: 10px;
}
.mini-tabs {
display: flex;
background: var(--bg-input);
padding: 2px;
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
}
.mini-tab {
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.mini-tab:hover {
color: var(--text-primary);
}
.mini-tab.active {
background: var(--bg-surface);
color: var(--brand-primary);
box-shadow: var(--shadow-sm);
}
.widget-action-link {
color: var(--text-secondary);
font-size: 14px;
padding: 4px;
border-radius: 4px;
transition: all 0.2s;
}
.widget-action-link:hover {
color: var(--text-primary);
background: var(--bg-input);
}
.widget-body {
padding: 14px 16px;
}
.widget-empty {
text-align: center;
padding: 20px 0;
color: var(--text-secondary);
font-size: 13px;
}
/* ==========================================
为你推荐面板
========================================== */
.recommend-header {
background: rgba(139, 92, 246, 0.06);
border-bottom-color: rgba(139, 92, 246, 0.15);
}
.recommend-header h3 {
background: linear-gradient(to right, #a78bfa, #818cf8);
-webkit-background-clip: text;
color: transparent;
}
.recommend-empty {
text-align: center;
padding: 20px 0;
color: var(--text-secondary);
}
.recommend-empty i {
font-size: 28px;
display: block;
margin-bottom: 10px;
opacity: 0.3;
}
.recommend-empty p {
font-size: 13px;
margin: 0;
}
.empty-sub {
font-size: 11px !important;
color: var(--text-placeholder);
margin-top: 4px !important;
}
.recommend-go-btn {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding: 6px 16px;
font-size: 12px;
font-weight: 600;
border-radius: var(--radius-md);
background: var(--brand-primary);
color: #fff;
text-decoration: none;
transition: all 0.2s;
}
.recommend-go-btn:hover {
background: var(--brand-primary-hover);
transform: translateY(-1px);
}
/* 订阅关键词展示 */
.recommend-keywords {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-subtle);
}
.recommend-kw-label {
font-size: 11px;
color: var(--text-secondary);
margin-right: 2px;
}
.recommend-kw-tag {
font-size: 11px;
padding: 1px 8px;
border-radius: 10px;
background: var(--brand-primary-alpha);
color: var(--brand-primary);
font-weight: 500;
}
.recommend-kw-more {
font-size: 11px;
color: var(--text-placeholder);
}
/* 推荐事件条目 */
.recommend-item {
display: block;
position: relative;
padding: 10px 28px 10px 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
margin-bottom: 8px;
transition: border-color 0.2s, background 0.2s, transform 0.15s;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.recommend-item:last-child {
margin-bottom: 0;
}
.recommend-item:hover {
border-color: var(--brand-primary-alpha);
background: var(--bg-hover);
transform: translateX(2px);
}
.recommend-goto {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 10px;
color: var(--text-placeholder);
transition: color 0.2s, transform 0.2s;
}
.recommend-item:hover .recommend-goto {
color: var(--brand-primary);
transform: translateY(-50%) translateX(2px);
}
.recommend-item-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.recommend-score {
font-size: 10px;
color: var(--text-placeholder);
font-weight: 500;
}
.recommend-title {
font-size: 13px;
font-weight: 600;
line-height: 1.4;
margin: 0 0 6px;
}
.recommend-hits {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.hit-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 3px;
}
.hit-tag.exact {
background: rgba(16, 185, 129, 0.1);
color: var(--status-success);
}
.hit-tag.semantic {
background: rgba(139, 92, 246, 0.1);
color: #a78bfa;
}
.sim-score {
opacity: 0.7;
font-size: 9px;
}
/* ==========================================
公关修改追踪
========================================== */
.revision-header {
background: rgba(239, 68, 68, 0.06);
border-bottom-color: rgba(239, 68, 68, 0.15);
}
.revision-header h3 {
color: var(--status-error);
}
.pulse-icon {
animation: pulse-dot 2s infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.realtime-badge {
font-size: 10px;
padding: 2px 8px;
border-radius: 10px;
background: rgba(239, 68, 68, 0.12);
color: var(--status-error);
}
.revision-item {
padding: 12px;
background: var(--bg-input);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
margin-bottom: 10px;
}
.revision-item:last-child {
margin-bottom: 0;
}
.revision-meta {
margin-bottom: 8px;
}
.platform-info {
display: flex;
align-items: center;
font-size: 13px;
color: var(--text-secondary);
}
.rev-platform-icon {
margin-right: 4px;
}
.platform-link {
text-decoration: none;
color: inherit;
transition: color 0.15s;
}
.platform-link:hover {
color: var(--brand-primary);
}
.meta-dot {
margin: 0 4px;
}
.revision-diff {
display: flex;
flex-direction: column;
gap: 4px;
}
.old-headline {
font-size: 13px;
color: var(--text-secondary);
text-decoration: line-through;
text-decoration-color: var(--status-error);
margin: 0;
}
.arrow-down {
display: flex;
justify-content: center;
font-size: 10px;
color: var(--text-placeholder);
gap: 4px;
padding: 2px 0;
}
.new-headline {
font-size: 13px;
font-weight: 500;
color: var(--status-success);
background: rgba(16, 185, 129, 0.08);
padding: 4px 8px;
border-radius: 4px;
margin: 0;
}
/* ==========================================
系统状态
========================================== */
.stats-widget {
padding: 16px;
}
.stats-grid {
display: flex;
justify-content: space-between;
align-items: center;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-right {
text-align: right;
}
.stat-label {
font-size: 11px;
color: var(--text-secondary);
margin: 0;
}
.stat-value {
font-size: 14px;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 6px;
}
.stat-number {
font-size: 22px;
color: var(--brand-primary);
}
.stat-unit {
font-size: 12px;
font-weight: 400;
color: var(--text-secondary);
}
.status-dot-green {
width: 8px;
height: 8px;
background: var(--status-success);
border-radius: 50%;
display: inline-block;
}
.stats-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-subtle);
font-size: 11px;
color: var(--text-secondary);
}
.error-count {
color: var(--status-error);
}
/* ==========================================
聚光灯区块
========================================== */
.spotlight-wrap {
margin-bottom: 20px;
}
.spotlight-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: var(--brand-primary);
margin-bottom: 8px;
padding: 0 2px;
}
.spotlight-close {
margin-left: auto;
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--text-placeholder);
background: var(--bg-input);
border: 1px solid var(--border-subtle);
transition: all 0.2s;
}
.spotlight-close:hover {
color: var(--status-error);
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.2);
}
.spotlight-card {
border-color: var(--brand-primary-alpha);
box-shadow: 0 0 0 3px var(--brand-primary-alpha), var(--shadow-md);
}
/* 骨架屏 */
.spotlight-skeleton {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-xl);
padding: 20px 24px;
}
.skel {
background: var(--bg-input);
border-radius: 6px;
animation: skel-pulse 1.4s ease-in-out infinite;
}
.skel-title {
height: 20px;
width: 75%;
margin-bottom: 14px;
}
.skel-line {
height: 13px;
width: 100%;
margin-bottom: 10px;
}
.skel-line.short {
width: 55%;
}
@keyframes skel-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* 聚光灯进出场动画 */
.spotlight-fade-enter-active,
.spotlight-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.spotlight-fade-enter-from,
.spotlight-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>