Files
CloudSearch/packages/frontend/src/components/ResultCard.vue

531 lines
14 KiB
Vue
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="result-card" :class="{ 'clickable': loggedIn }" @click="loggedIn && openLink()">
<!-- 封面图区域左侧 -->
<div class="card-cover">
<!-- 后台静默加载资源图成功后切换 -->
<img v-if="showCover" :src="data.cover" :alt="data.title" @error="onCoverLoadError" loading="lazy" fetchpriority="low" />
<!-- 默认先显示兜底图自家服务器秒加载 -->
<img v-else-if="fallbackImage && !fallbackImgError" :src="fallbackImage" alt="cover" class="fallback-img" @error="onFallbackImgError" />
<!-- 兜底图也没有就用渐变色占位 -->
<div v-else class="cover-placeholder" :style="{ background: coverGradient }">
<span class="placeholder-icon">
<img v-if="cloudIcon.startsWith('data:') || cloudIcon.startsWith('http') || cloudIcon.startsWith('/')" :src="cloudIcon" style="width:36px;height:36px" />
<span v-else>{{ cloudIcon }}</span>
</span>
</div>
<span class="cover-tag" :style="{ background: CLOUD_COLORS[data.cloud_type] }">
{{ CLOUD_LABELS[data.cloud_type] }}
</span>
</div>
<!-- 右侧内容 -->
<div class="card-body">
<!-- 资源名称已清洗前缀和2行显示 -->
<div class="card-title" :title="data.title">{{ cleanTitle }}</div>
<!-- 更新时间 + 大小 -->
<div class="card-time">
<span>🕐 {{ relativeTime }}</span>
<span v-if="data.file_size" class="meta-size">📦 {{ data.file_size }}</span>
</div>
<!-- 标签行仅质量/格式/字幕类标签 -->
<div v-if="displayTags.length > 0" class="card-tags">
<span v-for="(tag, ti) in displayTags" :key="ti" class="tag" :class="'tag-' + tagClass(tag)">{{ tag }}</span>
</div>
<!-- 底部行来源 + 操作按钮 -->
<div class="card-bottom-row">
<div class="bottom-left">
<span v-if="sourceName" class="meta-source" :title="data.source">
{{ sourceIcon }} {{ sourceName }}
</span>
</div>
<div class="bottom-right">
<button v-if="data.share_url && !loggedIn" class="action-btn get-link-btn" @click.stop="handleSave">
🔗 获取分享链接
</button>
<button v-if="data.share_url && loggedIn" class="action-btn open-link-btn" @click.stop="openLink">
🔗 打开链接
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { CLOUD_LABELS, CLOUD_ICONS, CLOUD_COLORS } from '../types'
import type { SearchResult } from '../types'
const props = defineProps<{
data: SearchResult
fallbackTags?: string[]
fallbackImage?: string
loggedIn?: boolean
cloudTypeMap?: Record<string, { label: string; icon: string }>
}>()
const emit = defineEmits<{
save: [data: SearchResult]
}>()
const PRELOAD_TIMEOUT_MS = 10000 // 资源图静默加载超时10秒后放弃
const showCover = ref(false) // 是否切换显示资源图(默认先显示兜底)
const fallbackImgError = ref(false) // 兜底图加载失败
const coverLoading = ref(false) // 资源图是否正在后台尝试
let preloadTimer: ReturnType<typeof setTimeout> | null = null
onMounted(() => {
// 后台静默预加载资源图
if (props.data.cover && !showCover.value) {
coverLoading.value = true
const img = new Image()
let resolved = false
preloadTimer = setTimeout(() => {
// 超时未完成 -> 放弃,继续显示兜底图
if (!resolved) {
resolved = true
coverLoading.value = false
}
}, PRELOAD_TIMEOUT_MS)
img.onload = () => {
if (!resolved) {
resolved = true
showCover.value = true
coverLoading.value = false
if (preloadTimer) clearTimeout(preloadTimer)
}
}
img.onerror = () => {
if (!resolved) {
resolved = true
coverLoading.value = false
if (preloadTimer) clearTimeout(preloadTimer)
}
}
img.src = props.data.cover
}
})
onUnmounted(() => {
if (preloadTimer) clearTimeout(preloadTimer)
})
// 极意外情况showCover 后图片加载失败 -> 回退到兜底图
function onCoverLoadError() {
showCover.value = false
// 注意:此时 fallbackImage 可能已经加载过,浏览器有缓存会直接显示
}
function onFallbackImgError() { fallbackImgError.value = true }
// 网盘图标 — 优先使用 prop 中的 API 数据fallback emoji
const cloudIcon = computed(() => {
const icon = props.cloudTypeMap?.[props.data.cloud_type]?.icon
return icon || CLOUD_ICONS[props.data.cloud_type] || '📁'
})
// 封面渐变色(无图时)
const coverGradient = computed(() => {
const gradients: Record<string, string> = {
quark: 'linear-gradient(135deg, #e8f5e9, #c8e6c9)',
baidu: 'linear-gradient(135deg, #e3f2fd, #bbdefb)',
aliyun: 'linear-gradient(135deg, #fff3e0, #ffe0b2)',
'115': 'linear-gradient(135deg, #f3e5f5, #e1bee7)',
xunlei: 'linear-gradient(135deg, #e8f5e9, #a5d6a7)',
magnet: 'linear-gradient(135deg, #e8eaf6, #c5cae9)',
}
return gradients[props.data.cloud_type] || 'linear-gradient(135deg, #f5f5f5, #e0e0e0)'
})
// ===== 时间格式化 =====
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 diffMs = now - date.getTime()
if (diffMs < 0) return dateStr.slice(0, 10)
const secs = Math.floor(diffMs / 1000)
if (secs < 60) return '刚刚'
const mins = Math.floor(secs / 60)
if (mins < 60) return `${mins} 分钟前`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
if (days < 30) return `${days} 天前`
if (days < 365) return `${Math.floor(days / 30)} 个月前`
return `${Math.floor(days / 365)} 年前`
}
const relativeTime = computed(() => {
return formatRelativeTime(props.data.update_time || props.data.datetime)
})
// ===== 来源解析 =====
const sourceName = computed(() => {
const src = props.data.source || ''
if (!src) return ''
if (src.startsWith('tg:')) return '@' + src.slice(3)
if (src.startsWith('plugin:')) return src.slice(7)
return src
})
const sourceIcon = computed(() => {
const src = props.data.source || ''
if (src.startsWith('tg:')) return '📢'
if (src.startsWith('plugin:')) return '🔌'
return '📎'
})
// ===== 标题清洗 =====
// 移除各类前缀: [夸克网盘]、【#电影名称:】等
const CLEAN_PREFIXES = [
/^\[夸克网盘\][:]?\s*/,
/^【#电影名称:】\s*/,
/^【#电影名称[:]】\s*/,
/^【[^】]*[网盘|分享|电影|下载|资源]】[:]?\s*/,
/^\[[^\]]*[网盘|分享|电影|下载|资源]\]\s*/,
/^[#]电影名称[:]?\s*/,
/^[#]资源名称[:]?\s*/,
/^[#]标题[:]?\s*/,
/^【[^】]*资源名称[^】]*】\s*/,
/^【影片名称】\s*/,
/^【资源名称】\s*/,
/^【标题】\s*/,
]
const cleanTitle = computed(() => {
let title = props.data.title || ''
// 1. 去除【】内的通用前缀类标识
for (const pat of CLEAN_PREFIXES) {
title = title.replace(pat, '')
}
// 2. 去除剩余的【xxx】内容保留内容作为标签标题只留干净部分
title = title.replace(/【[^】]+】/g, '').trim()
return title || props.data.title
})
// ===== 标签 =====
const QUALITY_TAG_SET = new Set([
'4K', '1080P', '2160P', '720P', '480P',
'HDR', 'HDR10', 'HDR10+', 'DV', '杜比视界', '杜比全景声',
'高码率', 'BluRay', 'REMUX', 'HEVC', 'x264', 'x265', 'AVC',
'内封简繁英字幕', '内嵌中英字幕', '内封简繁', '内嵌字幕',
'字幕', '中文字幕', '简繁字幕', '中英字幕', '内封字幕',
'臻彩', '高清', 'WEB-DL', 'WEBRip', '蓝光',
])
const QUALITY_PATTERNS = [
/\b(4K)\b/, /\b(1080[Pp])\b/, /\b(2160[Pp])\b/, /\b(720[Pp])\b/,
/\b(HDR10?\+?)\b/i, /\b(DV)\b/i,
/\b(BluRay|蓝光)\b/i, /\b(REMUX)\b/i, /\b(HEVC)\b/i,
/\b(x264)\b/i, /\b(x265)\b/i, /\b(WEB-DL)\b/i, /\b(WEBRip)\b/i,
]
const displayTags = computed(() => {
const title = props.data.title || ''
const tags: string[] = []
// 1. 从【xxx】提取内容只保留质量/格式/字幕类标签
const bracketMatches = title.matchAll(/【([^】]+)】/g)
for (const m of bracketMatches) {
const inner = m[1]
const parts = inner.split(/[.·、,,\/\\|]/)
for (const p of parts) {
const trimmed = p.trim()
if (trimmed && QUALITY_TAG_SET.has(trimmed) && !tags.includes(trimmed)) {
tags.push(trimmed)
}
}
}
// 2. 额外从标题提取分辨率/编码标签
for (const pat of QUALITY_PATTERNS) {
const m = title.match(pat)
if (m) {
const found = m[1]
if (!tags.includes(found)) tags.push(found)
}
}
// 3. 从标题全文找包含的关键词
const fullTextKeywords = ['杜比视界', '杜比全景声', '高码率', '内封简繁英字幕', '内嵌中英字幕', '内封简繁', '内嵌字幕', '中文字幕', '简繁字幕', '中英字幕', '内封字幕', '臻彩']
for (const kw of fullTextKeywords) {
if (title.includes(kw) && !tags.includes(kw)) {
tags.push(kw)
}
}
if (tags.length === 0 && props.fallbackTags && props.fallbackTags.length > 0) {
return props.fallbackTags.slice(0, 6)
}
return tags.slice(0, 10)
})
function tagClass(tag: string): string {
const quality = ['4K', '1080P', '2160P', '720P', '480P', 'HDR', 'HDR10', 'HDR10+', 'DV', '杜比视界', 'BluRay', 'REMUX', 'HEVC', 'x264', 'x265', '臻彩', '高清', 'WEB-DL', 'WEBRip']
if (quality.includes(tag)) return 'quality'
if (tag.includes('字幕') || tag === '杜比全景声' || tag === '高码率') return 'subtitle'
return 'default'
}
// ===== 交互行为 =====
function handleSave() {
emit('save', props.data)
}
function openLink() {
if (props.data.share_url) {
window.open(props.data.share_url, '_blank')
}
}
</script>
<style scoped>
.result-card {
display: flex;
background: #fff;
border-radius: 10px;
padding: 12px;
gap: 14px;
border: 1px solid #ebeef5;
transition: all 0.2s ease;
content-visibility: auto;
contain-intrinsic-size: 130px 120px;
min-width: 0;
}
.result-card:hover {
border-color: #c0c4cc;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.result-card.clickable {
cursor: pointer;
}
.result-card.clickable:hover {
border-color: #409eff;
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.12);
}
/* ---- 封面(左侧,与右侧等高) ---- */
.card-cover {
position: relative;
flex: 0 0 100px;
max-width: 130px;
border-radius: 8px;
overflow: hidden;
background: #f0f2f5;
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
min-height: 100%;
}
.card-cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
position: absolute;
top: 0;
left: 0;
}
.card-cover img.fallback-img {
object-fit: contain;
background: #f0f2f5;
loading: eager;
}
.result-card:hover .card-cover img {
transform: scale(1.05);
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
}
.placeholder-icon {
font-size: 30px;
opacity: 0.5;
filter: grayscale(0.2);
}
.cover-tag {
position: absolute;
bottom: 5px;
left: 5px;
padding: 1px 7px;
border-radius: 4px;
color: #fff;
font-size: 11px;
font-weight: 600;
line-height: 1.5;
letter-spacing: 0.3px;
backdrop-filter: blur(2px);
z-index: 1;
}
/* ---- 右侧内容 ---- */
.card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
}
/* 资源名称 */
.card-title {
font-size: 15px;
font-weight: 700;
color: #1a1a2e;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 更新时间 */
.card-time {
font-size: 12px;
color: #909399;
display: flex;
align-items: center;
gap: 10px;
}
.meta-size {
color: #67c23a;
white-space: nowrap;
font-size: 11px;
}
/* ---- 标签 ---- */
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tag {
display: inline-block;
padding: 1px 7px;
border-radius: 4px;
font-size: 11px;
line-height: 1.8;
white-space: nowrap;
transition: opacity 0.2s;
}
.tag-quality {
background: #fef0f0;
color: #e74c3c;
}
.tag-subtitle {
background: #f0f9eb;
color: #67c23a;
}
.tag-default {
background: #ecf5ff;
color: #409eff;
}
/* ---- 底部行:来源 + 操作 ---- */
.card-bottom-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: auto;
}
.bottom-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.bottom-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.meta-source {
color: #909399;
background: #f4f4f5;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
}
.action-btn {
padding: 5px 12px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 3px;
}
.get-link-btn {
background: var(--primary-color);
color: #fff;
}
.get-link-btn:hover {
background: var(--primary-dark);
transform: scale(1.05);
}
.open-link-btn {
background: #67c23a;
color: #fff;
}
.open-link-btn:hover {
background: #5daf34;
transform: scale(1.05);
}
/* ===== 响应式 ===== */
@media (max-width: 640px) {
.result-card {
flex-direction: column;
gap: 10px;
}
.card-cover {
width: 100%;
height: 160px;
align-self: auto;
}
.card-cover img {
position: static;
height: 160px;
}
.card-cover img.fallback-img {
position: static;
}
.cover-placeholder {
position: static;
}
.card-bottom-row {
flex-wrap: wrap;
}
}
</style>