This commit is contained in:
stardrophere
2026-03-12 13:00:10 +08:00
parent e28b893a12
commit 3d7d53f96f
10 changed files with 433 additions and 65 deletions
+25 -9
View File
@@ -30,7 +30,8 @@ MAX_RANKING_POINTS = 30
@router.get("/unified", response_model=PaginatedUnifiedEventResponse) @router.get("/unified", response_model=PaginatedUnifiedEventResponse)
def list_unified_events( def list_unified_events(
min_hot: int = Query(5, ge=0, description="热度阈值,仅返回 hot_score >= 此值的事件"), min_hot: int = Query(5, ge=0, description="热度阈值,仅返回 hot_score >= 此值的事件"),
hours: int = Query(24, ge=1, le=720, description="查询最近多少小时的数据"), hours: int = Query(48, ge=1, le=720, description="查询最近多少小时的数据"),
sort_by: str = Query("hot_score", description="排序字段: hot_score | created_at"),
skip: int = Query(0, ge=0, description="分页偏移量"), skip: int = Query(0, ge=0, description="分页偏移量"),
limit: int = Query(10, ge=1, le=50, description="每页返回条数"), limit: int = Query(10, ge=1, le=50, description="每页返回条数"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -46,13 +47,12 @@ def list_unified_events(
total = base_query.count() total = base_query.count()
# 分页查询 # 分页查询
events = ( if sort_by == "created_at":
base_query base_query = base_query.order_by(UnifiedEvent.created_at.desc())
.order_by(UnifiedEvent.hot_score.desc()) else:
.offset(skip) base_query = base_query.order_by(UnifiedEvent.hot_score.desc(), UnifiedEvent.created_at.desc())
.limit(limit)
.all() events = base_query.offset(skip).limit(limit).all()
)
if not events: if not events:
return PaginatedUnifiedEventResponse(total=total, has_more=False, data=[]) return PaginatedUnifiedEventResponse(total=total, has_more=False, data=[])
@@ -110,7 +110,8 @@ def list_unified_events(
results: list[UnifiedEventResponse] = [] results: list[UnifiedEventResponse] = []
for ev in events: for ev in events:
platform_list: list[PlatformTrendResponse] = [] platform_list: list[PlatformTrendResponse] = []
for trend, source_name in trend_map.get(ev.id, []): trends_for_ev = trend_map.get(ev.id, [])
for trend, source_name in trends_for_ev:
history = ranking_map.get(trend.id, []) history = ranking_map.get(trend.id, [])
# 截取尾部,只保留最近的点 # 截取尾部,只保留最近的点
if len(history) > MAX_RANKING_POINTS: if len(history) > MAX_RANKING_POINTS:
@@ -127,6 +128,13 @@ def list_unified_events(
) )
) )
# 取所有关联热搜条目中最新的 updated_at,代表"最后一次在平台热搜榜看到"的时间
last_active_at = (
max(t.updated_at for t, _ in trends_for_ev)
if trends_for_ev
else ev.updated_at
)
results.append( results.append(
UnifiedEventResponse( UnifiedEventResponse(
event_id=ev.id, event_id=ev.id,
@@ -134,6 +142,7 @@ def list_unified_events(
summary=ev.ai_comprehensive_summary, summary=ev.ai_comprehensive_summary,
hot_score=ev.hot_score, hot_score=ev.hot_score,
created_at=ev.created_at, created_at=ev.created_at,
last_active_at=last_active_at,
platforms=platform_list, platforms=platform_list,
tags=tag_map.get(ev.id, []), tags=tag_map.get(ev.id, []),
) )
@@ -204,12 +213,19 @@ def get_unified_event(
) )
) )
last_active_at = (
max(t.updated_at for t, _ in trend_rows)
if trend_rows
else ev.updated_at
)
return UnifiedEventResponse( return UnifiedEventResponse(
event_id=ev.id, event_id=ev.id,
unified_title=ev.unified_title if ev.unified_title else "暂无标题", unified_title=ev.unified_title if ev.unified_title else "暂无标题",
summary=ev.ai_comprehensive_summary, summary=ev.ai_comprehensive_summary,
hot_score=ev.hot_score, hot_score=ev.hot_score,
created_at=ev.created_at, created_at=ev.created_at,
last_active_at=last_active_at,
platforms=platform_list, platforms=platform_list,
tags=tags, tags=tags,
) )
+4
View File
@@ -120,6 +120,7 @@ def recommend_events(
hours: int = Query(72, ge=1, le=24 * 30, description="仅匹配最近多少小时的事件"), hours: int = Query(72, ge=1, le=24 * 30, description="仅匹配最近多少小时的事件"),
limit: int = Query(20, ge=1, le=50, description="最多返回多少条推荐"), limit: int = Query(20, ge=1, le=50, description="最多返回多少条推荐"),
semantic_threshold: float = Query(0.78, ge=0.0, le=1.0, description="语义匹配相似度阈值"), semantic_threshold: float = Query(0.78, ge=0.0, le=1.0, description="语义匹配相似度阈值"),
sort_by: str = Query("match_score", description="排序方式: match_score | created_at"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: AppUser = Depends(get_current_user), current_user: AppUser = Depends(get_current_user),
): ):
@@ -135,6 +136,9 @@ def recommend_events(
semantic_threshold=semantic_threshold, semantic_threshold=semantic_threshold,
) )
if sort_by == "created_at":
matched.sort(key=lambda x: x.event.created_at, reverse=True)
result_data: list[MatchedEventResponse] = [] result_data: list[MatchedEventResponse] = []
for item in matched: for item in matched:
result_data.append( result_data.append(
+4 -2
View File
@@ -21,6 +21,7 @@ class HeadlineRevisionResponse(BaseModel):
revised_headline: str revised_headline: str
source_name: Optional[str] = None source_name: Optional[str] = None
platform_icon: Optional[str] = None platform_icon: Optional[str] = None
url: Optional[str] = None
created_at: datetime created_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -39,7 +40,7 @@ def list_headline_revisions(
time_limit = utcnow() - timedelta(hours=hours) time_limit = utcnow() - timedelta(hours=hours)
rows = ( rows = (
db.query(HeadlineRevision, InfoSource.source_name) db.query(HeadlineRevision, InfoSource.source_name, TrendingEvent.event_url)
.join(TrendingEvent, HeadlineRevision.event_id == TrendingEvent.id) .join(TrendingEvent, HeadlineRevision.event_id == TrendingEvent.id)
.join(InfoSource, TrendingEvent.source_id == InfoSource.id) .join(InfoSource, TrendingEvent.source_id == InfoSource.id)
.filter(HeadlineRevision.created_at >= time_limit) .filter(HeadlineRevision.created_at >= time_limit)
@@ -59,7 +60,7 @@ def list_headline_revisions(
} }
results: list[HeadlineRevisionResponse] = [] results: list[HeadlineRevisionResponse] = []
for revision, source_name in rows: for revision, source_name, event_url in rows:
results.append( results.append(
HeadlineRevisionResponse( HeadlineRevisionResponse(
id=revision.id, id=revision.id,
@@ -68,6 +69,7 @@ def list_headline_revisions(
revised_headline=revision.revised_headline, revised_headline=revision.revised_headline,
source_name=source_name, source_name=source_name,
platform_icon=icon_map.get(source_name, "newspaper"), platform_icon=icon_map.get(source_name, "newspaper"),
url=event_url,
created_at=revision.created_at, created_at=revision.created_at,
) )
) )
+1
View File
@@ -19,6 +19,7 @@ class UnifiedEventResponse(BaseModel):
summary: Optional[str] summary: Optional[str]
hot_score: int hot_score: int
created_at: datetime created_at: datetime
last_active_at: datetime
platforms: List[PlatformTrendResponse] platforms: List[PlatformTrendResponse]
tags: List[str] = Field(default_factory=list) tags: List[str] = Field(default_factory=list)
+2 -1
View File
@@ -10,10 +10,11 @@ export function fetchEventById(eventId: number): Promise<UnifiedEvent> {
export function fetchUnifiedEvents(params?: { export function fetchUnifiedEvents(params?: {
min_hot?: number min_hot?: number
hours?: number hours?: number
sort_by?: string
skip?: number skip?: number
limit?: number limit?: number
}): Promise<PaginatedEvents> { }): Promise<PaginatedEvents> {
return apiGet<PaginatedEvents>('/events/unified', params as Record<string, number>) return apiGet<PaginatedEvents>('/events/unified', params as Record<string, string | number>)
} }
/** 获取标题修改追踪列表 */ /** 获取标题修改追踪列表 */
+2 -2
View File
@@ -24,10 +24,10 @@ export function deletePreference(userId: number, preferenceId: number): Promise<
/** 基于兴趣词获取推荐事件 */ /** 基于兴趣词获取推荐事件 */
export function fetchRecommendedEvents( export function fetchRecommendedEvents(
userId: number, userId: number,
params?: { min_hot?: number; hours?: number; limit?: number }, params?: { min_hot?: number; hours?: number; limit?: number; sort_by?: string },
): Promise<RecommendationResponse> { ): Promise<RecommendationResponse> {
return apiGet<RecommendationResponse>( return apiGet<RecommendationResponse>(
`/users/${userId}/recommended-events`, `/users/${userId}/recommended-events`,
params as Record<string, number>, params as Record<string, string | number>,
) )
} }
+2
View File
@@ -15,6 +15,7 @@ export interface UnifiedEvent {
summary: string | null summary: string | null
hot_score: number hot_score: number
created_at: string created_at: string
last_active_at: string
platforms: PlatformTrend[] platforms: PlatformTrend[]
tags: string[] tags: string[]
} }
@@ -34,6 +35,7 @@ export interface HeadlineRevision {
revised_headline: string revised_headline: string
source_name: string | null source_name: string | null
platform_icon: string | null platform_icon: string | null
url: string | null
created_at: string created_at: string
} }
+225 -42
View File
@@ -65,6 +65,11 @@ const THRESHOLD_STORAGE_KEY = 'ir-hot-threshold'
const savedThreshold = localStorage.getItem(THRESHOLD_STORAGE_KEY) const savedThreshold = localStorage.getItem(THRESHOLD_STORAGE_KEY)
const minHot = ref(savedThreshold !== null ? Number(savedThreshold) : 3) const minHot = ref(savedThreshold !== null ? Number(savedThreshold) : 3)
// 增加时间筛选和排序
const hoursRange = ref(48)
const sortBy = ref('hot_score')
const recSortBy = ref('match_score')
// 当前鼠标悬停的平台行标识 // 当前鼠标悬停的平台行标识
// 使用 "eventId-platformIndex" 作为唯一键,而非 sourceId // 使用 "eventId-platformIndex" 作为唯一键,而非 sourceId
// 因为同一个平台在同一大事件下可能有多条不同的热搜条目 // 因为同一个平台在同一大事件下可能有多条不同的热搜条目
@@ -76,8 +81,22 @@ const thresholdOptions = [
{ label: '≥ 3', value: 3 }, { label: '≥ 3', value: 3 },
{ label: '≥ 5', value: 5 }, { label: '≥ 5', value: 5 },
{ label: '≥ 10', value: 10 }, { label: '≥ 10', value: 10 },
{ label: '≥ 20', value: 20 }, ]
{ label: '≥ 50', value: 50 },
const hoursOptions = [
{ label: '24小时', value: 24 },
{ label: '48小时', value: 48 },
{ label: '7天', value: 168 },
]
const sortOptions = [
{ label: '热度排序', value: 'hot_score' },
{ label: '时间排序', value: 'created_at' },
]
const recSortOptions = [
{ label: '匹配度', value: 'match_score' },
{ label: '最新', value: 'created_at' },
] ]
// ========================================== // ==========================================
@@ -189,7 +208,7 @@ function getRankingChartOptions(history: number[], platformColor: string) {
/** /**
* 根据排名历史计算趋势方向。 * 根据排名历史计算趋势方向。
* 排名数值越小越好,所以数值下降 = 排名上升(绿色),数值上升 = 排名下滑(色)。 * 排名上升(色),数值上升 = 排名下滑(绿色)。
*/ */
function getRankingTrend(p: { current_ranking: number | null; ranking_history: number[] }) { function getRankingTrend(p: { current_ranking: number | null; ranking_history: number[] }) {
const rank = p.current_ranking const rank = p.current_ranking
@@ -200,13 +219,14 @@ function getRankingTrend(p: { current_ranking: number | null; ranking_history: n
const prev = h[h.length - 2] const prev = h[h.length - 2]
const curr = h[h.length - 1] 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 const diff = prev - curr
if (diff > 0) { if (diff > 0) {
return { icon: 'fa-solid fa-arrow-trend-up', color: '#10b981', text: `TOP ${rank}${diff}` } return { icon: 'fa-solid fa-arrow-trend-up', color: '#ef4444', text: `TOP ${rank}${diff}` }
} }
if (diff < 0) { if (diff < 0) {
return { icon: 'fa-solid fa-arrow-trend-down', color: '#ef4444', text: `TOP ${rank}${Math.abs(diff)}` } 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}` } return { icon: 'fa-solid fa-equals', color: '#f97316', text: `TOP ${rank}` }
} }
@@ -235,7 +255,8 @@ async function loadEvents(append = false) {
const skip = append ? events.value.length : 0 const skip = append ? events.value.length : 0
const result = await fetchUnifiedEvents({ const result = await fetchUnifiedEvents({
min_hot: minHot.value, min_hot: minHot.value,
hours: 48, hours: hoursRange.value,
sort_by: sortBy.value,
skip, skip,
limit: PAGE_SIZE, limit: PAGE_SIZE,
}) })
@@ -261,7 +282,7 @@ async function loadRecommendations() {
try { try {
const [keywords, recommended] = await Promise.all([ const [keywords, recommended] = await Promise.all([
fetchPreferences(userId.value), fetchPreferences(userId.value),
fetchRecommendedEvents(userId.value, { min_hot: 1, hours: 72, limit: 8 }), fetchRecommendedEvents(userId.value, { min_hot: 1, hours: 72, limit: 8, sort_by: recSortBy.value }),
]) ])
userKeywords.value = keywords userKeywords.value = keywords
recommendedEvents.value = recommended.data recommendedEvents.value = recommended.data
@@ -278,6 +299,21 @@ function onThresholdChange(value: number) {
loadEvents(false) 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() { function loadMore() {
if (hasMore.value && !loadingMore.value) { if (hasMore.value && !loadingMore.value) {
loadEvents(true) loadEvents(true)
@@ -341,20 +377,56 @@ watch(() => route.query.event, (newId) => {
</h2> </h2>
<span class="section-meta">基于语义聚类 · {{ totalEvents }} </span> <span class="section-meta">基于语义聚类 · {{ totalEvents }} </span>
</div> </div>
<div class="threshold-bar"> <div class="filters-bar">
<span class="threshold-label"> <div class="filter-group">
<i class="fa-solid fa-sliders"></i> 热度阈值 <span class="filter-label">
</span> <i class="fa-solid fa-fire"></i> 最低热度
<div class="threshold-tabs"> </span>
<button <div class="filter-tabs">
v-for="opt in thresholdOptions" <button
:key="opt.value" v-for="opt in thresholdOptions"
class="threshold-tab" :key="opt.value"
:class="{ active: minHot === opt.value }" class="filter-tab"
@click="onThresholdChange(opt.value)" :class="{ active: minHot === opt.value }"
> @click="onThresholdChange(opt.value)"
{{ opt.label }} >
</button> {{ 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> </div>
</div> </div>
@@ -393,7 +465,13 @@ watch(() => route.query.event, (newId) => {
</span> </span>
<span v-for="tag in spotlightEvent.tags.slice(0, 3)" :key="tag" class="topic-tag">{{ tag }}</span> <span v-for="tag in spotlightEvent.tags.slice(0, 3)" :key="tag" class="topic-tag">{{ tag }}</span>
</div> </div>
<span class="card-time">{{ formatRelativeTime(spotlightEvent.created_at) }}</span> <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> </div>
<h3 class="card-title">{{ spotlightEvent.unified_title }}</h3> <h3 class="card-title">{{ spotlightEvent.unified_title }}</h3>
<div v-if="spotlightEvent.summary" class="ai-summary"> <div v-if="spotlightEvent.summary" class="ai-summary">
@@ -499,7 +577,13 @@ watch(() => route.query.event, (newId) => {
</span> </span>
<span v-for="tag in ev.tags.slice(0, 3)" :key="tag" class="topic-tag">{{ tag }}</span> <span v-for="tag in ev.tags.slice(0, 3)" :key="tag" class="topic-tag">{{ tag }}</span>
</div> </div>
<span class="card-time">{{ formatRelativeTime(ev.created_at) }}</span> <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> </div>
<h3 class="card-title">{{ ev.unified_title }}</h3> <h3 class="card-title">{{ ev.unified_title }}</h3>
@@ -599,9 +683,22 @@ watch(() => route.query.event, (newId) => {
<i class="fa-solid fa-wand-magic-sparkles"></i> <i class="fa-solid fa-wand-magic-sparkles"></i>
为你推荐 为你推荐
</h3> </h3>
<RouterLink to="/topics" class="widget-action-link"> <div class="recommend-actions">
<i class="fa-solid fa-gear"></i> <div class="mini-tabs">
</RouterLink> <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>
<div class="widget-body"> <div class="widget-body">
<!-- 加载中 --> <!-- 加载中 -->
@@ -689,9 +786,20 @@ watch(() => route.query.event, (newId) => {
</div> </div>
<div v-for="rev in revisions" :key="rev.id" class="revision-item"> <div v-for="rev in revisions" :key="rev.id" class="revision-item">
<div class="revision-meta"> <div class="revision-meta">
<span> <span class="platform-info">
<i :class="getPlatformIcon(rev.source_name || '')" class="rev-platform-icon"></i> <i :class="getPlatformIcon(rev.source_name || '')" class="rev-platform-icon"></i>
{{ rev.source_name || '未知平台' }} · {{ formatRelativeTime(rev.created_at) }} <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> </span>
</div> </div>
<div class="revision-diff"> <div class="revision-diff">
@@ -843,11 +951,11 @@ watch(() => route.query.event, (newId) => {
font-weight: 500; font-weight: 500;
} }
.threshold-bar { .filters-bar {
display: flex; display: flex;
align-items: center; flex-wrap: wrap;
gap: 12px; gap: 16px;
padding: 8px 12px; padding: 12px 16px;
background: var(--bg-surface); background: var(--bg-surface);
backdrop-filter: var(--backdrop-blur); backdrop-filter: var(--backdrop-blur);
-webkit-backdrop-filter: var(--backdrop-blur); -webkit-backdrop-filter: var(--backdrop-blur);
@@ -856,7 +964,13 @@ watch(() => route.query.event, (newId) => {
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.threshold-label { .filter-group {
display: flex;
align-items: center;
gap: 10px;
}
.filter-label {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
@@ -864,10 +978,9 @@ watch(() => route.query.event, (newId) => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding-left: 6px;
} }
.threshold-tabs { .filter-tabs {
display: flex; display: flex;
gap: 6px; gap: 6px;
flex-wrap: wrap; flex-wrap: wrap;
@@ -877,23 +990,24 @@ watch(() => route.query.event, (newId) => {
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
} }
.threshold-tab { .filter-tab {
padding: 6px 14px; padding: 6px 14px;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: none; /* 移除单个按钮的边框,采用内部选项卡组设计 */ border: none;
background: transparent; background: transparent;
color: var(--text-secondary); color: var(--text-secondary);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
} }
.threshold-tab:hover { .filter-tab:hover {
color: var(--text-primary); color: var(--text-primary);
background: var(--bg-hover); background: var(--bg-hover);
} }
.threshold-tab.active { .filter-tab.active {
background: var(--bg-surface); background: var(--bg-surface);
color: var(--brand-primary); color: var(--brand-primary);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
@@ -958,12 +1072,25 @@ watch(() => route.query.event, (newId) => {
color: var(--text-secondary); color: var(--text-secondary);
} }
.card-time { .event-time-range {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
white-space: nowrap; white-space: nowrap;
} }
.time-arrow {
font-size: 10px;
color: var(--text-placeholder);
}
.active-time {
color: var(--brand-primary);
font-weight: 500;
}
.card-title { .card-title {
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
@@ -1043,6 +1170,7 @@ watch(() => route.query.event, (newId) => {
align-items: center; align-items: center;
gap: 10px; gap: 10px;
min-width: 0; min-width: 0;
font-size: 14px;
} }
.platform-info i { .platform-info i {
@@ -1207,6 +1335,42 @@ watch(() => route.query.event, (newId) => {
gap: 8px; 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 { .widget-action-link {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 14px; font-size: 14px;
@@ -1451,15 +1615,34 @@ watch(() => route.query.event, (newId) => {
} }
.revision-meta { .revision-meta {
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 8px; margin-bottom: 8px;
} }
.platform-info {
display: flex;
align-items: center;
font-size: 13px;
color: var(--text-secondary);
}
.rev-platform-icon { .rev-platform-icon {
margin-right: 4px; 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 { .revision-diff {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+22 -1
View File
@@ -15,6 +15,7 @@ interface RevisionChain {
first_at: string first_at: string
last_at: string last_at: string
change_count: number change_count: number
url: string | null
} }
const revisions = ref<HeadlineRevision[]>([]) const revisions = ref<HeadlineRevision[]>([])
@@ -105,6 +106,7 @@ const revisionChains = computed<RevisionChain[]>(() => {
first_at: items[0].created_at, first_at: items[0].created_at,
last_at: items[items.length - 1].created_at, last_at: items[items.length - 1].created_at,
change_count: items.length, change_count: items.length,
url: items[0].url,
}) })
} }
@@ -191,7 +193,16 @@ onMounted(loadRevisions)
<div class="revision-header"> <div class="revision-header">
<div class="platform-info"> <div class="platform-info">
<i :class="getPlatformIcon(chain.source_name || '')"></i> <i :class="getPlatformIcon(chain.source_name || '')"></i>
<span>{{ chain.source_name || '未知平台' }}</span> <a
v-if="chain.url"
:href="chain.url"
target="_blank"
rel="noopener"
class="platform-link"
>
{{ chain.source_name || '未知平台' }}
</a>
<span v-else>{{ chain.source_name || '未知平台' }}</span>
<span v-if="chain.change_count > 1" class="change-badge"> <span v-if="chain.change_count > 1" class="change-badge">
{{ chain.change_count }} 次修改 {{ chain.change_count }} 次修改
</span> </span>
@@ -402,6 +413,16 @@ onMounted(loadRevisions)
font-size: 16px; font-size: 16px;
} }
.platform-link {
text-decoration: none;
color: inherit;
transition: color 0.15s;
}
.platform-link:hover {
color: var(--brand-primary);
}
.revision-time-range { .revision-time-range {
display: flex; display: flex;
align-items: center; align-items: center;
+146 -8
View File
@@ -19,6 +19,30 @@ const matchedEvents = ref<MatchedEvent[]>([])
const loadingMatched = ref(false) const loadingMatched = ref(false)
const matchedError = ref('') const matchedError = ref('')
const hoursRange = ref(72)
const sortBy = ref('match_score')
const hoursOptions = [
{ label: '24小时', value: 24 },
{ label: '72小时', value: 72 },
{ label: '7天', value: 168 },
]
const sortOptions = [
{ label: '匹配度', value: 'match_score' },
{ label: '最新', value: 'created_at' },
]
function onHoursChange(value: number) {
hoursRange.value = value
loadMatchedEvents()
}
function onSortChange(value: string) {
sortBy.value = value
loadMatchedEvents()
}
/** 加载用户的兴趣关键词 */ /** 加载用户的兴趣关键词 */
async function loadPreferences() { async function loadPreferences() {
if (!userId.value) return if (!userId.value) return
@@ -39,7 +63,11 @@ async function loadMatchedEvents() {
loadingMatched.value = true loadingMatched.value = true
matchedError.value = '' matchedError.value = ''
try { try {
const result = await fetchRecommendedEvents(userId.value, { limit: 30 }) const result = await fetchRecommendedEvents(userId.value, {
limit: 30,
hours: hoursRange.value,
sort_by: sortBy.value
})
matchedEvents.value = result.data matchedEvents.value = result.data
} catch (e) { } catch (e) {
matchedError.value = e instanceof Error ? e.message : '加载失败' matchedError.value = e instanceof Error ? e.message : '加载失败'
@@ -180,13 +208,52 @@ onMounted(async () => {
<!-- 命中的热点事件 --> <!-- 命中的热点事件 -->
<div class="matched-section"> <div class="matched-section">
<h2 class="sub-title"> <div class="section-header">
<i class="fa-solid fa-wand-magic-sparkles" style="color: var(--brand-primary)"></i> <h2 class="sub-title">
命中的热点事件 <i class="fa-solid fa-wand-magic-sparkles" style="color: var(--brand-primary)"></i>
<span v-if="!loadingMatched && matchedEvents.length > 0" class="count-badge"> 命中的热点事件
{{ matchedEvents.length }} <span v-if="!loadingMatched && matchedEvents.length > 0" class="count-badge">
</span> {{ matchedEvents.length }}
</h2> </span>
</h2>
<!-- 筛选排序栏 -->
<div v-if="preferences.length > 0" class="filters-bar">
<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>
<!-- 加载中 --> <!-- 加载中 -->
<div v-if="loadingMatched" class="loading-state"> <div v-if="loadingMatched" class="loading-state">
@@ -507,6 +574,77 @@ onMounted(async () => {
margin-bottom: 32px; margin-bottom: 32px;
} }
.section-header {
margin-bottom: 16px;
}
.section-header .sub-title {
margin-bottom: 12px;
}
.filters-bar {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 12px 16px;
margin-bottom: 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-lg);
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-md);
border: 1px solid var(--border-subtle);
}
.filter-tab {
padding: 4px 12px;
font-size: 12px;
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);
}
.matched-list { .matched-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;