531 lines
14 KiB
Vue
Executable File
531 lines
14 KiB
Vue
Executable File
<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>
|