From 3d7d53f96f13627ef0ead5a838f2b5607655870e Mon Sep 17 00:00:00 2001 From: stardrophere <1925008984@qq.com> Date: Thu, 12 Mar 2026 13:00:10 +0800 Subject: [PATCH] update --- backend/app/api/endpoints/events.py | 34 ++- backend/app/api/endpoints/preferences.py | 4 + backend/app/api/endpoints/revisions.py | 6 +- backend/app/schemas/event_schema.py | 1 + frontend/src/api/events.ts | 3 +- frontend/src/api/preferences.ts | 4 +- frontend/src/types/event.ts | 2 + frontend/src/views/DashboardView.vue | 267 +++++++++++++++++++---- frontend/src/views/RevisionsView.vue | 23 +- frontend/src/views/TopicsView.vue | 154 ++++++++++++- 10 files changed, 433 insertions(+), 65 deletions(-) diff --git a/backend/app/api/endpoints/events.py b/backend/app/api/endpoints/events.py index 25c90f0..d7f994d 100644 --- a/backend/app/api/endpoints/events.py +++ b/backend/app/api/endpoints/events.py @@ -30,7 +30,8 @@ MAX_RANKING_POINTS = 30 @router.get("/unified", response_model=PaginatedUnifiedEventResponse) def list_unified_events( 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="分页偏移量"), limit: int = Query(10, ge=1, le=50, description="每页返回条数"), db: Session = Depends(get_db), @@ -46,13 +47,12 @@ def list_unified_events( total = base_query.count() # 分页查询 - events = ( - base_query - .order_by(UnifiedEvent.hot_score.desc()) - .offset(skip) - .limit(limit) - .all() - ) + if sort_by == "created_at": + base_query = base_query.order_by(UnifiedEvent.created_at.desc()) + else: + base_query = base_query.order_by(UnifiedEvent.hot_score.desc(), UnifiedEvent.created_at.desc()) + + events = base_query.offset(skip).limit(limit).all() if not events: return PaginatedUnifiedEventResponse(total=total, has_more=False, data=[]) @@ -110,7 +110,8 @@ def list_unified_events( results: list[UnifiedEventResponse] = [] for ev in events: 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, []) # 截取尾部,只保留最近的点 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( UnifiedEventResponse( event_id=ev.id, @@ -134,6 +142,7 @@ def list_unified_events( summary=ev.ai_comprehensive_summary, hot_score=ev.hot_score, created_at=ev.created_at, + last_active_at=last_active_at, platforms=platform_list, 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( event_id=ev.id, unified_title=ev.unified_title if ev.unified_title else "暂无标题", summary=ev.ai_comprehensive_summary, hot_score=ev.hot_score, created_at=ev.created_at, + last_active_at=last_active_at, platforms=platform_list, tags=tags, ) diff --git a/backend/app/api/endpoints/preferences.py b/backend/app/api/endpoints/preferences.py index 56b733b..3ac1e9f 100644 --- a/backend/app/api/endpoints/preferences.py +++ b/backend/app/api/endpoints/preferences.py @@ -120,6 +120,7 @@ def recommend_events( hours: int = Query(72, ge=1, le=24 * 30, description="仅匹配最近多少小时的事件"), limit: int = Query(20, ge=1, le=50, 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), current_user: AppUser = Depends(get_current_user), ): @@ -135,6 +136,9 @@ def recommend_events( semantic_threshold=semantic_threshold, ) + if sort_by == "created_at": + matched.sort(key=lambda x: x.event.created_at, reverse=True) + result_data: list[MatchedEventResponse] = [] for item in matched: result_data.append( diff --git a/backend/app/api/endpoints/revisions.py b/backend/app/api/endpoints/revisions.py index ad92727..a64b6d5 100644 --- a/backend/app/api/endpoints/revisions.py +++ b/backend/app/api/endpoints/revisions.py @@ -21,6 +21,7 @@ class HeadlineRevisionResponse(BaseModel): revised_headline: str source_name: Optional[str] = None platform_icon: Optional[str] = None + url: Optional[str] = None created_at: datetime model_config = ConfigDict(from_attributes=True) @@ -39,7 +40,7 @@ def list_headline_revisions( time_limit = utcnow() - timedelta(hours=hours) rows = ( - db.query(HeadlineRevision, InfoSource.source_name) + db.query(HeadlineRevision, InfoSource.source_name, TrendingEvent.event_url) .join(TrendingEvent, HeadlineRevision.event_id == TrendingEvent.id) .join(InfoSource, TrendingEvent.source_id == InfoSource.id) .filter(HeadlineRevision.created_at >= time_limit) @@ -59,7 +60,7 @@ def list_headline_revisions( } results: list[HeadlineRevisionResponse] = [] - for revision, source_name in rows: + for revision, source_name, event_url in rows: results.append( HeadlineRevisionResponse( id=revision.id, @@ -68,6 +69,7 @@ def list_headline_revisions( revised_headline=revision.revised_headline, source_name=source_name, platform_icon=icon_map.get(source_name, "newspaper"), + url=event_url, created_at=revision.created_at, ) ) diff --git a/backend/app/schemas/event_schema.py b/backend/app/schemas/event_schema.py index e609d68..6b5b15e 100644 --- a/backend/app/schemas/event_schema.py +++ b/backend/app/schemas/event_schema.py @@ -19,6 +19,7 @@ class UnifiedEventResponse(BaseModel): summary: Optional[str] hot_score: int created_at: datetime + last_active_at: datetime platforms: List[PlatformTrendResponse] tags: List[str] = Field(default_factory=list) diff --git a/frontend/src/api/events.ts b/frontend/src/api/events.ts index cbba3b5..ecb37c4 100644 --- a/frontend/src/api/events.ts +++ b/frontend/src/api/events.ts @@ -10,10 +10,11 @@ export function fetchEventById(eventId: number): Promise { export function fetchUnifiedEvents(params?: { min_hot?: number hours?: number + sort_by?: string skip?: number limit?: number }): Promise { - return apiGet('/events/unified', params as Record) + return apiGet('/events/unified', params as Record) } /** 获取标题修改追踪列表 */ diff --git a/frontend/src/api/preferences.ts b/frontend/src/api/preferences.ts index 284c554..196cada 100644 --- a/frontend/src/api/preferences.ts +++ b/frontend/src/api/preferences.ts @@ -24,10 +24,10 @@ export function deletePreference(userId: number, preferenceId: number): Promise< /** 基于兴趣词获取推荐事件 */ export function fetchRecommendedEvents( userId: number, - params?: { min_hot?: number; hours?: number; limit?: number }, + params?: { min_hot?: number; hours?: number; limit?: number; sort_by?: string }, ): Promise { return apiGet( `/users/${userId}/recommended-events`, - params as Record, + params as Record, ) } diff --git a/frontend/src/types/event.ts b/frontend/src/types/event.ts index e7a7a7a..ffafb88 100644 --- a/frontend/src/types/event.ts +++ b/frontend/src/types/event.ts @@ -15,6 +15,7 @@ export interface UnifiedEvent { summary: string | null hot_score: number created_at: string + last_active_at: string platforms: PlatformTrend[] tags: string[] } @@ -34,6 +35,7 @@ export interface HeadlineRevision { revised_headline: string source_name: string | null platform_icon: string | null + url: string | null created_at: string } diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index a86a0cc..d0b661c 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -65,6 +65,11 @@ 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('hot_score') +const recSortBy = ref('match_score') + // 当前鼠标悬停的平台行标识 // 使用 "eventId-platformIndex" 作为唯一键,而非 sourceId, // 因为同一个平台在同一大事件下可能有多条不同的热搜条目 @@ -76,8 +81,22 @@ const thresholdOptions = [ { label: '≥ 3', value: 3 }, { label: '≥ 5', value: 5 }, { 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[] }) { 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 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: '#10b981', text: `TOP ${rank} ↑${diff}` } + 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: '#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}` } } @@ -235,7 +255,8 @@ async function loadEvents(append = false) { const skip = append ? events.value.length : 0 const result = await fetchUnifiedEvents({ min_hot: minHot.value, - hours: 48, + hours: hoursRange.value, + sort_by: sortBy.value, skip, limit: PAGE_SIZE, }) @@ -261,7 +282,7 @@ async function loadRecommendations() { try { const [keywords, recommended] = await Promise.all([ 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 recommendedEvents.value = recommended.data @@ -278,6 +299,21 @@ function onThresholdChange(value: number) { 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) @@ -341,20 +377,56 @@ watch(() => route.query.event, (newId) => { -
- - 热度阈值 - -
- +
+
+ + 最低热度 + +
+ +
+
+ +
+ + 时间范围 + +
+ +
+
+ +
+ + 排序方式 + +
+ +
@@ -393,7 +465,13 @@ watch(() => route.query.event, (newId) => { {{ tag }}
- {{ formatRelativeTime(spotlightEvent.created_at) }} +
+ {{ formatRelativeTime(spotlightEvent.created_at) }} + +

{{ spotlightEvent.unified_title }}

@@ -499,7 +577,13 @@ watch(() => route.query.event, (newId) => { {{ tag }}
- {{ formatRelativeTime(ev.created_at) }} +
+ {{ formatRelativeTime(ev.created_at) }} + +

{{ ev.unified_title }}

@@ -599,9 +683,22 @@ watch(() => route.query.event, (newId) => { 为你推荐 - - - +
+
+ +
+ + + +
@@ -689,9 +786,20 @@ watch(() => route.query.event, (newId) => {
- + - {{ rev.source_name || '未知平台' }} · {{ formatRelativeTime(rev.created_at) }} + + {{ rev.source_name || '未知平台' }} + + {{ rev.source_name || '未知平台' }} + · + {{ formatRelativeTime(rev.created_at) }}
@@ -843,11 +951,11 @@ watch(() => route.query.event, (newId) => { font-weight: 500; } -.threshold-bar { +.filters-bar { display: flex; - align-items: center; - gap: 12px; - padding: 8px 12px; + flex-wrap: wrap; + gap: 16px; + padding: 12px 16px; background: var(--bg-surface); 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); } -.threshold-label { +.filter-group { + display: flex; + align-items: center; + gap: 10px; +} + +.filter-label { font-size: 13px; font-weight: 600; color: var(--text-secondary); @@ -864,10 +978,9 @@ watch(() => route.query.event, (newId) => { display: flex; align-items: center; gap: 6px; - padding-left: 6px; } -.threshold-tabs { +.filter-tabs { display: flex; gap: 6px; flex-wrap: wrap; @@ -877,23 +990,24 @@ watch(() => route.query.event, (newId) => { border: 1px solid var(--border-subtle); } -.threshold-tab { +.filter-tab { padding: 6px 14px; font-size: 13px; font-weight: 600; border-radius: var(--radius-sm); - border: none; /* 移除单个按钮的边框,采用内部选项卡组设计 */ + border: none; background: transparent; color: var(--text-secondary); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; } -.threshold-tab:hover { +.filter-tab:hover { color: var(--text-primary); background: var(--bg-hover); } -.threshold-tab.active { +.filter-tab.active { background: var(--bg-surface); color: var(--brand-primary); box-shadow: var(--shadow-sm); @@ -958,12 +1072,25 @@ watch(() => route.query.event, (newId) => { color: var(--text-secondary); } -.card-time { +.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; @@ -1043,6 +1170,7 @@ watch(() => route.query.event, (newId) => { align-items: center; gap: 10px; min-width: 0; + font-size: 14px; } .platform-info i { @@ -1207,6 +1335,42 @@ watch(() => route.query.event, (newId) => { 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; @@ -1451,15 +1615,34 @@ watch(() => route.query.event, (newId) => { } .revision-meta { - font-size: 11px; - color: var(--text-secondary); 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; diff --git a/frontend/src/views/RevisionsView.vue b/frontend/src/views/RevisionsView.vue index aa0c498..1ba29f1 100644 --- a/frontend/src/views/RevisionsView.vue +++ b/frontend/src/views/RevisionsView.vue @@ -15,6 +15,7 @@ interface RevisionChain { first_at: string last_at: string change_count: number + url: string | null } const revisions = ref([]) @@ -105,6 +106,7 @@ const revisionChains = computed(() => { first_at: items[0].created_at, last_at: items[items.length - 1].created_at, change_count: items.length, + url: items[0].url, }) } @@ -191,7 +193,16 @@ onMounted(loadRevisions)
- {{ chain.source_name || '未知平台' }} + + {{ chain.source_name || '未知平台' }} + + {{ chain.source_name || '未知平台' }} {{ chain.change_count }} 次修改 @@ -402,6 +413,16 @@ onMounted(loadRevisions) font-size: 16px; } +.platform-link { + text-decoration: none; + color: inherit; + transition: color 0.15s; +} + +.platform-link:hover { + color: var(--brand-primary); +} + .revision-time-range { display: flex; align-items: center; diff --git a/frontend/src/views/TopicsView.vue b/frontend/src/views/TopicsView.vue index 19f0099..f8d1c15 100644 --- a/frontend/src/views/TopicsView.vue +++ b/frontend/src/views/TopicsView.vue @@ -19,6 +19,30 @@ const matchedEvents = ref([]) const loadingMatched = ref(false) 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() { if (!userId.value) return @@ -39,7 +63,11 @@ async function loadMatchedEvents() { loadingMatched.value = true matchedError.value = '' 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 } catch (e) { matchedError.value = e instanceof Error ? e.message : '加载失败' @@ -180,13 +208,52 @@ onMounted(async () => {
-

- - 命中的热点事件 - - {{ matchedEvents.length }} - -

+
+

+ + 命中的热点事件 + + {{ matchedEvents.length }} + +

+ + +
+
+ + 时间范围 + +
+ +
+
+ +
+ + 排序方式 + +
+ +
+
+
+
@@ -507,6 +574,77 @@ onMounted(async () => { 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 { display: flex; flex-direction: column;