chore: initial commit - CloudSearch v0.0.2

This commit is contained in:
2026-05-15 05:50:50 +08:00
commit d83225d736
102 changed files with 37926 additions and 0 deletions

View 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>