mirror of
https://github.com/stardrophere/InsightRadar.git
synced 2026-06-06 00:52:50 +08:00
1853 lines
49 KiB
Vue
1853 lines
49 KiB
Vue
<!-- 主仪表盘:事件流、为你推荐、公关修改追踪、系统状态 -->
|
||
<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>
|