Files
CloudSearch/packages/frontend/src/pages/HomePage.vue

364 lines
12 KiB
Vue
Executable File
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.
<template>
<div class="home-page">
<div class="hero-section">
<template v-if="configLoaded">
<img v-if="siteLogo" :src="siteLogo" :alt="siteName || 'CloudSearch'" class="logo-img" @error="(e: any) => { (e.target as HTMLElement).style.display='none'; siteLogo='' }" />
<div v-else class="logo-text">{{ siteName || 'CloudSearch' }}</div>
</template>
<div class="search-box">
<el-input
v-model="query"
placeholder="搜索网盘资源,或粘贴视频/网盘链接..."
size="large"
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" size="large" @click="handleSearch" class="search-btn">
</el-button>
</div>
<div class="quote-section" v-if="currentQuote">
<span class="quote-text"> {{ currentQuote }} </span>
<span class="quote-author">---{{ quoteAuthor }}</span>
</div>
</div>
<div class="content-section">
<div v-if="categories.length > 0" class="rankings-grid">
<div
v-for="cat in categories"
:key="cat.category"
class="rank-panel"
>
<div class="panel-header">
<span class="panel-title">{{ getCategoryIcon(cat.category) }} {{ cat.label }}</span>
<div class="panel-tabs">
<span class="panel-tab" :class="{ active: activeTab[cat.category] === 'hot' }" @click="switchTab(cat.category, 'hot')">热榜</span>
<span class="panel-tab" :class="{ active: activeTab[cat.category] === 'newest' }" @click="switchTab(cat.category, 'newest')">最新</span>
</div>
</div>
<div class="panel-body">
<div
v-for="(item, idx) in visibleItems(cat)"
:key="cat.category + '-' + idx"
class="rank-item"
@click="searchTag(item.keyword)"
>
<span class="rank-idx" :class="{ 'top-three': idx < 3 }">{{ idx + 1 }}</span>
<span class="rank-name">{{ item.keyword }}</span>
<span class="rank-cnt">{{ formatCount(item) }}</span>
</div>
<!-- 展开按钮 -->
<div v-if="hasMoreItems(cat)" class="rank-expand" @click="expandCategory(cat.category)">
展开全部
</div>
</div>
<div class="panel-footer">
<span v-if="cat.category === 'hotsite'">基于本站搜索数据</span>
<span v-else-if="cat.category === 'donghua' || cat.category === 'global_anime'">数据来源Bilibili</span>
<span v-else-if="cat.category === 'movie' || cat.category === 'tv'">数据来源百度</span>
<span v-else>数据来源TMDB</span>
<span class="footer-time">{{ fetchedAt }}</span>
</div>
</div>
</div>
</div>
<div v-if="siteDisclaimer" class="site-footer">
<div class="footer-inner">{{ siteDisclaimer }}</div>
<div class="footer-actions">
<el-button class="footer-disclaimer-btn" size="small" @click="openDisclaimer">📜 免责声明</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue'
import { getCategorizedRankings, getSiteConfig } from '../api'
const router = useRouter()
const query = ref('')
const categories = ref<any[]>([])
const expanded = reactive<Record<string, boolean>>({})
const activeTab = reactive<Record<string, string>>({})
const siteLogo = ref('')
const siteName = ref('')
const siteDisclaimer = ref('')
const configLoaded = ref(false)
const currentQuote = ref('')
const quoteAuthor = ref('')
const fetchedAt = ref('')
const INITIAL_SHOW = 8
const CATEGORY_ICONS: Record<string, string> = {
movie: '🎬', western_movie: '🎥', western_tv: '🌍',
donghua: '🐉', global_anime: '🌐',
tv: '📺',
niche: '💎', hotsite: '🏆',
}
function getCategoryIcon(cat: string): string {
return CATEGORY_ICONS[cat] || '📋'
}
function formatCount(item: any): string {
const parts: string[] = []
if (item.rating) {
parts.push(`${item.rating}`)
}
if (item.searchCount > 0) {
const n = item.searchCount
if (n >= 100000000) {
parts.push(`${(n / 100000000).toFixed(1)}亿`)
} else if (n >= 10000) {
parts.push(`${(n / 10000).toFixed(0)}`)
} else {
parts.push(String(n))
}
}
return parts.join(' ') || ''
}
function getItems(cat: any): any[] {
const tab = activeTab[cat.category] || 'hot'
return tab === 'hot' ? (cat.hot || []) : (cat.newest || [])
}
function visibleItems(cat: any): any[] {
const items = getItems(cat)
return expanded[cat.category] ? items : items.slice(0, INITIAL_SHOW)
}
function hasMoreItems(cat: any): boolean {
const items = getItems(cat)
return items.length > INITIAL_SHOW && !expanded[cat.category]
}
function expandCategory(category: string) {
expanded[category] = true
}
function switchTab(category: string, tab: string) {
activeTab[category] = tab
// 切换标签时收起展开
expanded[category] = false
}
function openDisclaimer() {
window.open('/disclaimer/', '_blank')
}
onMounted(async () => {
// 一言 API
try {
const res = await fetch('https://v1.hitokoto.cn/')
const data = await res.json()
currentQuote.value = data.hitokoto || ''
quoteAuthor.value = data.from_who || data.from || ''
} catch {
currentQuote.value = '学而时习之,不亦说乎。'
quoteAuthor.value = '孔子'
}
try {
const [catsData, siteCfg] = await Promise.all([
getCategorizedRankings(),
getSiteConfig(),
])
// 新格式: { fetchedAt, categories }
if (catsData.fetchedAt) {
fetchedAt.value = catsData.fetchedAt
categories.value = catsData.categories || []
} else {
// 兼容旧格式
categories.value = Array.isArray(catsData) ? catsData : []
}
for (const cat of categories.value) {
activeTab[cat.category] = 'hot'
expanded[cat.category] = false
}
if (siteCfg.site_logo) {
siteLogo.value = siteCfg.site_logo
}
if (siteCfg.site_name) {
siteName.value = siteCfg.site_name
}
if (siteCfg.site_disclaimer) {
siteDisclaimer.value = siteCfg.site_disclaimer
}
configLoaded.value = true
} catch (e) {
console.error('加载首页数据失败', e)
}
})
function handleSearch() {
const q = query.value.trim()
if (q) router.push('/search?q=' + encodeURIComponent(q))
}
function searchTag(tag: string) {
router.push('/search?q=' + encodeURIComponent(tag))
}
</script>
<style scoped>
.home-page { min-height: 100vh; display: flex; flex-direction: column; }
.hero-section {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 60px 24px 40px;
}
.logo-text { font-size: 64px; font-weight: 700; color: var(--primary-color); margin-bottom: 32px; letter-spacing: -2px; }
.logo-img { max-width: 500px; max-height: 120px; width: auto; height: auto; object-fit: contain; margin-bottom: 32px; }
.search-box {
display: flex;
align-items: center;
width: 100%; max-width: 640px;
border: 1px solid #dfe1e5;
border-radius: 24px;
background: #fff;
box-shadow: none;
transition: box-shadow .2s, border-color .2s;
overflow: hidden;
}
.search-box:focus-within {
box-shadow: 0 1px 6px rgba(32,33,36,.28);
border-color: rgba(223,225,229,0);
}
.search-box :deep(.el-input__wrapper) {
border: none; box-shadow: none; background: transparent;
padding: 4px 20px; border-radius: 0;
}
.search-box :deep(.el-input__inner) {
font-size: 15px;
}
.search-btn {
flex-shrink: 0;
border: none;
border-radius: 999px;
padding: 0 24px;
height: 38px;
line-height: 38px;
margin: 4px;
font-size: 14px; font-weight: 600;
background: var(--primary-color); color: #fff;
cursor: pointer; transition: all .2s;
letter-spacing: 1px;
}
.search-btn:hover {
background: #3a7be0;
}
.search-btn:active {
background: #2d6ccf;
}
.quote-section { margin-top: 18px; max-width: 640px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.quote-text { font-size: 14px; color: #aab0b8; font-style: italic; letter-spacing: 0.5px; }
.quote-author { font-size: 12px; color: #c0c4cc; display: inline-block; margin-left: 4px; }
.content-section { max-width: 1500px; width: 100%; margin: 0 auto; padding: 0 16px 60px; }
.rankings-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px;
margin-top: 8px;
}
.rank-panel {
background: var(--bg-white,#fff);
border-radius: 12px; padding: 14px; border: 1px solid #ebeef5;
box-shadow: 0 1px 4px rgba(0,0,0,0.04); display: flex; flex-direction: column;
}
.panel-header {
display: flex; align-items: center; justify-content: space-between;
padding-bottom: 10px; border-bottom: 2px solid #f0f0f0; margin-bottom: 4px;
}
.panel-title { font-size: 15px; font-weight: 700; color: #303133; white-space: nowrap; }
.panel-tabs { display: flex; gap: 2px; background: #f0f2f5; border-radius: 6px; padding: 2px; }
.panel-tab {
font-size: 11px; padding: 3px 10px; border-radius: 5px; cursor: pointer;
color: #909399; font-weight: 500; transition: all .2s; user-select: none;
}
.panel-tab.active { background: #fff; color: var(--primary-color); font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.panel-body { flex: 1; display: flex; flex-direction: column; gap: 1px; }
.rank-item {
display: flex; align-items: center; gap: 8px; padding: 5px 6px;
border-radius: 6px; cursor: pointer; transition: background .15s;
}
.rank-item:hover { background: #f0f5ff; }
.rank-item:active { background: #e6f0ff; }
.rank-idx {
width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center;
justify-content: center; font-size: 12px; font-weight: 700;
color: #909399; background: #f0f0f0; flex-shrink: 0;
}
.rank-idx.top-three { background: var(--primary-color); color: #fff; }
.rank-name { flex: 1; min-width: 0; font-size: 13px; font-weight: 500; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rank-cnt { font-size: 11px; color: #c0c4cc; white-space: nowrap; flex-shrink: 0; }
.rank-expand {
text-align: center; padding: 6px; margin-top: 2px; font-size: 12px;
color: var(--primary-color); cursor: pointer; border-radius: 6px;
transition: background .15s; user-select: none;
}
.rank-expand:hover { background: #ecf5ff; }
.panel-footer {
margin-top: 8px; padding-top: 8px; border-top: 1px solid #f0f0f0;
display: flex; align-items: center; justify-content: space-between;
font-size: 11px; color: #c0c4cc;
}
.footer-time { font-family: monospace; font-size: 10px; }
@media (max-width: 900px) {
.hero-section { padding: 36px 16px 24px; }
.logo-text { font-size: 36px; margin-bottom: 20px; }
.logo-img { max-width: 360px; max-height: 100px; margin-bottom: 20px; }
.rankings-scroll { gap: 12px; }
}
.site-footer {
margin-top: auto;
padding: 20px 16px 32px;
background: #f9fafb;
border-top: 1px solid #ebeef5;
}
.footer-inner {
max-width: 800px;
margin: 0 auto;
font-size: 12px;
line-height: 1.8;
color: #909399;
text-align: center;
white-space: pre-line;
}
.footer-actions {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.footer-disclaimer-btn {
font-size: 12px !important;
color: #909399 !important;
}
.footer-disclaimer-btn:hover {
color: #409eff !important;
}
</style>