mirror of
https://github.com/stardrophere/InsightRadar.git
synced 2026-06-05 23:56:36 +08:00
big update
This commit is contained in:
@@ -0,0 +1,668 @@
|
||||
<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('')
|
||||
|
||||
/** 加载用户的兴趣关键词 */
|
||||
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 })
|
||||
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">
|
||||
<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="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;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user