big update

This commit is contained in:
stardrophere
2026-03-11 20:52:58 +08:00
parent 8ed819a580
commit 966bcfbba4
44 changed files with 7124 additions and 650 deletions
+668
View File
@@ -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>