| <script setup lang="ts"> |
| import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'; |
| import { search, checkAuthStatus, logout, type SearchParams } from '@/api'; |
| import type { SearchResponse, MergedResults } from '@/types'; |
| import SearchForm from '@/components/SearchForm.vue'; |
| import ResultTabs from '@/components/ResultTabs.vue'; |
| import SearchStats from '@/components/SearchStats.vue'; |
| import ApiStatus from '@/components/ApiStatus.vue'; |
| import ApiDocs from '@/components/ApiDocs.vue'; |
| import LoginDialog from '@/components/LoginDialog.vue'; |
|
|
| |
| const loading = ref(false); |
| const searchResults = reactive<{ |
| total: number; |
| mergedResults: MergedResults; |
| }>({ |
| total: 0, |
| mergedResults: {} |
| }); |
|
|
| |
| const searchTime = ref<number | undefined>(undefined); |
|
|
| |
| const isUpdating = ref(false); |
| const updateCount = ref(0); |
| const updateTimer = ref<number | null>(null); |
| const secondSearchTimeout = ref<number | null>(null); |
| const thirdSearchTimeout = ref<number | null>(null); |
| const lastSearchParams = ref<SearchParams | null>(null); |
|
|
| |
| const hasSearched = ref(false); |
| |
| const isActivelySearching = ref(false); |
|
|
| |
| const currentPage = ref<'search' | 'status' | 'docs'>('search'); |
|
|
| |
| const showLogin = ref(false); |
| const isAuthenticated = ref(false); |
| const currentUsername = ref(''); |
|
|
| |
| const switchToStatus = () => { |
| currentPage.value = 'status'; |
| }; |
|
|
| const switchToDocs = () => { |
| currentPage.value = 'docs'; |
| }; |
|
|
|
|
|
|
| |
| const handleSearch = async (params: SearchParams) => { |
| |
| stopUpdate(); |
| |
| |
| hasSearched.value = true; |
| isActivelySearching.value = true; |
| |
| |
| loading.value = true; |
| |
| |
| searchResults.total = 0; |
| searchResults.mergedResults = {}; |
| searchTime.value = undefined; |
| |
| |
| lastSearchParams.value = { ...params }; |
| |
| const startTime = Date.now(); |
| |
| try { |
| |
| const tgParams: SearchParams = { |
| ...params, |
| src: 'tg' |
| }; |
| |
| |
| const allParams: SearchParams = { |
| ...params, |
| src: 'all' |
| }; |
| |
| |
| search(tgParams) |
| .then(tgResponse => { |
| |
| if (tgResponse && tgResponse.total !== undefined) { |
| |
| updateSearchResults(tgResponse); |
| searchTime.value = Date.now() - startTime; |
| |
| loading.value = false; |
| |
| |
| search(allParams) |
| .then(allResponse => { |
| |
| |
| const firstAllSearchCompleteTime = Date.now(); |
| |
| |
| if (allResponse && allResponse.total >= searchResults.total) { |
| updateSearchResults(allResponse); |
| } |
| |
| |
| startSecondAllSearch(firstAllSearchCompleteTime); |
| }) |
| .catch(error => { |
| console.error('第一次ALL搜索出错:', error); |
| |
| |
| startSecondAllSearch(Date.now()); |
| }); |
| } else { |
| console.error('TG搜索结果格式不正确:', tgResponse); |
| loading.value = false; |
| |
| |
| search(allParams) |
| .then(allResponse => { |
| |
| if (allResponse && allResponse.total !== undefined) { |
| updateSearchResults(allResponse); |
| const firstAllSearchCompleteTime = Date.now(); |
| startSecondAllSearch(firstAllSearchCompleteTime); |
| } |
| }) |
| .catch(error => { |
| console.error('第一次ALL搜索出错:', error); |
| isActivelySearching.value = false; |
| }); |
| } |
| }) |
| .catch(error => { |
| console.error('TG搜索出错:', error); |
| loading.value = false; |
| |
| |
| search(allParams) |
| .then(allResponse => { |
| |
| if (allResponse && allResponse.total !== undefined) { |
| updateSearchResults(allResponse); |
| const firstAllSearchCompleteTime = Date.now(); |
| startSecondAllSearch(firstAllSearchCompleteTime); |
| } |
| }) |
| .catch(error => { |
| console.error('第一次ALL搜索出错:', error); |
| isActivelySearching.value = false; |
| }); |
| }); |
| |
| |
| setTimeout(() => { |
| if (loading.value) { |
| loading.value = false; |
| } |
| }, 5000); |
| |
| } catch (error) { |
| console.error('搜索初始化出错:', error); |
| loading.value = false; |
| isActivelySearching.value = false; |
| } |
| }; |
|
|
| |
| const handleSearchComplete = () => { |
| |
| }; |
|
|
| |
| const updateSearchResults = (response: SearchResponse) => { |
| if (!response) return; |
| |
| searchResults.total = response.total || 0; |
| |
| if (response.merged_by_type) { |
| searchResults.mergedResults = { ...response.merged_by_type }; |
| } else { |
| console.warn('搜索结果中没有merged_by_type字段'); |
| searchResults.mergedResults = {}; |
| } |
| }; |
|
|
| |
| const startSecondAllSearch = (firstAllSearchCompleteTime: number) => { |
| if (!lastSearchParams.value) return; |
| |
| isUpdating.value = true; |
| isActivelySearching.value = true; |
| updateCount.value = 1; |
| |
| |
| const allParams: SearchParams = { |
| ...lastSearchParams.value, |
| src: 'all' |
| }; |
| |
| |
| const currentTime = Date.now(); |
| const timeElapsedSinceFirstAllSearch = currentTime - firstAllSearchCompleteTime; |
| const delayForSecondSearch = Math.max(0, 2000 - timeElapsedSinceFirstAllSearch); |
| |
| |
| const executeSecondAllSearch = async () => { |
| if (!lastSearchParams.value) { |
| stopUpdate(); |
| return; |
| } |
| |
| try { |
| const secondAllSearchStartTime = Date.now(); |
| const response = await search(allParams); |
| |
| |
| if (response && response.total >= searchResults.total) { |
| updateSearchResults(response); |
| } |
| |
| |
| const secondAllSearchCompleteTime = Date.now(); |
| |
| |
| startThirdAllSearch(secondAllSearchCompleteTime); |
| } catch (error) { |
| console.error('第二次ALL搜索出错:', error); |
| stopUpdate(); |
| } |
| }; |
| |
| |
| secondSearchTimeout.value = window.setTimeout(executeSecondAllSearch, delayForSecondSearch); |
| }; |
|
|
| |
| const startThirdAllSearch = (secondAllSearchCompleteTime: number) => { |
| if (!lastSearchParams.value) return; |
| |
| updateCount.value = 2; |
| |
| |
| const allParams: SearchParams = { |
| ...lastSearchParams.value, |
| src: 'all' |
| }; |
| |
| |
| const currentTime = Date.now(); |
| const timeElapsedSinceSecondAllSearch = currentTime - secondAllSearchCompleteTime; |
| const delayForThirdSearch = Math.max(0, 3000 - timeElapsedSinceSecondAllSearch); |
| |
| |
| const executeThirdAllSearch = async () => { |
| if (!lastSearchParams.value) { |
| stopUpdate(); |
| return; |
| } |
| |
| try { |
| const response = await search(allParams); |
| |
| |
| if (response && response.total >= searchResults.total) { |
| updateSearchResults(response); |
| } |
| } catch (error) { |
| console.error('第三次ALL搜索出错:', error); |
| } finally { |
| |
| stopUpdate(); |
| } |
| }; |
| |
| |
| thirdSearchTimeout.value = window.setTimeout(executeThirdAllSearch, delayForThirdSearch); |
| }; |
|
|
| |
| const stopUpdate = () => { |
| |
| if (updateTimer.value) { |
| clearInterval(updateTimer.value); |
| updateTimer.value = null; |
| } |
| |
| if (secondSearchTimeout.value) { |
| clearTimeout(secondSearchTimeout.value); |
| secondSearchTimeout.value = null; |
| } |
| |
| if (thirdSearchTimeout.value) { |
| clearTimeout(thirdSearchTimeout.value); |
| thirdSearchTimeout.value = null; |
| } |
| |
| |
| isUpdating.value = false; |
| isActivelySearching.value = false; |
| }; |
|
|
| |
| const resetToInitial = () => { |
| |
| stopUpdate(); |
| |
| |
| currentPage.value = 'search'; |
| |
| |
| hasSearched.value = false; |
| isActivelySearching.value = false; |
| loading.value = false; |
| searchResults.total = 0; |
| searchResults.mergedResults = {}; |
| searchTime.value = undefined; |
| isUpdating.value = false; |
| updateCount.value = 0; |
| }; |
|
|
| |
| const checkAuth = async () => { |
| const status = await checkAuthStatus(); |
| if (status.enabled && !status.authenticated) { |
| showLogin.value = true; |
| isAuthenticated.value = false; |
| } else if (status.enabled && status.authenticated) { |
| isAuthenticated.value = true; |
| currentUsername.value = localStorage.getItem('auth_username') || ''; |
| } |
| }; |
|
|
| |
| const handleAuthRequired = () => { |
| showLogin.value = true; |
| }; |
|
|
| |
| const handleLoginSuccess = () => { |
| window.location.reload(); |
| }; |
|
|
| |
| const handleLogout = async () => { |
| if (confirm('确定要退出登录吗?')) { |
| await logout(); |
| window.location.reload(); |
| } |
| }; |
|
|
| |
| onMounted(() => { |
| checkAuth(); |
| window.addEventListener('auth:required', handleAuthRequired); |
| }); |
|
|
| onUnmounted(() => { |
| |
| stopUpdate(); |
| window.removeEventListener('auth:required', handleAuthRequired); |
| }); |
| </script> |
|
|
| <template> |
| <div class="min-h-screen bg-background text-foreground transition-colors duration-300 flex flex-col"> |
| |
| <LoginDialog |
| v-model:visible="showLogin" |
| @success="handleLoginSuccess" |
| /> |
| |
| |
| <div class="bg-decorative"></div> |
| |
| |
| <nav class="nav-header backdrop-blur-md bg-background/80 border-b border-border"> |
| <div class="container mx-auto px-4 h-16 flex items-center justify-between"> |
| <div class="flex items-center gap-3 cursor-pointer" @click="resetToInitial"> |
| <div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center"> |
| <svg class="w-5 h-5 text-primary-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path> |
| </svg> |
| </div> |
| <div> |
| <h1 class="text-xl font-bold">PanSou</h1> |
| </div> |
| </div> |
| |
| |
| <nav class="flex items-center gap-2" v-if="currentPage === 'search'"> |
| <button |
| @click="switchToStatus" |
| class="nav-button" |
| > |
| <span class="nav-icon">📊</span> |
| 状态 |
| </button> |
| <button |
| @click="switchToDocs" |
| class="nav-button" |
| > |
| <span class="nav-icon">📖</span> |
| API文档 |
| </button> |
| <button |
| v-if="isAuthenticated" |
| @click="handleLogout" |
| class="nav-button logout-button" |
| :title="'当前用户: ' + currentUsername" |
| > |
| <span class="nav-icon">🚪</span> |
| 退出 |
| </button> |
| </nav> |
| </div> |
| </nav> |
| |
| |
| <main class="container mx-auto px-4 py-8 flex-1"> |
| |
| <div v-if="currentPage === 'search'" class="search-page"> |
| |
| <div class="mb-6"> |
| <SearchForm |
| @search="handleSearch" |
| @search-complete="handleSearchComplete" |
| /> |
| </div> |
| |
| |
| <div v-if="hasSearched || loading" class="mb-6"> |
| <SearchStats |
| :total="searchResults.total || 0" |
| :mergedResults="searchResults.mergedResults || {}" |
| :loading="loading" |
| :searchTime="searchTime" |
| :isUpdating="isUpdating" |
| :updateCount="updateCount" |
| /> |
| </div> |
| |
| |
| <div v-if="loading" class="card p-6"> |
| <div class="space-y-3"> |
| <div class="h-4 bg-muted rounded animate-pulse"></div> |
| <div class="h-4 bg-muted rounded animate-pulse w-3/4"></div> |
| <div class="h-4 bg-muted rounded animate-pulse w-1/2"></div> |
| <div class="h-4 bg-muted rounded animate-pulse w-2/3"></div> |
| <div class="h-4 bg-muted rounded animate-pulse"></div> |
| </div> |
| </div> |
| |
| |
| <div v-else> |
| <ResultTabs |
| :mergedResults="searchResults.mergedResults || {}" |
| :loading="loading" |
| :hasSearched="hasSearched" |
| :isActivelySearching="isActivelySearching" |
| /> |
| </div> |
| </div> |
| |
| |
| <div v-else-if="currentPage === 'status'" class="status-page"> |
| <ApiStatus /> |
| </div> |
| |
| |
| <div v-else-if="currentPage === 'docs'" class="docs-page"> |
| <ApiDocs /> |
| </div> |
| </main> |
| |
| |
| <footer class="border-t border-border bg-background/50 backdrop-blur-sm mt-auto"> |
| <div class="container mx-auto px-4 py-4"> |
| <div class="flex items-center justify-center gap-4 text-sm text-muted-foreground"> |
| <span>© {{ new Date().getFullYear() }}-{{ new Date().getFullYear() + 10 }}</span> |
| <a href="https://dm.xueximeng.com/" target="_blank" rel="noopener noreferrer" class="hover:text-foreground transition-colors">美漫资源共建</a> |
| <a href="https://github.com/fish2018" target="_blank" rel="noopener noreferrer" class="hover:text-foreground transition-colors"> |
| <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"> |
| <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/> |
| </svg> |
| </a> |
| </div> |
| </div> |
| </footer> |
| </div> |
| </template> |
|
|
| <style scoped> |
| .bg-decorative { |
| position: fixed; |
| inset: 0; |
| z-index: -10; |
| background-image: radial-gradient(circle at 1px 1px, hsl(var(--muted-foreground)) 1px, transparent 0); |
| background-size: 20px 20px; |
| opacity: 0.1; |
| } |
| |
| .nav-header { |
| position: sticky; |
| top: 0; |
| z-index: 50; |
| } |
| |
| |
| .nav-button { |
| display: flex; |
| align-items: center; |
| gap: 0.5rem; |
| padding: 0.5rem 1rem; |
| background: transparent; |
| color: hsl(var(--muted-foreground)); |
| border: 1px solid hsl(var(--border)); |
| border-radius: 0.375rem; |
| font-size: 0.875rem; |
| font-weight: 500; |
| cursor: pointer; |
| transition: all 0.2s ease; |
| } |
| |
| .nav-button:hover { |
| background: hsl(var(--accent)); |
| color: hsl(var(--accent-foreground)); |
| border-color: hsl(var(--accent)); |
| } |
| |
| .logout-button { |
| border-color: hsl(0, 84%, 60%); |
| color: hsl(0, 84%, 60%); |
| } |
| |
| .logout-button:hover { |
| background: hsl(0, 84%, 95%); |
| border-color: hsl(0, 84%, 60%); |
| color: hsl(0, 84%, 50%); |
| } |
| |
| @media (prefers-color-scheme: dark) { |
| .logout-button:hover { |
| background: hsl(0, 84%, 20%); |
| color: hsl(0, 84%, 90%); |
| } |
| } |
| |
| |
| |
| .nav-icon { |
| font-size: 1rem; |
| } |
| |
| |
| .search-page, .status-page { |
| animation: fadeIn 0.3s ease-in-out; |
| } |
| |
| @keyframes fadeIn { |
| from { |
| opacity: 0; |
| transform: translateY(10px); |
| } |
| to { |
| opacity: 1; |
| transform: translateY(0); |
| } |
| } |
| |
| @media (max-width: 768px) { |
| .container { |
| padding-left: 1rem; |
| padding-right: 1rem; |
| } |
| |
| .nav-button { |
| padding: 0.375rem 0.75rem; |
| font-size: 0.8rem; |
| } |
| |
| .nav-icon { |
| font-size: 0.875rem; |
| } |
| } |
| |
| |
| footer button { |
| background: transparent; |
| border: none; |
| padding: 0; |
| font-size: inherit; |
| color: inherit; |
| cursor: pointer; |
| } |
| </style> |
|
|