Files
InsightRadar/frontend/src/views/TopicsView.vue
T
2026-04-20 16:02:50 +08:00

807 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { fetchPreferences, createPreference, deletePreference, fetchRecommendedEvents } from '@/api/preferences'
import type { UserTopicPreference, MatchedEvent } from '@/types/preference'
const authStore = useAuthStore()
const userId = computed(() => authStore.user?.id ?? 0)
const preferences = ref<UserTopicPreference[]>([])
const newKeyword = ref('')
const loading = ref(true)
const submitting = ref(false)
const error = ref('')
const successMsg = ref('')
const matchedEvents = ref<MatchedEvent[]>([])
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
loading.value = true
error.value = ''
try {
preferences.value = await fetchPreferences(userId.value)
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
/** 加载命中关键词的推荐事件 */
async function loadMatchedEvents() {
if (!userId.value) return
loadingMatched.value = true
matchedError.value = ''
try {
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 : '加载失败'
} finally {
loadingMatched.value = false
}
}
/** 格式化热度标签 */
function hotLabel(score: number): { text: string; color: string; bg: string } {
if (score >= 50) return { text: `🔥 ${score}`, color: '#ef4444', bg: 'rgba(239,68,68,0.1)' }
if (score >= 20) return { text: `🌡 ${score}`, color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' }
return { text: `${score}`, color: 'var(--text-secondary)', bg: 'var(--bg-input)' }
}
/** 添加新关键词 */
async function handleAdd() {
const keyword = newKeyword.value.trim()
if (!keyword) return
if (!userId.value) return
submitting.value = true
error.value = ''
successMsg.value = ''
try {
const created = await createPreference(userId.value, keyword)
preferences.value.unshift(created)
newKeyword.value = ''
successMsg.value = `已添加「${keyword}`
setTimeout(() => { successMsg.value = '' }, 3000)
loadMatchedEvents()
} catch (e) {
error.value = e instanceof Error ? e.message : '添加失败'
} finally {
submitting.value = false
}
}
/** 删除关键词 */
async function handleDelete(pref: UserTopicPreference) {
if (!userId.value) return
error.value = ''
try {
await deletePreference(userId.value, pref.id)
preferences.value = preferences.value.filter(p => p.id !== pref.id)
loadMatchedEvents()
} catch (e) {
error.value = e instanceof Error ? e.message : '删除失败'
}
}
/** Enter 键提交 */
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
handleAdd()
}
}
onMounted(async () => {
await loadPreferences()
loadMatchedEvents()
})
</script>
<template>
<div class="topics-page">
<div class="page-header">
<h1>
<i class="fa-solid fa-rss" style="color: var(--brand-primary)"></i>
我的泛订阅
</h1>
<p class="page-desc">
添加你感兴趣的关键词系统会自动匹配全网热点事件并推送给你
支持精确匹配和 AI 语义匹配
</p>
</div>
<!-- 添加关键词 -->
<div class="add-section">
<div class="add-form">
<div class="input-wrapper">
<i class="fa-solid fa-plus input-icon"></i>
<input
v-model="newKeyword"
type="text"
class="keyword-input"
placeholder="输入关键词,如「篮球」「科比」「科技」..."
maxlength="100"
@keydown="onInputKeydown"
/>
</div>
<button class="add-btn" :disabled="submitting || !newKeyword.trim()" @click="handleAdd">
{{ submitting ? '添加中...' : '添加' }}
</button>
</div>
<!-- 提示消息 -->
<p v-if="successMsg" class="msg success-msg">
<i class="fa-solid fa-check-circle"></i> {{ successMsg }}
</p>
<p v-if="error" class="msg error-msg">
<i class="fa-solid fa-circle-exclamation"></i> {{ error }}
</p>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<i class="fa-solid fa-spinner fa-spin"></i>
<span>加载中...</span>
</div>
<!-- 关键词列表 -->
<div v-else class="keywords-section">
<h2 class="sub-title">
已订阅的关键词
<span class="count-badge">{{ preferences.length }}</span>
</h2>
<div v-if="preferences.length === 0" class="empty-state">
<i class="fa-solid fa-bookmark"></i>
<p>还没有添加任何关键词</p>
<p class="empty-hint">在上方输入框中添加你感兴趣的话题</p>
</div>
<div v-else class="keywords-grid">
<div v-for="pref in preferences" :key="pref.id" class="keyword-card">
<div class="keyword-content">
<i class="fa-solid fa-hashtag keyword-icon"></i>
<span class="keyword-text">{{ pref.interested_keyword }}</span>
</div>
<button class="delete-btn" title="删除" @click="handleDelete(pref)">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
</div>
<!-- 命中的热点事件 -->
<div class="matched-section">
<div class="section-header">
<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>
</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">
<i class="fa-solid fa-spinner fa-spin"></i>
<span>AI 匹配中...</span>
</div>
<!-- 错误 -->
<p v-else-if="matchedError" class="msg error-msg">
<i class="fa-solid fa-circle-exclamation"></i> {{ matchedError }}
</p>
<!-- 无关键词 -->
<div v-else-if="preferences.length === 0" class="empty-state">
<i class="fa-solid fa-search"></i>
<p>先添加关键词才能查看匹配事件</p>
</div>
<!-- 无匹配 -->
<div v-else-if="matchedEvents.length === 0" class="empty-state">
<i class="fa-solid fa-satellite-dish"></i>
<p>暂未匹配到相关事件</p>
<p class="empty-hint">系统会在下次 AI 摘要生成后自动更新</p>
</div>
<!-- 匹配事件列表 -->
<div v-else class="matched-list">
<RouterLink
v-for="ev in matchedEvents"
:key="ev.event_id"
:to="{ path: '/', query: { event: ev.event_id } }"
class="matched-card"
>
<!-- 热度 + 匹配度 -->
<div class="matched-card-meta">
<span
class="hot-chip"
:style="{ color: hotLabel(ev.hot_score).color, background: hotLabel(ev.hot_score).bg }"
>
{{ hotLabel(ev.hot_score).text }}
</span>
<span class="match-score-chip">
<i class="fa-solid fa-crosshairs"></i>
匹配度 {{ ev.match_score.toFixed(0) }}
</span>
<span class="matched-goto">
<i class="fa-solid fa-arrow-up-right-from-square"></i> 查看详情
</span>
</div>
<!-- 标题 -->
<p class="matched-title">{{ ev.unified_title }}</p>
<!-- AI 摘要 -->
<p v-if="ev.summary" class="matched-summary">{{ ev.summary }}</p>
<!-- 命中的关键词标签 -->
<div class="matched-hits">
<span v-for="hit in ev.exact_hits.slice(0, 4)" :key="hit" class="hit-tag exact">
<i class="fa-solid fa-bullseye"></i> {{ hit }}
</span>
<span
v-for="sh in ev.semantic_hits.slice(0, 3)"
:key="sh.topic_keyword"
class="hit-tag semantic"
>
<i class="fa-solid fa-brain"></i> {{ sh.topic_keyword }}
<span class="sim-pct">{{ (sh.similarity * 100).toFixed(0) }}%</span>
</span>
</div>
</RouterLink>
</div>
</div>
<!-- 功能说明 -->
<div class="info-panel">
<h3><i class="fa-solid fa-lightbulb"></i> 匹配说明</h3>
<ul>
<li><strong>精确匹配</strong>关键词与事件标签完全一致或互为包含关系时命中</li>
<li><strong>语义匹配</strong>使用向量模型计算语义相似度超过阈值自动命中</li>
<li><strong>推送触发</strong>当新事件的标签命中您的关键词时将在设定时间推送简报</li>
</ul>
</div>
</div>
</template>
<style scoped>
.topics-page {
max-width: 800px;
margin: 0 auto;
}
.page-header {
margin-bottom: 32px;
}
.page-header h1 {
font-size: 24px;
font-weight: 700;
margin: 0 0 8px;
display: flex;
align-items: center;
gap: 10px;
}
.page-desc {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
margin: 0;
}
/* ==========================================
添加区域
========================================== */
.add-section {
margin-bottom: 32px;
}
.add-form {
display: flex;
gap: 12px;
}
.input-wrapper {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
.input-icon {
position: absolute;
left: 14px;
color: var(--text-placeholder);
font-size: 13px;
}
.keyword-input {
width: 100%;
padding: 14px 16px 14px 42px;
background: var(--bg-input);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 15px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: inset 0 2px 4px rgba(0,0,0,0.02);
}
.keyword-input::placeholder {
color: var(--text-placeholder);
}
.keyword-input:focus {
outline: none;
border-color: var(--brand-primary);
box-shadow: 0 0 0 4px var(--brand-primary-alpha), inset 0 1px 2px rgba(0,0,0,0.02);
background: var(--bg-surface);
}
.add-btn {
padding: 14px 28px;
background: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-primary-hover) 100%);
color: #fff;
font-size: 15px;
font-weight: 600;
border-radius: var(--radius-md);
white-space: nowrap;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px var(--brand-primary-alpha);
}
.add-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px var(--brand-primary-alpha);
filter: brightness(1.1);
}
.add-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.msg {
font-size: 13px;
margin: 10px 0 0;
display: flex;
align-items: center;
gap: 6px;
}
.success-msg {
color: var(--status-success);
}
.error-msg {
color: var(--status-error);
}
/* ==========================================
关键词列表
========================================== */
.keywords-section {
margin-bottom: 32px;
}
.sub-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 16px;
display: flex;
align-items: center;
gap: 8px;
}
.count-badge {
font-size: 12px;
padding: 2px 10px;
border-radius: 10px;
background: var(--brand-primary-alpha);
color: var(--brand-primary);
font-weight: 700;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px;
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.empty-state i {
font-size: 36px;
opacity: 0.3;
margin-bottom: 12px;
display: block;
}
.empty-state p {
margin: 0;
font-size: 14px;
}
.empty-hint {
margin-top: 6px !important;
font-size: 13px !important;
color: var(--text-placeholder);
}
.keywords-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.keyword-card {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 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);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--shadow-sm);
}
.keyword-card:hover {
border-color: var(--brand-primary-alpha);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.keyword-content {
display: flex;
align-items: center;
gap: 6px;
}
.keyword-icon {
color: var(--brand-primary);
font-size: 12px;
}
.keyword-text {
font-size: 14px;
font-weight: 500;
}
.delete-btn {
color: var(--text-placeholder);
padding: 4px 6px;
border-radius: 4px;
font-size: 12px;
transition: all 0.2s;
}
.delete-btn:hover {
color: var(--status-error);
background: rgba(239, 68, 68, 0.1);
}
/* ==========================================
命中事件区块
========================================== */
.matched-section {
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;
gap: 10px;
}
.matched-card {
display: block;
padding: 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);
text-decoration: none;
color: inherit;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.matched-card:hover {
border-color: var(--brand-primary-alpha);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.matched-card-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.hot-chip {
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 99px;
}
.match-score-chip {
font-size: 11px;
color: var(--text-placeholder);
display: flex;
align-items: center;
gap: 4px;
}
.matched-goto {
margin-left: auto;
font-size: 11px;
color: var(--brand-primary);
opacity: 0;
transition: opacity 0.2s;
white-space: nowrap;
}
.matched-card:hover .matched-goto {
opacity: 1;
}
.matched-title {
font-size: 15px;
font-weight: 600;
line-height: 1.5;
margin: 0 0 6px;
color: var(--text-primary);
}
.matched-summary {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
margin: 0 0 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.matched-hits {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.hit-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 99px;
font-size: 11px;
font-weight: 600;
}
.hit-tag.exact {
background: rgba(99, 102, 241, 0.1);
color: #6366f1;
}
.hit-tag.semantic {
background: rgba(16, 185, 129, 0.1);
color: var(--status-success);
}
.sim-pct {
opacity: 0.7;
}
/* ==========================================
功能说明
========================================== */
.info-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);
padding: 24px;
box-shadow: var(--shadow-sm);
}
.info-panel h3 {
font-size: 14px;
font-weight: 600;
margin: 0 0 12px;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-primary);
}
.info-panel h3 i {
color: #facc15;
}
.info-panel ul {
list-style: none;
padding: 0;
margin: 0;
}
.info-panel li {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.8;
padding-left: 16px;
position: relative;
}
.info-panel li::before {
content: '•';
position: absolute;
left: 0;
color: var(--brand-primary);
}
</style>