chore: initial commit - CloudSearch v0.0.2
This commit is contained in:
530
packages/frontend/src/components/ResultCard.vue
Executable file
530
packages/frontend/src/components/ResultCard.vue
Executable file
@@ -0,0 +1,530 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user