Files
CloudSearch/source_clean/frontend-src/src/pages/SearchResult.vue
2026-05-18 05:11:57 +08:00

2145 lines
56 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>
<!-- 滚动通知条跑马灯 -->
<div v-if="siteMarquee" class="marquee-bar">
<span class="marquee-icon marquee-icon-left">📢</span>
<div class="marquee-track">
<span class="marquee-text">{{ siteMarquee }}</span>
</div>
<span class="marquee-icon marquee-icon-right">📢</span>
</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 || ''"
>
<img v-if="tab.icon && (tab.icon.startsWith('data:') || tab.icon.startsWith('http') || tab.icon.startsWith('/'))" :src="tab.icon" class="tab-icon-img" />
<span v-else-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>
<!-- 内容信息紧凑信息条 -->
<div v-if="!loading && (contentInfo || contentTags.length > 0) && intent === 'SEARCH'" class="media-strip">
<a
v-if="contentInfo?.tmdb_url"
:href="contentInfo.tmdb_url"
target="_blank"
class="media-strip-inner"
rel="noopener"
>
<span v-if="contentInfo?.cover && !coverError" class="strip-thumb">
<img :src="contentInfo.cover" @error="coverError = true" />
</span>
<span v-else class="strip-thumb strip-thumb-fallback">🎬</span>
<span class="strip-title">{{ contentInfo?.title || query }}</span>
<span v-if="contentInfo?.year" class="strip-year">{{ contentInfo.year }}</span>
<span v-if="contentInfo?.rating" class="strip-rating"> {{ contentInfo.rating }}</span>
<span v-if="contentInfo?.genres?.length" class="strip-genres">
<span v-for="(g, gi) in contentInfo.genres.slice(0, 3)" :key="gi" class="strip-genre"> {{ g }}</span>
</span>
<span v-if="contentTags.length > 0" class="strip-tags">
<span v-for="tag in contentTags.slice(0, 3)" :key="tag" class="strip-tag">{{ tag }}</span>
</span>
<span class="strip-right">信息来源 TMDB · 更多详情 </span>
</a>
<div v-else class="media-strip-inner">
<span v-if="contentInfo?.cover && !coverError" class="strip-thumb">
<img :src="contentInfo.cover" @error="coverError = true" />
</span>
<span v-else class="strip-thumb strip-thumb-fallback">🎬</span>
<span class="strip-title">{{ contentInfo?.title || query }}</span>
<span v-if="contentInfo?.year" class="strip-year">{{ contentInfo.year }}</span>
<span v-if="contentInfo?.rating" class="strip-rating"> {{ contentInfo.rating }}</span>
<span v-if="contentInfo?.genres?.length" class="strip-genres">
<span v-for="(g, gi) in contentInfo.genres.slice(0, 3)" :key="gi" class="strip-genre"> {{ g }}</span>
</span>
<span v-if="contentTags.length > 0" class="strip-tags">
<span v-for="tag in contentTags.slice(0, 3)" :key="tag" class="strip-tag">{{ tag }}</span>
</span>
<span class="strip-right">信息来源 TMDB · 更多详情 </span>
</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"
:loggedIn="false"
:cloudTypeMap="cloudTypeMap"
@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"
>
<span class="channel-header">
<img v-if="channelIcon(channel.cloud_type).startsWith('data:') || channelIcon(channel.cloud_type).startsWith('http') || channelIcon(channel.cloud_type).startsWith('/')" :src="channelIcon(channel.cloud_type)" class="channel-icon-img" />
<span v-else 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>
</span>
<ResultCard
v-for="(item, ri) in channelVisibleItems(channel)"
:key="'ch-' + ci + '-' + ri"
:data="item"
:fallbackTags="contentTags"
:fallbackImage="fallbackImage"
:loggedIn="false"
:cloudTypeMap="cloudTypeMap"
@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>
<!-- 空状态智能分析 -->
<div v-if="!loading && !loadingMore && totalCount === 0 && results.length === 0" class="empty-wrapper">
<div class="empty-icon">🔍</div>
<div class="empty-title">没有找到相关资源</div>
<div class="empty-hint">{{ emptyHint }}</div>
<div v-if="emptyTips.length > 0" class="empty-tips">
<div v-for="(tip, ti) in emptyTips" :key="ti" class="empty-tip-item">💡 {{ tip }}</div>
</div>
</div>
<!-- 加载更多 -->
<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>
<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, getCloudTypes, streamSearch, getSiteConfig } 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_ICONS, 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 = computed(() => {
// 兜底图优先用 search_fallback_image没有则用 site_logo
return _fallbackImage.value || siteLogo.value || ''
})
const _fallbackImage = ref('')
const siteLogo = ref('')
const siteName = ref('')
const siteDisclaimer = ref('')
const siteMarquee = ref('')
const coverError = ref(false)
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>>({})
// 网盘图标映射 — 从后端 API 加载
const cloudTypeMap = ref<Record<string, { label: string; icon: string }>>({})
function cloudIconByType(t: string): string {
return cloudTypeMap.value[t]?.icon || CLOUD_ICONS[t] || '📁'
}
async function loadCloudTypes() {
try {
const res = await getCloudTypes()
const map: Record<string, { label: string; icon: string }> = {}
for (const ct of res.types) {
map[ct.type] = { label: ct.label, icon: ct.icon }
}
cloudTypeMap.value = map
} catch { /* ignore */ }
}
onMounted(async () => {
// 预加载网站配置logo/名称/公告等)
try {
const sc = await getSiteConfig()
if (sc.site_logo) siteLogo.value = sc.site_logo
if (sc.site_name) siteName.value = sc.site_name
if (sc.site_disclaimer) siteDisclaimer.value = sc.site_disclaimer
if (sc.site_marquee) siteMarquee.value = sc.site_marquee
} catch {}
const q = (route.query.q as string) || ''
if (q) {
query.value = q
doSearch(q)
}
loadCloudTypes()
})
// 网盘分类标签 — 始终展示所有已知云盘类型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: cloudIconByType(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
})
// ===== 空结果智能提示分析 =====
const emptyHint = computed(() => {
const q = query.value.trim()
if (!q) return '请输入关键词进行搜索'
if (q.length < 2) return `${q}」太短了,试试输入更完整的关键词`
if (q.length > 30) return '关键词太长啦,试试用几个核心词代替整句话'
const chineseChars = (q.match(/[\u4e00-\u9fff]/g) || []).length
if (chineseChars === 0) return '网盘资源通常以中文命名,试试用中文搜索'
return `${q}」暂时没找到匹配的资源`
})
const emptyTips = computed(() => {
const q = query.value.trim()
if (!q) return ['输入电视剧/电影/文件名称试试']
const tips: string[] = []
// 1. 判断关键词长度
if (q.length < 2) {
tips.push('输入至少 2 个字符,试试完整的资源名称')
}
// 2. 判断是否过长
if (q.length > 30) {
tips.push('缩短到 2-10 个字,用核心关键词搜索更精准')
}
// 3. 没有中文
const chineseChars = (q.match(/[\u4e00-\u9fff]/g) || []).length
if (chineseChars === 0) {
tips.push('国内网盘资源标题大多是中文,试试转换为中文名称')
}
// 4. 中文太少
if (chineseChars > 0 && chineseChars < q.length * 0.5 && q.length >= 2) {
tips.push('混合了太多非中文字符,提取核心中文关键词试试')
}
// 5. 包含空格
if (q.includes(' ') || q.includes(' ')) {
tips.push('搜索词中包含了空格,试试去掉空格连续输入')
}
// 6. 是否是完整句子
const stopWords = ['的', '了', '是', '在', '有', '我', '他', '她', '它', '这', '那', '和', '与', '及', '或', '但', '而', '且']
const stopCount = stopWords.filter(w => q.includes(w)).length
if (stopCount >= 2 && q.length > 6) {
tips.push('看起来像是一句话,试着只保留资源核心名称(去掉「的」「了」「我」等词)')
}
// 7. 太具体/特殊字符过多
const specialChars = (q.match(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~`《》【】!@#¥%……&*()——+]/g) || []).length
if (specialChars > 2) {
tips.push('特殊符号过多,试试只用中英文和数字')
}
// 8. 如果是单字中文
if (q.length === 1 && chineseChars === 1) {
tips.push('单个汉字过于宽泛,试试完整的剧名或文件名')
}
// 9. 默认通用建议
if (tips.length === 0) {
tips.push('试试更换关键词或减短搜索词')
tips.push('检查一下是否输入了正确的资源名称')
}
return tips.slice(0, 4)
})
// 加载更多本地数据
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 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} 个月前`
}
// 频道图标 — 使用 API 加载的网盘图标
function channelIcon(ct: string): string {
return cloudIconByType(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
siteMarquee.value = ''
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, {
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
// 预加载兜底图:成功了缓存,失败了清空引用,避免每个卡片单独试
const preload = new Image()
preload.onload = () => {} // 浏览器自动缓存
preload.onerror = () => { _fallbackImage.value = '' }
preload.src = 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
}
if (stats.site_marquee) {
siteMarquee.value = stats.site_marquee
}
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
// 清除上一次保存的结果状态,避免新旧结果同时显示
saveSuccess.value = false
saveResult.value = null
shareLink.value = ''
sharePwd.value = ''
renamedFiles.value = []
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
// 清除上一次保存的结果状态
saveSuccess.value = false
saveResult.value = null
shareLink.value = ''
sharePwd.value = ''
renamedFiles.value = []
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);
}
/* ---- 跑马灯通知条 ---- */
.marquee-bar {
max-width: 680px;
margin: 0 auto;
padding: 6px 12px;
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.marquee-icon {
flex-shrink: 0;
font-size: 15px;
line-height: 1;
}
.marquee-track {
flex: 1;
overflow: hidden;
white-space: nowrap;
}
.marquee-text {
display: inline-block;
font-size: 13px;
color: #e6a23c;
animation: marquee-scroll 20s linear infinite;
padding-left: 100%;
}
@keyframes marquee-scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
}
.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: 0.5px;
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 {
margin: 16px auto 0;
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;
font-weight: 700;
letter-spacing: 0.5px;
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-icon-img {
width: 16px;
height: 16px;
vertical-align: middle;
margin-right: 2px;
}
.tab-count {
font-size: 11px;
color: #c0c4cc;
margin-left: 2px;
}
/* 结果列表 */
.result-content {
max-width: 1080px;
margin: 0 auto;
padding: 0 24px 48px;
}
/* 宽屏下搜索栏保持800px结果容器保持1080px网格保持3列 */
@media (min-width: 1280px) {
.result-content {
max-width: 1080px;
}
.search-bar-inner {
max-width: 800px;
}
}
.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-icon-img {
width: 18px;
height: 18px;
vertical-align: middle;
margin-right: 4px;
}
.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;
}
/* 全部标签 — 卡片网格3列 */
.flat-list {
display: grid !important;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
/* 窄屏降为1列平板降为2列 */
@media (max-width: 720px) {
.flat-list {
grid-template-columns: 1fr;
}
.channel-section {
grid-template-columns: 1fr;
}
}
@media (min-width: 721px) and (max-width: 1079px) {
.flat-list {
grid-template-columns: repeat(2, 1fr);
}
.channel-section {
grid-template-columns: repeat(2, 1fr);
}
}
/* 频道列表 — 每个频道区块也3列 */
.channel-list {
gap: 14px;
}
.channel-section {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin-bottom: 24px;
}
.channel-section .channel-header {
grid-column: 1 / -1;
}
.channel-section .channel-load-more {
grid-column: 1 / -1;
}
.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;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
/* ---- 保存弹窗 — 进度流程 ---- */
.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;
}
/* ===== 内容信息(紧凑信息条) ===== */
.media-strip {
margin: 10px auto 0;
width: 100%;
}
.media-strip-inner {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
background: linear-gradient(135deg, #f8faff, #f0f5ff);
border: 1px solid #e8f0fe;
border-radius: 10px;
text-decoration: none;
color: inherit;
font-size: 13px;
line-height: 1.4;
transition: all 0.2s;
}
.media-strip-inner:hover {
border-color: #c0d9ff;
background: linear-gradient(135deg, #f0f7ff, #e6f0ff);
}
.strip-thumb {
flex-shrink: 0;
width: 32px;
height: 44px;
border-radius: 4px;
overflow: hidden;
background: #e8ecf1;
display: flex;
align-items: center;
justify-content: center;
}
.strip-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.strip-thumb-fallback {
font-size: 18px;
opacity: 0.5;
}
.strip-title {
font-weight: 700;
color: #1a1a2e;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.strip-year {
color: #909399;
font-size: 12px;
flex-shrink: 0;
}
.strip-rating {
color: #e6a23c;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.strip-genres {
display: flex;
gap: 2px;
flex-wrap: wrap;
}
.strip-genre {
font-size: 11px;
color: #67c23a;
background: #f0f9eb;
padding: 1px 7px;
border-radius: 4px;
}
.strip-tags {
display: flex;
gap: 3px;
flex-wrap: wrap;
}
.strip-tag {
font-size: 11px;
color: #409eff;
background: #ecf5ff;
padding: 1px 7px;
border-radius: 4px;
}
.strip-right {
font-size: 12px;
color: #909399;
margin-left: auto;
flex-shrink: 0;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 2px;
}
/* 重命名信息 */
.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;
}
}
/* ===== 空结果智能提示 ===== */
.empty-wrapper {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px 40px;
text-align: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.empty-title {
font-size: 18px;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 8px;
}
.empty-hint {
font-size: 14px;
color: #909399;
margin-bottom: 20px;
max-width: 360px;
line-height: 1.5;
}
.empty-tips {
display: flex;
flex-direction: column;
gap: 8px;
}
.empty-tip-item {
font-size: 13px;
color: #606266;
background: #f4f8ff;
padding: 8px 16px;
border-radius: 8px;
border: 1px solid #e8f0fe;
line-height: 1.4;
max-width: 400px;
}
.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>