mirror of
https://github.com/stardrophere/InsightRadar.git
synced 2026-06-06 00:57:51 +08:00
update
This commit is contained in:
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取标题修改追踪列表 */
|
/** 获取标题修改追踪列表 */
|
||||||
|
|||||||
@@ -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>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,15 +377,16 @@ 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">
|
||||||
|
<i class="fa-solid fa-fire"></i> 最低热度
|
||||||
</span>
|
</span>
|
||||||
<div class="threshold-tabs">
|
<div class="filter-tabs">
|
||||||
<button
|
<button
|
||||||
v-for="opt in thresholdOptions"
|
v-for="opt in thresholdOptions"
|
||||||
:key="opt.value"
|
:key="opt.value"
|
||||||
class="threshold-tab"
|
class="filter-tab"
|
||||||
:class="{ active: minHot === opt.value }"
|
:class="{ active: minHot === opt.value }"
|
||||||
@click="onThresholdChange(opt.value)"
|
@click="onThresholdChange(opt.value)"
|
||||||
>
|
>
|
||||||
@@ -357,6 +394,41 @@ watch(() => route.query.event, (newId) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<!-- 聚光灯:从推荐页跳转时展示目标事件(独立于主列表,不受分页影响) -->
|
<!-- 聚光灯:从推荐页跳转时展示目标事件(独立于主列表,不受分页影响) -->
|
||||||
@@ -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,10 +683,23 @@ 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>
|
||||||
|
<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">
|
<RouterLink to="/topics" class="widget-action-link">
|
||||||
<i class="fa-solid fa-gear"></i>
|
<i class="fa-solid fa-gear"></i>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="widget-body">
|
<div class="widget-body">
|
||||||
<!-- 加载中 -->
|
<!-- 加载中 -->
|
||||||
<div v-if="loadingRecommend" class="widget-empty">
|
<div v-if="loadingRecommend" class="widget-empty">
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,6 +208,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<!-- 命中的热点事件 -->
|
<!-- 命中的热点事件 -->
|
||||||
<div class="matched-section">
|
<div class="matched-section">
|
||||||
|
<div class="section-header">
|
||||||
<h2 class="sub-title">
|
<h2 class="sub-title">
|
||||||
<i class="fa-solid fa-wand-magic-sparkles" style="color: var(--brand-primary)"></i>
|
<i class="fa-solid fa-wand-magic-sparkles" style="color: var(--brand-primary)"></i>
|
||||||
命中的热点事件
|
命中的热点事件
|
||||||
@@ -188,6 +217,44 @@ onMounted(async () => {
|
|||||||
</span>
|
</span>
|
||||||
</h2>
|
</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">
|
||||||
<i class="fa-solid fa-spinner fa-spin"></i>
|
<i class="fa-solid fa-spinner fa-spin"></i>
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user