Files
CloudSearch/packages/frontend/SearchResult.vue

2210 lines
58 KiB
Vue
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="search-result-page">
<!-- 顶部固定搜索栏 -->
<div class="top-search-bar">
<div class="search-bar-inner">
<router-link to="/" class="logo-link" title="返回首页">
<img v-if="siteLogo" :src="siteLogo" :alt="siteName || '首页'" class="logo-img" @error="(e: any) => e.target.style.display='none'" />
<div v-else-if="siteName" class="logo-text-only">{{ siteName }}</div>
<div v-else class="logo-icon">
<svg viewBox="0 0 28 28" width="28" height="28" fill="none">
<circle cx="14" cy="14" r="13" stroke="var(--primary-color)" stroke-width="2"/>
<path d="M8 14l4 4 8-8" stroke="var(--primary-color)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</router-link>
<div class="search-box-inner">
<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="result-search-btn"> </el-button>
</div>
</div>
<!-- 右上角登录/用户信息独立于搜索栏 -->
<div class="top-right-user">
<template v-if="userInfo">
<span class="user-badge">{{ userInfo.username }}</span>
<el-button size="small" text @click="handleLogout">退出</el-button>
</template>
<el-button v-else size="small" @click="showLogin = true">登录</el-button>
</div>
</div>
<div class="result-content">
<!-- 搜索结果信息栏 -->
<div v-if="intent === 'SEARCH' && !loading" class="result-info-bar">
<div class="info-left">
<span v-if="totalCount > 0" class="info-item info-count">已为您挑选到最符合 {{ totalCount }} 条结果</span>
<span v-if="cloudTypesCount > 0" class="info-item info-type">📂 {{ cloudTypesCount }} 个网盘</span>
<span v-if="filteredCount > 0" class="filter-badge"> 失效 {{ filteredCount }}</span>
<span v-if="skippedCount > 0" class="skip-badge"> 跳过 {{ skippedCount }}</span>
</div>
<div class="info-right">
<span v-if="searchTime > 0" class="info-item info-time"> {{ searchTime }}ms</span>
<span v-if="hasMore" class="info-hasmore">📄 {{ currentPage }} </span>
<button class="refresh-btn" @click="handleRefresh" title="强制刷新">🔄 刷新</button>
</div>
</div>
<!-- 意图标签 -->
<!-- 搜索进度条 -->
<div v-if="loading" class="loading-section">
<div class="progress-track">
<div class="progress-bar" :style="{ width: loadingProgress + '%' }"></div>
</div>
<div class="progress-label">
<span v-if="loadingPhase === 'search'">🔍 正在搜索中...</span>
<span v-else-if="loadingPhase === 'validate'">
正在验证链接有效性
<span v-if="validationTotal > 0" class="validate-count">
({{ validationDone }} / {{ validationTotal }})
</span>
</span>
<span v-else> 加载中...</span>
<span class="progress-time"> {{ searchTime }}ms</span>
</div>
<el-skeleton :rows="3" animated class="loading-skeleton" />
</div>
<!-- 网盘分类标签栏 仅显示有结果的类型 -->
<div v-if="intent === 'SEARCH' && visibleTabs.length > 0 && !loading" class="cloud-tabs">
<div
v-for="tab in visibleTabs"
:key="tab.type || 'all'"
class="cloud-tab"
:class="{ active: activeCloudTab === (tab.type || '') }"
@click="activeCloudTab = tab.type || ''"
>
<span v-if="tab.icon" class="tab-icon">{{ tab.icon }}</span>
{{ tab.label }}
<span v-if="tab.count > 0" class="tab-count">{{ tab.count }}</span>
</div>
</div>
<!-- 内容信息来自TMDB全面展示 -->
<div v-if="!loading && (contentInfo || contentTags.length > 0) && intent === 'SEARCH'" class="content-info-section">
<a
v-if="contentInfo?.tmdb_url"
:href="contentInfo.tmdb_url"
target="_blank"
class="media-card media-card-link"
rel="noopener"
>
<div class="media-poster-wrap">
<img v-if="contentInfo?.cover && !coverError" :src="contentInfo.cover" class="media-poster" @error="coverError = true" />
<div v-else class="media-poster-placeholder">🎬</div>
<span v-if="contentInfo?.rating" class="media-rating-badge"> {{ contentInfo.rating }}</span>
</div>
<div class="media-body">
<div class="media-title">{{ contentInfo?.title || query }}<span v-if="contentInfo?.year" class="media-year">&nbsp;({{ contentInfo.year }})</span></div>
<!-- 评分和来源 -->
<div class="media-meta-row">
<span v-if="contentInfo?.rating" class="media-meta-rating">
<strong>{{ contentInfo.rating }}</strong>
<span v-if="contentInfo?.rating_count" class="rating-count">/10 · {{ contentInfo.rating_count }}人评价</span>
</span>
<span class="media-source-tag">{{ contentInfo?.source === 'tmdb' ? 'TMDB' : '缓存' }}</span>
</div>
<!-- 导演 -->
<div v-if="contentInfo?.directors" class="media-meta-line">
<span class="meta-label">导演</span>
<span class="meta-value">{{ contentInfo.directors }}</span>
</div>
<!-- 演员 -->
<div v-if="contentInfo?.actors" class="media-meta-line">
<span class="meta-label">演员</span>
<span class="meta-value meta-actors">{{ contentInfo.actors }}</span>
</div>
<!-- 类型/地区/片长 -->
<div v-if="contentInfo?.genres?.length || contentInfo?.region || contentInfo?.duration" class="media-meta-line">
<span class="meta-label">类型</span>
<span class="meta-value">
<span v-if="contentInfo?.genres?.length" class="meta-genres">
<span v-for="(g, gi) in contentInfo.genres" :key="gi" class="meta-genre-tag">{{ g }}</span>
</span>
<span v-if="contentInfo?.region" class="meta-region"> / {{ contentInfo.region }}</span>
<span v-if="contentInfo?.duration" class="meta-duration"> / {{ contentInfo.duration }}</span>
</span>
</div>
<!-- 描述 -->
<div v-if="contentInfo?.description" class="media-subtitle">{{ contentInfo.description }}</div>
<!-- 标签 -->
<div v-if="contentTags.length > 0" class="media-tags">
<span v-for="tag in contentTags" :key="tag" class="media-tag" :class="'media-tag-' + mediaTagColor(tag)">{{ tag }}</span>
</div>
<div class="media-hint">查看TMDB详情 </div>
</div>
</a>
<div v-else class="media-card">
<div class="media-poster-wrap">
<img v-if="contentInfo?.cover && !coverError" :src="contentInfo.cover" class="media-poster" @error="coverError = true" />
<div v-else class="media-poster-placeholder">🎬</div>
<span v-if="contentInfo?.rating" class="media-rating-badge"> {{ contentInfo.rating }}</span>
<span class="media-source-badge">{{ contentInfo?.source === 'tmdb' ? 'TMDB' : '缓存' }}</span>
</div>
<div class="media-body">
<div class="media-title">{{ contentInfo?.title || query }}<span v-if="contentInfo?.year">&nbsp;({{ contentInfo.year }})</span></div>
<div v-if="contentInfo?.rating" class="media-meta-row">
<span class="media-meta-rating"> <strong>{{ contentInfo.rating }}</strong><span v-if="contentInfo?.rating_count" class="rating-count">/10 · {{ contentInfo.rating_count }}人评价</span></span>
</div>
<div v-if="contentInfo?.directors" class="media-meta-line">
<span class="meta-label">导演</span>
<span class="meta-value">{{ contentInfo.directors }}</span>
</div>
<div v-if="contentInfo?.actors" class="media-meta-line">
<span class="meta-label">演员</span>
<span class="meta-value meta-actors">{{ contentInfo.actors }}</span>
</div>
<div v-if="contentInfo?.genres?.length || contentInfo?.region || contentInfo?.duration" class="media-meta-line">
<span class="meta-label">类型</span>
<span class="meta-value">
<span v-if="contentInfo?.genres?.length" class="meta-genres">
<span v-for="(g, gi) in contentInfo.genres" :key="gi" class="meta-genre-tag">{{ g }}</span>
</span>
<span v-if="contentInfo?.region"> / {{ contentInfo.region }}</span>
<span v-if="contentInfo?.duration"> / {{ contentInfo.duration }}</span>
</span>
</div>
<div v-if="contentInfo?.description" class="media-subtitle">{{ contentInfo.description }}</div>
<div v-if="contentTags.length > 0" class="media-tags">
<span v-for="tag in contentTags" :key="tag" class="media-tag" :class="'media-tag-' + mediaTagColor(tag)">{{ tag }}</span>
</div>
</div>
</div>
</div>
<!-- 结果展示 -->
<template v-if="!loading && intent === 'SEARCH'">
<!-- 全部标签按时间分组 + 卡片网格 -->
<div v-if="!activeCloudTab && displayedResults.length > 0" class="result-list flat-list">
<template v-for="(item, ri) in displayedResults" :key="'flat-' + ri">
<ResultCard
:data="item"
:fallbackTags="contentTags"
:fallbackImage="fallbackImage"
@save="handleSave"
/>
</template>
<!-- 页面内加载更多 -->
<div v-if="hasMoreLocal" class="load-more-inline">
<el-button @click="loadMoreLocal" :loading="loadingMore" class="load-more-btn">
加载更多 (已显示 {{ visibleCount }} / {{ allResultsFlat.length }})
</el-button>
</div>
</div>
<!-- 具体网盘标签频道分组展示 -->
<div v-else-if="activeCloudTab && filteredChannels.length > 0" class="result-list channel-list">
<div
v-for="(channel, ci) in filteredChannels"
:key="'ch-' + channel.cloud_type"
class="channel-section"
>
<div class="channel-header">
<span class="channel-icon">{{ channelIcon(channel.cloud_type) }}</span>
<span class="channel-label">{{ channel.label }}</span>
<span class="channel-total-badge">{{ channel.count }} 条资源</span>
<span v-if="channel.newestTime" class="channel-time">🕐 {{ channel.newestTime }}</span>
</div>
<ResultCard
v-for="(item, ri) in channelVisibleItems(channel)"
:key="'ch-' + ci + '-' + ri"
:data="item"
:fallbackTags="contentTags"
:fallbackImage="fallbackImage"
@save="handleSave"
/>
<div v-if="channelHasMore(channel)" class="channel-load-more" @click="channelLoadMore(channel.cloud_type)">
<span class="channel-load-more-text">
展开更多 (已显示 {{ channelVisibleItems(channel).length }} / {{ channel.count }})
</span>
</div>
</div>
</div>
<!-- 点击分类标签后无匹配结果 -->
<div v-else-if="totalCount > 0 && activeCloudTab" class="no-match-tip">
<span>当前页暂无{{ getActiveTabLabel() }}资源</span>
<el-button size="small" @click="loadMore" :loading="loadingMore" v-if="hasMore">
加载更多试试
</el-button>
</div>
</template>
<!-- 视频解析结果 -->
<div v-else-if="!loading && intent === 'VIDEO_PARSE'" class="result-list">
<VideoResultCard
v-for="(item, index) in videoResults"
:key="index"
:data="item"
@save="handleVideoSave"
/>
</div>
<!-- 空状态 -->
<el-empty v-if="!loading && !loadingMore && totalCount === 0 && results.length === 0" description="未找到相关资源" />
<!-- 加载更多 -->
<div v-if="hasMore && intent === 'SEARCH' && !loading" class="load-more">
<el-button :loading="loadingMore" @click="loadMore">加载更多 ({{ currentPage }}/{{ totalPages }})</el-button>
</div>
</div>
<!-- 保存结果弹窗 -->
<el-dialog v-model="resultDialogVisible" width="650px" :close-on-click-modal="false" class="save-dialog">
<template #header>
<strong class="dialog-title-bold">{{ dialogTitle }}</strong>
</template>
<div class="result-dialog-content">
<!-- 进度流程 -->
<div v-if="saving" class="progress-flow">
<div class="progress-step" :class="{ active: progressStep >= 1, done: progressStep > 1 }">
<div class="step-dot">
<span v-if="progressStep > 1" class="step-check"></span>
<span v-else class="step-num">1</span>
</div>
<div class="step-body">
<span class="step-title">正在转存到{{ diskLabel }}...</span>
<span v-if="progressStep === 1" class="step-status loading">进行中</span>
<span v-else class="step-status done">已完成</span>
</div>
</div>
<div class="progress-step" :class="{ active: progressStep >= 2, done: progressStep > 2 }">
<div class="step-dot">
<span v-if="progressStep > 2" class="step-check"></span>
<span v-else class="step-num">2</span>
</div>
<div class="step-body">
<span class="step-title">正在重命名文件防和谐...</span>
<span v-if="progressStep === 2" class="step-status loading">进行中</span>
<span v-else-if="progressStep > 2" class="step-status done">已完成</span>
<span v-else class="step-status pending">等待中</span>
</div>
</div>
<div class="progress-step" :class="{ active: progressStep >= 3, done: progressStep > 3 }">
<div class="step-dot">
<span v-if="progressStep > 3" class="step-check"></span>
<span v-else class="step-num">3</span>
</div>
<div class="step-body">
<span class="step-title">正在生成分享链接...</span>
<span v-if="progressStep === 3" class="step-status loading">进行中</span>
<span v-else-if="progressStep > 3" class="step-status done">已完成</span>
<span v-else class="step-status pending">等待中</span>
</div>
</div>
</div>
<!-- 保存失败 -->
<div v-else-if="!saveSuccess" class="save-error">
<el-alert
type="error"
:title="saveResult?.message || (saveResult as any)?.error || '保存失败'"
show-icon
:closable="false"
/>
</div>
<!-- 重命名信息完成时显示 -->
<div v-if="saveSuccess && renamedFiles.length > 0 && shareLink" class="rename-info-bar">
<el-alert type="warning" :closable="false" show-icon>
<template #title>
<span style="font-size:13px">已对 {{ renamedFiles.length }} 个文件执行防和谐重命名</span>
</template>
<div v-for="r in renamedFiles" :key="r" class="rename-item">{{ r }}</div>
</el-alert>
</div>
<!-- 成功结果展示 -->
<div v-if="saveSuccess && shareLink" class="share-result">
<div class="share-layout">
<div class="qr-left">
<canvas ref="qrCanvasRef" class="qr-canvas"></canvas>
<p class="qr-hint">{{ diskLabel }}APP扫码转存</p>
<p class="qr-subhint">保存到你自己网盘</p>
<div class="qr-disclaimer-short">
<span> 本站资源仅供学习交流请于24h内删除</span>
</div>
</div>
<div class="link-right">
<div class="success-header">
<el-icon class="success-icon" :size="20" color="#67c23a"><CircleCheckFilled /></el-icon>
<span class="success-text">{{ diskLabel }}<strong>分享链接已生成</strong></span>
</div>
<div class="link-row">
<el-input v-model="shareLink" readonly class="share-input" />
</div>
<div v-if="sharePwd" class="share-pwd-row">
<span class="pwd-label">🔑 提取密码</span>
<el-tag type="warning">{{ sharePwd }}</el-tag>
<span class="pwd-hint">打开链接后需输入密码</span>
</div>
<div class="share-tip">
<span class="share-tip-warn"></span>
<div class="share-tip-text">
<strong>请尽快复制链接到浏览器打开</strong> <strong>{{ diskLabel }}APP扫码</strong><br>
<strong>转存至您的网盘以免资源被官方和谐</strong>
</div>
</div>
<!-- 郑重警告 -->
<div class="warnings-box">
<p class="warning-item">郑重警告一网盘内除您所需资源外不要打开任何不相关内容</p>
<p class="warning-item">郑重警告二网盘内除您所需资源外不要打开任何不相关内容</p>
<p class="warning-item">郑重警告三网盘内除您所需资源外不要打开任何不相关内容</p>
<p class="warning-item">郑重警告四以上警告说三遍你还要明知故犯吗</p>
</div>
<!-- 底部操作 -->
<div class="dialog-actions">
<el-button class="disclaimer-btn" @click="openDisclaimer">📜 免责声明</el-button>
<el-button @click="resultDialogVisible = false">关闭</el-button>
<el-button type="primary" @click="copyShareLink">一键复制链接</el-button>
</div>
</div>
</div>
</div>
</div>
</el-dialog>
</div>
<!-- 登录弹窗 -->
<el-dialog v-model="showLogin" title="登录" width="380px" :close-on-click-modal="false" top="25vh">
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" label-width="0" @keyup.enter="handleLogin">
<el-form-item prop="username">
<el-input v-model="loginForm.username" placeholder="用户名" prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="loginForm.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loginLoading" style="width: 100%" @click="handleLogin">登录</el-button>
</el-form-item>
</el-form>
<p v-if="loginError" class="login-error">{{ loginError }}</p>
</el-dialog>
<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>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Search, Loading, CircleCheckFilled } from '@element-plus/icons-vue'
import QRCode from 'qrcode'
import ResultCard from '../components/ResultCard.vue'
import VideoResultCard from '../components/VideoResultCard.vue'
import { query as searchQuery, saveToCloud, saveVideoToCloud, getSystemConfigs, streamSearch, adminLogin, getMe } from '../api'
import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus'
import type { SearchResult, VideoParseResult, SaveResult, IntentType, CloudType, ChannelGroup } from '../types'
import { CLOUD_LABELS, CLOUD_COLORS } from '../types'
const route = useRoute()
const router = useRouter()
const query = ref('')
const loading = ref(false)
const loadingMore = ref(false)
const intent = ref<IntentType | null>(null)
const searchResults = ref<SearchResult[]>([])
const videoResults = ref<VideoParseResult[]>([])
const results = ref<any[]>([])
const channels = ref<ChannelGroup[]>([])
const filteredCount = ref(0)
const skippedCount = ref(0)
const hasMore = ref(false)
const currentPage = ref(1)
const totalCount = ref(0)
const searchTime = ref(0)
const activeCloudTab = ref('')
const loadingProgress = ref(0)
const loadingPhase = ref<'search' | 'validate' | 'done'>('search')
const validationTotal = ref(0)
const validationDone = ref(0)
const contentInfo = ref<any>(null)
const contentTags = ref<string[]>([])
const fallbackImage = ref('')
const siteLogo = ref('')
const siteName = ref('')
const siteDisclaimer = ref('')
const coverError = ref(false)
// 登录状态
const userInfo = ref<{ username: string } | null>(null)
const showLogin = ref(false)
const loginFormRef = ref<FormInstance>()
const loginForm = reactive({ username: '', password: '' })
const loginLoading = ref(false)
const loginError = ref('')
const loginRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
const allResultsMap = ref(new Map<string, any>())
const validResults = ref<any[]>([])
// 页面内分批加载
const FLAT_PAGE_SIZE = 30
const CHANNEL_PAGE_SIZE = 20
const visibleCount = ref(FLAT_PAGE_SIZE)
// 每个频道展示的条目数上限
const channelLimits = ref<Record<string, number>>({})
// 网盘图标映射
const CLOUD_ICONS: Record<string, string> = {
quark: '☁️',
baidu: '🔵',
aliyun: '🟠',
'115': '🟣',
tianyi: '🔷',
'123pan': '🔴',
uc: '🟡',
xunlei: '🟢',
pikpak: '🟤',
magnet: '🧲',
ed2k: '🔗',
others: '📁',
}
onMounted(async () => {
const q = (route.query.q as string) || ''
if (q) {
query.value = q
doSearch(q)
}
// 检查登录状态
const me: any = await getMe().catch(() => ({ loggedIn: false }))
if (me.loggedIn && me.username) {
userInfo.value = { username: me.username }
}
})
async function handleLogin() {
const valid = await loginFormRef.value?.validate().catch(() => false)
if (!valid) return
loginLoading.value = true
loginError.value = ''
try {
const res = await adminLogin(loginForm.username, loginForm.password)
localStorage.setItem('admin_token', res.token)
userInfo.value = { username: loginForm.username }
showLogin.value = false
loginForm.password = ''
ElMessage.success('登录成功')
} catch (e: any) {
loginError.value = e?.response?.data?.error || e?.message || '登录失败'
} finally {
loginLoading.value = false
}
}
function handleLogout() {
localStorage.removeItem('admin_token')
userInfo.value = null
ElMessage.success('已退出')
}
// 网盘分类标签 — 始终展示所有已知云盘类型0 也显示)
const cloudTabs = computed(() => {
const counts: Record<string, number> = {}
for (const r of searchResults.value) {
const ct = r.cloud_type || 'others'
counts[ct] = (counts[ct] || 0) + 1
}
const list: { type: string; label: string; count: number; icon: string }[] = []
list.push({ type: '', label: '全部', count: searchResults.value.length, icon: '📋' })
const cloudOrder: Record<string, number> = {
quark: 1, baidu: 2, aliyun: 3, '115': 4,
tianyi: 5, '123pan': 6, uc: 7, xunlei: 8,
pikpak: 9, magnet: 10, ed2k: 11, others: 12,
}
const allTypes = Object.keys(CLOUD_LABELS) as CloudType[]
const sorted = allTypes.sort((a, b) => (cloudOrder[a] ?? 99) - (cloudOrder[b] ?? 99))
for (const type of sorted) {
list.push({
type,
label: CLOUD_LABELS[type],
count: counts[type] || 0,
icon: CLOUD_ICONS[type] || '📁',
})
}
return list
})
// 只显示有结果的标签(隐藏 0 计数的标签)
const visibleTabs = computed(() => {
return cloudTabs.value.filter(t => t.count > 0)
})
function getActiveTabLabel(): string {
const tab = cloudTabs.value.find(t => t.type === activeCloudTab.value)
return tab?.label || activeCloudTab.value || ''
}
// 扁平全部结果(按时间排序 + 页面内分批)
const allResultsFlat = computed(() => {
const all: any[] = []
for (const ch of channels.value) {
all.push(...ch.items)
}
// 按 update_time 降序(最新优先)
return all.sort((a, b) => {
const ta = a.update_time || a.datetime || ''
const tb = b.update_time || b.datetime || ''
if (!ta && !tb) return 0
if (!ta) return 1
if (!tb) return -1
return tb.localeCompare(ta)
})
})
// 分页显示的切片
const displayedResults = computed(() => {
return allResultsFlat.value.slice(0, visibleCount.value)
})
// 是否还有更多本地数据可加载
const hasMoreLocal = computed(() => {
return visibleCount.value < allResultsFlat.value.length
})
// 未显示的数量
const remainingCount = computed(() => {
return allResultsFlat.value.length - visibleCount.value
})
// 加载更多本地数据
function loadMoreLocal() {
visibleCount.value += FLAT_PAGE_SIZE
}
// 获取频道可见条目数
function channelVisibleItems(channel: any): any[] {
const max = channelLimits.value[channel.cloud_type] || CHANNEL_PAGE_SIZE
return (channel.items || []).slice(0, max)
}
// 频道是否还有更多
function channelHasMore(channel: any): boolean {
const max = channelLimits.value[channel.cloud_type] || CHANNEL_PAGE_SIZE
return (channel.items || []).length > max
}
// 频道展开加载更多
function channelLoadMore(cloudType: string) {
channelLimits.value = {
...channelLimits.value,
[cloudType]: (channelLimits.value[cloudType] || CHANNEL_PAGE_SIZE) + CHANNEL_PAGE_SIZE
}
}
// 重置频道限制
function resetChannelLimits() {
channelLimits.value = {}
}
// 根据当前激活的 cloud tab 过滤 channels
const filteredChannels = computed<ChannelGroup[]>(() => {
let list = channels.value
if (activeCloudTab.value) {
list = list.filter(ch => ch.cloud_type === activeCloudTab.value)
}
return list
})
// 总页数
const totalPages = computed(() => {
if (totalCount.value <= 0) return 1
return Math.ceil(totalCount.value / 20)
})
// 包含结果的网盘类型数
const cloudTypesCount = computed(() => {
return cloudTabs.value.filter(t => t.type !== '' && t.count > 0).length
})
// 强制刷新搜索
function handleRefresh() {
if (query.value.trim()) {
doSearch(query.value.trim())
}
}
// 模拟进度条(实时更新时间)
function startProgressSimulation() {
loadingProgress.value = 0
loadingPhase.value = 'search'
const startTime = Date.now()
searchTime.value = 0
const interval = setInterval(() => {
if (!loading.value) {
loadingProgress.value = 100
clearInterval(interval)
return
}
// 实时更新时间
searchTime.value = Date.now() - startTime
if (loadingProgress.value < 60) {
loadingProgress.value += 1 + Math.random() * 3
} else if (loadingProgress.value < 85) {
loadingPhase.value = 'validate'
loadingProgress.value += 0.5 + Math.random() * 1
} else if (loadingProgress.value < 98) {
loadingProgress.value += 0.2 + Math.random() * 0.5
}
}, 200)
return interval
}
// 计算频道最新时间
function getChannelNewestTime(items: any[]): string {
const times = items
.map(i => i.update_time || i.datetime || '')
.filter(Boolean)
.sort()
.reverse()
if (times.length === 0) return ''
return formatRelativeTime(times[0])
}
// 媒体标签颜色
function mediaTagColor(tag: string): string {
const genre = ['动画', '动漫', '国漫', '奇幻', '玄幻', '仙侠', '动作', '剧情', '喜剧', '悬疑', '科幻', '冒险', '爱情', '古装']
if (genre.includes(tag)) return 'genre'
const type = ['电影', '剧集', '电视剧', '纪录片', '短片', '真人秀', '综艺']
if (type.includes(tag)) return 'type'
const quality = ['剧场版', '年番', '总集篇', 'OVA', 'SP']
if (quality.includes(tag)) return 'quality'
return 'default'
}
function formatRelativeTime(dateStr: string): string {
if (!dateStr) return ''
const now = Date.now()
const date = new Date(dateStr)
if (isNaN(date.getTime())) return dateStr.slice(0, 10)
const diff = now - date.getTime()
if (diff < 0) return dateStr.slice(0, 10)
const mins = Math.floor(diff / 60000)
if (mins < 60) return mins <= 1 ? '刚刚' : `${mins} 分钟前`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
if (days < 30) return `${days} 天前`
const months = Math.floor(days / 30)
return `${months} 个月前`
}
// 频道图标
function channelIcon(ct: string): string {
const icons: Record<string, string> = {
quark: '☁️', baidu: '🔵', aliyun: '🟠', '115': '🟣',
tianyi: '🔷', '123pan': '🔴', uc: '🟡', xunlei: '🟢',
pikpak: '🟤', magnet: '🧲', ed2k: '🔗', others: '📁',
}
return icons[ct] || '📁'
}
watch(
() => route.query.q,
(newQ) => {
if (newQ && newQ !== query.value) {
query.value = newQ as string
doSearch(newQ as string)
}
}
)
const saving = ref(false)
const currentSaveItem = ref<any>(null)
const resultDialogVisible = ref(false)
const saveSuccess = ref(false)
const saveResult = ref<SaveResult | null>(null)
const shareLink = ref('')
const sharePwd = ref('')
const renamedFiles = ref<string[]>([])
const qrCanvasRef = ref<HTMLCanvasElement | null>(null)
const progressStep = ref(0)
// 当前保存的网盘名称(如"夸克网盘"、"百度网盘"
const diskLabel = computed(() => {
const ct = currentSaveItem.value?.cloud_type || 'quark'
return (CLOUD_LABELS as any)[ct] || '夸克网盘'
})
// 弹窗标题
const dialogTitle = computed(() => {
const title = currentSaveItem.value?.title || ''
// 取干净标题(去掉【】内容)
const clean = title.replace(/【[^】]+】/g, '').trim()
return clean || title || '资源'
})
async function doSearch(q: string) {
loading.value = true
const startTime = Date.now()
visibleCount.value = FLAT_PAGE_SIZE
currentPage.value = 1
searchResults.value = []
videoResults.value = []
results.value = []
channels.value = []
validResults.value = []
allResultsMap.value = new Map()
filteredCount.value = 0
skippedCount.value = 0
hasMore.value = false
activeCloudTab.value = ''
searchTime.value = 0
coverError.value = false
resetChannelLimits()
const progressTimer = startProgressSimulation()
try {
intent.value = 'SEARCH'
let totalValidated = 0
let totalFiltered = 0
const validatedMap = new Map<string, boolean>()
let validationComplete = false
await streamSearch(q, {
onSearching: () => {
searchTime.value = Date.now() - startTime
loadingPhase.value = 'search'
},
onSaved: (data) => {
searchTime.value = Date.now() - startTime
// Add DB cached results to display immediately
const saved = (data.results || []).map((item: any) => ({
...item,
id: item.id || ('saved_' + Math.random()),
}))
// Add to the map so validation doesn't re-add them
for (const item of saved) {
allResultsMap.value.set(item.id, item)
}
validResults.value.push(...saved)
searchResults.value = [...validResults.value]
channels.value = buildChannelsFromResults(searchResults.value).map((ch: any) => ({
...ch,
newestTime: getChannelNewestTime(ch.items),
}))
totalCount.value = searchResults.value.length
loadingPhase.value = 'validate'
},
onStats: (stats) => {
searchTime.value = Date.now() - startTime
totalCount.value = stats.total
contentInfo.value = stats.content_info || null
contentTags.value = stats.content_tags || []
if (stats.fallback_image) {
fallbackImage.value = stats.fallback_image
}
if (stats.site_logo) {
siteLogo.value = stats.site_logo
}
if (stats.site_name) {
siteName.value = stats.site_name
}
if (stats.site_disclaimer) {
siteDisclaimer.value = stats.site_disclaimer
}
loadingPhase.value = 'validate'
// 存储所有结果,后续按验证结果逐步展示
if (stats.channels) {
const map = new Map<string, any>()
const all: any[] = []
for (const ch of stats.channels) {
for (const item of (ch.items || [])) {
map.set(item.id, item)
all.push(item)
}
}
allResultsMap.value = map
results.value = all
}
if (stats.link_validation) {
// Now we validate ALL items, so validation total = total count
validationTotal.value = stats.total
}
},
onResult: (id, valid) => {
totalValidated++
validationDone.value = totalValidated
searchTime.value = Date.now() - startTime
// 验证通过就加入结果列表
if (valid) {
const item = allResultsMap.value.get(id)
if (item) {
validResults.value.push(item)
// Incrementally update channels for display
searchResults.value = [...validResults.value]
channels.value = buildChannelsFromResults(searchResults.value).map((ch: any) => ({
...ch,
newestTime: getChannelNewestTime(ch.items),
}))
}
}
},
onComplete: (data) => {
searchTime.value = Date.now() - startTime
// 后端已过滤失效链接,直接使用
const backendResults = (data.results as SearchResult[]) || []
totalCount.value = backendResults.length
filteredCount.value = (data.filtered || 0)
skippedCount.value = (data.skipped || 0)
hasMore.value = false
validationDone.value = validationTotal.value
// Use the complete data's final results
searchResults.value = backendResults
channels.value = (data.channels || []).map((ch: any) => ({
...ch,
newestTime: getChannelNewestTime(ch.items),
}))
// Recalculate channel item lists
const itemsByType: Record<string, any[]> = {}
for (const r of backendResults) {
const ct = r.cloud_type || 'others'
if (!itemsByType[ct]) itemsByType[ct] = []
itemsByType[ct].push(r)
}
channels.value = channels.value.map(ch => ({
...ch,
count: (itemsByType[ch.cloud_type] || []).length,
items: itemsByType[ch.cloud_type] || [],
})).filter(ch => ch.count > 0)
results.value = backendResults
loading.value = false
loadingPhase.value = 'done'
loadingProgress.value = 100
clearInterval(progressTimer)
},
onError: (err) => {
console.error('搜索失败', err)
loading.value = false
loadingPhase.value = 'done'
loadingProgress.value = 100
clearInterval(progressTimer)
},
})
} catch (e) {
console.error('搜索异常', e)
loading.value = false
loadingPhase.value = 'done'
loadingProgress.value = 100
clearInterval(progressTimer)
}
}
function buildChannelsFromResults(results: any[]): ChannelGroup[] {
const groups: Record<string, any[]> = {}
const order: Record<string, number> = {
quark: 1, baidu: 2, aliyun: 3, '115': 4,
tianyi: 5, '123pan': 6, uc: 7, xunlei: 8,
pikpak: 9, magnet: 10, ed2k: 11, others: 12,
}
for (const r of results) {
const ct = r.cloud_type || 'others'
if (!groups[ct]) groups[ct] = []
groups[ct].push(r)
}
return Object.entries(groups)
.sort((a, b) => (order[a[0]] ?? 99) - (order[b[0]] ?? 99))
.map(([cloud_type, items]) => ({
cloud_type,
label: CLOUD_LABELS[cloud_type as CloudType] || cloud_type,
color: CLOUD_COLORS[cloud_type as CloudType] || '#95a5a6',
count: items.length,
items,
newestTime: getChannelNewestTime(items),
}))
}
async function loadMore() {
loadingMore.value = true
currentPage.value++
try {
const res = await searchQuery(query.value, currentPage.value)
const newResults = res.results as SearchResult[]
searchResults.value.push(...newResults)
totalCount.value = res.total // 更新总数
hasMore.value = res.total > searchResults.value.length
filteredCount.value += res.filtered || 0
channels.value = buildChannelsFromResults(searchResults.value)
} catch (e) {
console.error('加载更多失败', e)
} finally {
loadingMore.value = false
}
}
function handleSearch() {
const q = query.value.trim()
if (q) {
router.replace('/search?q=' + encodeURIComponent(q))
doSearch(q)
}
}
async function handleSave(data: SearchResult) {
currentSaveItem.value = data
saving.value = true
resultDialogVisible.value = true
progressStep.value = 1
const cloudType = data.cloud_type || 'quark'
try {
const result: SaveResult = await saveToCloud({
type: 'search',
source: data,
target_cloud: cloudType,
})
saveResult.value = result
saveSuccess.value = result.success
if (result.success) {
// Populate rename info from backend response
if ((result as any).renamed?.length > 0) {
renamedFiles.value = (result as any).renamed
}
// Step 2: 重命名文件(防和谐)
progressStep.value = 2
await new Promise(r => setTimeout(r, 600))
// Step 3: 生成分享链接
progressStep.value = 3
await new Promise(r => setTimeout(r, 400))
if (result.share_url) {
shareLink.value = result.share_url
sharePwd.value = (result as any).share_pwd || (result as any).sharePwd || ''
await new Promise(r => setTimeout(r, 300))
progressStep.value = 4
} else {
progressStep.value = 4
}
}
} catch (e: any) {
saveResult.value = {
success: false,
share_url: '',
file_name: '',
file_size: '',
message: e.message || '保存请求失败',
}
saveSuccess.value = false
} finally {
saving.value = false
}
}
async function handleVideoSave(data: VideoParseResult) {
currentSaveItem.value = data
saving.value = true
resultDialogVisible.value = true
try {
const result = await saveVideoToCloud({
video_url: data.video_url,
title: data.title,
target_cloud: 'quark',
})
saveResult.value = result
saveSuccess.value = result.success
if (result.success && result.share_url) {
shareLink.value = result.share_url
}
} catch (e: any) {
saveResult.value = { success: false, share_url: '', file_name: '', file_size: '', message: e.message || '保存请求失败' }
saveSuccess.value = false
} finally {
saving.value = false
}
}
// 当保存完成且 shareLink 更新时,生成二维码
watch([shareLink, saving], async ([newLink, isSaving]) => {
if (newLink && !isSaving && resultDialogVisible.value) {
await nextTick()
if (qrCanvasRef.value) {
QRCode.toCanvas(qrCanvasRef.value, newLink, { width: 180, margin: 1 })
}
}
})
function copyShareLink() {
if (!shareLink.value) return
const text = shareLink.value
// Try modern clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success('链接已复制')
}).catch(() => {
fallbackCopy(text)
})
} else {
fallbackCopy(text)
}
}
function openDisclaimer() {
window.open('/disclaimer/', '_blank')
}
function fallbackCopy(text: string) {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.top = '-9999px'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
ElMessage.success('链接已复制')
} catch {
ElMessage.warning('复制失败,请手动复制链接')
}
document.body.removeChild(textarea)
}
</script>
<style scoped>
.search-result-page {
min-height: 100vh;
background: var(--bg-color, #f5f5f5);
}
.top-search-bar {
position: sticky;
top: 0;
z-index: 50;
background: #fff;
border-bottom: 1px solid #e8e8e8;
padding: 12px 24px;
}
.search-bar-inner {
max-width: 800px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 12px;
}
.search-box-inner {
display: flex;
align-items: center;
flex: 1;
border: 1px solid #dfe1e5;
border-radius: 24px;
background: #fff;
box-shadow: none;
transition: box-shadow .2s, border-color .2s;
overflow: hidden;
}
.search-box-inner:focus-within {
box-shadow: 0 1px 6px rgba(32,33,36,.28);
border-color: rgba(223,225,229,0);
}
.search-box-inner :deep(.el-input__wrapper) {
border: none; box-shadow: none; background: transparent;
padding: 4px 20px; border-radius: 0;
}
.search-box-inner :deep(.el-input__inner) {
font-size: 15px;
}
/* 右上角用户信息 - 独立于搜索栏 */
.top-right-user {
position: absolute;
top: 12px;
right: 20px;
display: flex;
align-items: center;
gap: 8px;
z-index: 100;
flex-shrink: 0;
}
.user-badge {
font-size: 13px;
color: var(--primary-color, #409eff);
font-weight: 600;
white-space: nowrap;
}
.login-error {
color: #f56c6c;
font-size: 13px;
text-align: center;
margin: 0;
}
.logo-link {
text-decoration: none;
flex-shrink: 0;
}
.logo-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: rgba(64, 158, 255, 0.08);
transition: background 0.2s;
}
.logo-icon:hover {
background: rgba(64, 158, 255, 0.15);
}
.logo-text-only {
font-size: 18px;
font-weight: 700;
color: var(--primary-color);
white-space: nowrap;
flex-shrink: 0;
}
.logo-img {
width: auto;
height: 40px;
max-width: 160px;
object-fit: contain;
flex-shrink: 0;
}
.result-search-btn {
height: 38px !important;
padding: 0 22px !important;
border: none !important;
border-radius: 999px !important;
margin: 4px;
font-size: 14px !important;
font-weight: 600 !important;
letter-spacing: 1px;
flex-shrink: 0;
background: var(--primary-color);
color: #fff;
transition: all .2s;
}
.result-search-btn:hover {
background: #3a7be0;
}
.result-search-btn:active {
background: #2d6ccf;
}
.share-input {
flex: 1;
}
.share-pwd-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.pwd-label {
font-size: 13px;
color: var(--text-secondary, #909399);
}
.pwd-hint {
font-size: 12px;
color: var(--text-secondary, #909399);
}
/* 结果信息栏 */
.result-info-bar {
max-width: 800px;
margin: 16px auto 0;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: #909399;
}
.info-left, .info-right {
display: flex;
align-items: center;
gap: 10px;
}
.info-item {
white-space: nowrap;
}
.info-count {
font-weight: 600;
color: #303133;
}
.info-type {
color: #909399;
font-size: 12px;
background: #f4f4f5;
padding: 1px 7px;
border-radius: 4px;
}
.info-time {
font-family: monospace;
background: #f4f4f5;
padding: 1px 7px;
border-radius: 4px;
color: #909399;
}
.info-hasmore {
color: #c0c4cc;
}
.filter-badge {
background: #fef0f0;
color: #f56c6c;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.skip-badge {
background: #fdf6ec;
color: #e6a23c;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.refresh-btn {
background: none;
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 2px 10px;
font-size: 12px;
color: #909399;
cursor: pointer;
transition: all 0.2s;
}
.refresh-btn:hover {
color: #409eff;
border-color: #409eff;
background: rgba(64,158,255,0.05);
}
/* 意图标签 */
.intent-badge {
max-width: 800px;
margin: 12px auto 0;
padding: 0 24px;
}
.intent-tag {
font-size: 13px;
color: #909399;
background: #f0f2f5;
padding: 2px 10px;
border-radius: 4px;
}
/* 加载进度 */
.loading-section {
max-width: 800px;
margin: 24px auto;
padding: 0 24px;
}
.progress-track {
width: 100%;
height: 4px;
background: #e8e8e8;
border-radius: 2px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #409eff, #67c23a);
border-radius: 2px;
transition: width 0.3s ease;
}
.progress-label {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
font-size: 13px;
color: #909399;
}
.progress-time {
margin-left: auto;
font-family: monospace;
color: #c0c4cc;
}
.validate-count {
font-family: monospace;
color: #409eff;
font-weight: 600;
}
.loading-skeleton {
margin-top: 16px;
}
/* 网盘分类标签栏 */
.cloud-tabs {
max-width: 800px;
margin: 16px auto 0;
padding: 0 24px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cloud-tab {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
color: #606266;
background: #f0f2f5;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.cloud-tab:hover {
background: #e4e7ed;
color: #303133;
}
.cloud-tab.active {
background: rgba(64, 158, 255, 0.1);
color: #409eff;
font-weight: 600;
}
.tab-icon {
font-size: 15px;
line-height: 1;
}
.tab-count {
font-size: 11px;
color: #c0c4cc;
margin-left: 2px;
}
/* 结果列表 */
.result-content {
max-width: 800px;
margin: 0 auto;
padding: 0 24px 48px;
}
/* 宽屏下搜索结果容器加宽以容纳3列 */
@media (min-width: 1280px) {
.result-content {
max-width: 1200px;
}
}
.result-list {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
}
/* 无匹配提示 */
.no-match-tip {
margin-top: 32px;
text-align: center;
color: #909399;
font-size: 14px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
/* 频道分组 */
.channel-section {
background: #fff;
border-radius: 12px;
padding: 14px 16px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
border: 1px solid #ebeef5;
}
.channel-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
.channel-icon {
font-size: 16px;
line-height: 1;
}
.channel-label {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.channel-total-badge {
font-size: 12px;
color: #909399;
background: #f4f4f5;
padding: 1px 8px;
border-radius: 10px;
margin-left: 2px;
}
.channel-time {
margin-left: auto;
font-size: 12px;
color: #b8860b;
white-space: nowrap;
}
/* 全部标签 — 卡片网格 */
.flat-list {
display: grid !important;
grid-template-columns: 1fr;
gap: 12px;
}
/* 响应式:平板 2 列 */
@media (min-width: 768px) {
.flat-list {
grid-template-columns: 1fr 1fr;
}
}
/* 响应式:桌面 3 列 */
@media (min-width: 1280px) {
.flat-list {
grid-template-columns: 1fr 1fr 1fr;
}
}
/* 频道列表保持单列 */
.channel-list {
gap: 12px;
}
.channel-load-more {
margin-top: 8px;
text-align: center;
padding: 8px;
border: 1px dashed #dcdfe6;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
background: #fafafa;
}
.channel-load-more:hover {
background: #f0f2f5;
border-color: #c0c4cc;
}
.channel-load-more-text {
font-size: 13px;
color: #909399;
}
/* 加载更多 */
.load-more {
text-align: center;
margin-top: 24px;
}
/* 内联加载更多("全部"标签的分批加载) */
.load-more-inline {
text-align: center;
margin-top: 8px;
}
.load-more-btn {
width: 100%;
border-radius: 8px;
padding: 10px;
border: 1px dashed #dcdfe6;
background: #fafafa;
color: #909399;
font-size: 13px;
transition: all 0.2s;
}
.load-more-btn:hover {
background: #f0f2f5;
border-color: #c0c4cc;
color: #606266;
}
/* 保存弹窗 */
.save-dialog :deep(.el-dialog__title) {
font-weight: 700;
font-size: 16px;
}
.save-dialog {
width: 650px !important;
}
/* 修复dialog被el-dialog默认样式覆盖 */
.save-dialog :deep(.el-dialog) {
--el-dialog-width: 650px !important;
}
.dialog-title-bold {
font-size: 16px;
font-weight: 700;
color: #303133;
line-height: 1.4;
}
/* ---- 保存弹窗 — 进度流程 ---- */
.result-dialog-content {
min-height: 80px;
}
.progress-flow {
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px 0;
}
.progress-step {
display: flex;
align-items: flex-start;
gap: 12px;
opacity: 0.4;
transition: all 0.3s ease;
}
.progress-step.active {
opacity: 1;
}
.progress-step.done {
opacity: 0.7;
}
.step-dot {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
background: #e4e7ed;
color: #909399;
transition: all 0.3s;
}
.progress-step.active .step-dot {
background: #409eff;
color: #fff;
box-shadow: 0 0 0 4px rgba(64,158,255,0.2);
}
.progress-step.done .step-dot {
background: #67c23a;
color: #fff;
}
.step-check {
font-size: 14px;
}
.step-body {
flex: 1;
padding-top: 3px;
display: flex;
align-items: center;
gap: 10px;
}
.step-title {
font-size: 14px;
color: #303133;
font-weight: 500;
}
.step-status {
font-size: 12px;
padding: 1px 8px;
border-radius: 10px;
white-space: nowrap;
}
.step-status.loading {
background: #ecf5ff;
color: #409eff;
}
.step-status.done {
background: #f0f9eb;
color: #67c23a;
}
.step-status.pending {
background: #f4f4f5;
color: #c0c4cc;
}
/* 保存弹窗 — 分享结果展示(左二维码 + 右链接) */
.share-result {
padding: 8px 0;
}
.share-layout {
display: flex;
gap: 24px;
align-items: stretch;
}
.qr-left {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px;
background: #fafafa;
border-radius: 12px;
border: 1px solid #ebeef5;
align-self: stretch;
}
.qr-canvas {
border-radius: 8px;
}
.qr-hint {
margin: 4px 0 0;
font-size: 12px;
color: #409eff;
font-weight: 600;
}
.qr-subhint {
margin: 0;
font-size: 11px;
color: #c0c4cc;
}
.qr-disclaimer-short {
margin-top: 8px;
padding: 4px 8px;
background: #fff7e6;
border: 1px solid #ffe7ba;
border-radius: 4px;
font-size: 10px;
color: #d46b08;
text-align: center;
line-height: 1.4;
}
.link-right {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.success-header {
display: flex;
align-items: center;
gap: 6px;
}
.success-text {
font-size: 15px;
font-weight: 700;
color: #303133;
}
.link-row {
display: flex;
gap: 8px;
}
.share-input :deep(.el-input__wrapper) {
background: #f5f7fa;
}
.share-tip {
margin: 0;
font-size: 12px;
line-height: 1.5;
background: #fdf6ec;
padding: 8px 10px;
border-radius: 6px;
text-align: left;
display: flex;
gap: 6px;
align-items: flex-start;
}
.share-tip strong {
color: #d46b08;
font-weight: 700;
}
.share-tip-warn {
font-size: 18px;
line-height: 1.5;
flex-shrink: 0;
display: inline-flex;
align-items: flex-start;
padding-top: 1px;
}
.share-tip-text {
flex: 1;
min-width: 0;
line-height: 1.6;
}
/* 底部操作按钮 */
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: auto;
padding-top: 8px;
}
.disclaimer-btn {
margin-right: auto !important;
font-size: 12px !important;
color: #909399 !important;
}
.disclaimer-btn:hover {
color: #409eff !important;
}
/* 郑重警告 */
.warnings-box {
display: flex;
flex-direction: column;
gap: 4px;
background: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 8px;
padding: 8px 10px;
}
.warning-item {
margin: 0;
font-size: 12px;
line-height: 1.8;
font-weight: 700;
white-space: nowrap;
}
.warning-item:nth-child(odd) {
color: #cf1322;
}
.warning-item:nth-child(even) {
color: #d46b08;
}
.warning-item:last-child {
color: #b71c1c;
font-size: 13px;
}
.save-error {
padding: 8px 0;
}
/* 内容信息(豆瓣)全面展示 */
.content-info-section {
max-width: 800px;
margin: 16px auto 0;
padding: 0 24px;
}
.media-card {
display: flex;
gap: 20px;
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
border: 1px solid #ebeef5;
text-decoration: none;
color: inherit;
transition: box-shadow 0.2s;
}
.media-card-link:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
border-color: #c0c4cc;
}
.media-card-link:hover .media-hint {
color: var(--primary-color);
}
.media-hint {
font-size: 12px;
color: #c0c4cc;
margin-top: auto;
transition: color 0.2s;
}
.media-poster-wrap {
position: relative;
flex-shrink: 0;
width: 120px;
height: 170px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.media-poster {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-poster-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea, #764ba2);
font-size: 40px;
color: rgba(255,255,255,0.7);
}
/* 评分 + 来源角标 */
.media-rating-badge {
position: absolute;
bottom: 6px;
left: 6px;
padding: 2px 8px;
background: rgba(0, 0, 0, 0.65);
color: #ffd700;
font-size: 12px;
font-weight: 700;
border-radius: 4px;
backdrop-filter: blur(4px);
line-height: 1.5;
}
.media-source-badge {
position: absolute;
top: 6px;
right: 6px;
padding: 2px 7px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 11px;
font-weight: 600;
border-radius: 4px;
backdrop-filter: blur(4px);
}
.media-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 4px;
}
.media-title {
font-size: 20px;
font-weight: 800;
color: #1a1a2e;
line-height: 1.3;
}
.media-year {
font-size: 16px;
font-weight: 600;
color: #909399;
}
/* 评分行 */
.media-meta-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 2px;
}
.media-meta-rating {
font-size: 14px;
color: #e6a23c;
}
.media-meta-rating strong {
font-size: 16px;
color: #f56c6c;
}
.rating-count {
font-size: 12px;
color: #909399;
font-weight: 400;
}
.media-source-tag {
font-size: 11px;
padding: 1px 8px;
background: #ecf5ff;
color: #409eff;
border-radius: 10px;
font-weight: 500;
}
/* 元信息行:导演 / 演员 / 类型 */
.media-meta-line {
display: flex;
gap: 8px;
font-size: 13px;
line-height: 1.5;
align-items: flex-start;
}
.meta-label {
flex-shrink: 0;
color: #909399;
width: 2.5em;
text-align: right;
}
.meta-value {
color: #606266;
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 2px;
align-items: center;
}
.meta-actors {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.meta-genres {
display: inline-flex;
gap: 4px;
flex-wrap: wrap;
}
.meta-genre-tag {
display: inline-block;
padding: 1px 8px;
background: #fef0f0;
color: #f56c6c;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
}
.meta-region,
.meta-duration {
color: #606266;
font-size: 13px;
}
/* 描述 */
.media-subtitle {
font-size: 13px;
color: #909399;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.media-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: auto;
}
.media-tag {
display: inline-block;
padding: 3px 12px;
border-radius: 14px;
font-size: 12px;
font-weight: 600;
transition: transform 0.15s;
}
.media-tag:hover {
transform: scale(1.05);
}
.media-tag-genre {
background: #f0f9eb;
color: #67c23a;
}
.media-tag-type {
background: #ecf5ff;
color: #409eff;
}
.media-tag-quality {
background: #fdf6ec;
color: #e6a23c;
}
.media-tag-default {
background: #f4f4f5;
color: #909399;
}
/* 重命名信息 */
.rename-info-bar {
margin-bottom: 12px;
}
.rename-item {
font-size: 11px;
color: #909399;
margin-top: 4px;
word-break: break-all;
line-height: 1.4;
}
/* ===== 响应式 ===== */
@media (max-width: 768px) {
.save-dialog {
width: 96vw !important;
}
.save-dialog :deep(.el-dialog) {
--el-dialog-width: 96vw !important;
margin: 5vh auto !important;
}
.save-dialog :deep(.el-dialog__body) {
padding: 12px;
}
.share-layout {
flex-direction: column;
align-items: center;
}
.qr-left {
width: 100%;
align-self: auto;
}
.link-right {
width: 100%;
}
/* 手机版警告区域水平滚动 */
.warnings-box {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
@media (max-width: 640px) {
.search-result-page {
}
.top-search-bar {
flex-direction: column;
gap: 8px;
}
.top-search-bar .search-bar-inner {
width: 100%;
}
.top-search-bar .search-bar-inner .el-input {
min-width: 0;
}
.top-right-user {
position: static;
align-self: flex-end;
}
.result-info-bar {
flex-direction: column;
gap: 6px;
}
.info-left,
.info-right {
flex-wrap: wrap;
}
.cloud-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
gap: 4px;
}
.cloud-tab {
flex-shrink: 0;
font-size: 12px;
padding: 4px 10px;
}
}
.site-footer {
margin-top: 40px;
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>