2025-12-04 10:04:21 +08:00

457 lines
9.2 KiB
Vue
Raw Permalink 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>
<article class="mk-card" ref="cardRef" :data-transition-slug="slug">
<!-- 封面 -->
<div class="mk-cover" :data-hero-cover="slug">
<img :src="cover" :alt="title || 'cover'" class="mk-img" loading="lazy" />
<!-- 悬浮层 CSS 控制显示 -->
<div class="mk-overlay">
<div class="mk-btns">
<a
v-if="visitHref"
:href="visitHref"
class="mk-btn"
target="_blank"
rel="noopener"
>
</a>
<NuxtLink
v-if="detailHref"
:to="detailHref"
class="mk-btn mk-btn--ghost"
@click="handleDetailClick"
>
详情
</NuxtLink>
</div>
</div>
</div>
<!-- 标题 -->
<h3 class="mk-title" :title="title" :data-hero-title="slug">
{{ title }}
</h3>
<!-- 标签 -->
<div class="mk-tags" v-if="tags?.length">
<span
v-for="t in tags"
:key="t"
class="mk-tag"
:class="{ 'mk-tag--rainbow': isRecTag(t) }"
>
{{ t }}
</span>
</div>
<!-- 时间 -->
<div class="mk-time" v-if="createdAt">
发布:{{ fdate(createdAt) }}
</div>
<!-- 底部信息 -->
<footer class="mk-meta">
<div class="mk-owner">
<img v-if="ownerAvatar" :src="ownerAvatar" class="mk-avatar" alt="" />
<span class="mk-owner-name" :title="ownerName">{{ ownerName }}</span>
</div>
<div class="mk-stats">
<span class="mk-stat">
<!-- 眼睛 -->
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 5c5.5 0 9.6 4.1 10.7 6.4a1.9 1.9 0 0 1 0 1.3C21.6 15.9 17.5 20 12 20S2.4 15.9 1.3 12.7a1.9 1.9 0 0 1 0-1.3C2.4 9.1 6.5 5 12 5Zm0 3.2a4.8 4.8 0 1 0 0 9.6 4.8 4.8 0 0 0 0-9.6Zm0 2.4a2.4 2.4 0 1 1 0 4.8 2.4 2.4 0 0 1 0-4.8Z"
/>
</svg>
{{ nfmt(views) }}
</span>
<button
type="button"
class="mk-stat mk-like"
:class="{ 'mk-like--active': favorited }"
@click.stop="emit('toggle-like')"
:aria-pressed="favorited ? 'true' : 'false'"
>
<!-- 心形 -->
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 21s-6.7-4.5-9.4-7.2A6 6 0 1 1 12 6a6 6 0 1 1 9.4 7.8C18.7 16.5 12 21 12 21Z"
/>
</svg>
{{ nfmt(likes) }}
</button>
</div>
</footer>
</article>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useSharedTransition } from '@/composables/useSharedTransition'
const emit = defineEmits<{
(e: 'toggle-like'): void
(e: 'view'): void
}>()
const props = withDefaults(
defineProps<{
slug?: string
cover: string
title: string
tags?: string[]
ownerName?: string
ownerAvatar?: string
views?: number
likes?: number
favorited?: boolean
/** 外链访问按钮 */
visitHref?: string
/** 站内详情页路由NuxtLink */
detailHref?: string | Record<string, any>
/** 创建时间 */
createdAt?: string
}>(),
{
slug: '',
tags: () => [],
ownerName: '秒哒官方',
ownerAvatar: '',
views: 0,
likes: 0,
favorited: false,
createdAt: '',
}
)
const cardRef = ref<HTMLElement | null>(null)
const transition = useSharedTransition()
onMounted(() => {
if (cardRef.value && props.slug) {
// 标记卡片位置用于转场动画
transition.markSource(props.slug, cardRef.value)
}
})
onBeforeUnmount(() => {
if (cardRef.value && props.slug) {
// 更新卡片位置(用户可能滚动了页面)
transition.markSource(props.slug, cardRef.value)
}
})
function handleDetailClick() {
// 在导航前标记当前卡片位置
if (cardRef.value && props.slug) {
transition.markSource(props.slug, cardRef.value)
}
emit('view')
}
/** 简单的 1200 -> 1.2K 格式化 */
function nfmt(n?: number) {
const v = n ?? 0
if (v >= 1_000_000)
return (v / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'
if (v >= 1_000) return (v / 1_000).toFixed(1).replace(/\.0$/, '') + 'K'
return String(v)
}
/** 是否为"推荐类"标签(炫彩) */
function isRecTag(tag?: string) {
if (!tag) return false
const t = String(tag).trim().toLowerCase()
// 兼容:推荐 / 精选 / 热门 / 置顶 / rec包含这些词也算
const keys = ['推荐', '精选', '热门', '置顶', 'rec']
return keys.some(k => t === k.toLowerCase() || t.includes(k.toLowerCase()))
}
function fdate(v?: string) {
if (!v) return ''
try {
const d = new Date(v)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString()
} catch {
return ''
}
}
</script>
<style scoped>
/* 容器 */
.mk-card {
--radius: 16px;
--shadow: 0 6px 20px rgba(16, 24, 40, 0.08);
--shadow-hover: 0 12px 32px rgba(16, 24, 40, 0.14);
/* 与首页保持一致的三原色渐变(紫→靛蓝→蓝→青) */
--rainbow: linear-gradient(
90deg,
#7c3aed 0%,
#6366f1 33%,
#60a5fa 66%,
#22d3ee 100%
);
background: #fff;
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
transition: box-shadow 0.25s ease, transform 0.25s ease;
}
.mk-card:hover,
.mk-card:focus-within {
box-shadow: var(--shadow-hover);
transform: translateY(-2px);
}
/* 封面与悬浮层 */
.mk-cover {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: calc(var(--radius) - 2px);
margin: 12px 12px 10px;
}
.mk-img {
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.001);
transition: transform 0.35s ease;
border-radius: inherit;
}
.mk-card:hover .mk-img,
.mk-card:focus-within .mk-img {
transform: scale(1.03);
}
/* 渐变遮罩 + 按钮 */
.mk-overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 30%,
rgba(0, 0, 0, 0.45) 100%
);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
}
.mk-card:hover .mk-overlay,
.mk-card:focus-within .mk-overlay {
opacity: 1;
pointer-events: auto;
}
.mk-btns {
display: flex;
gap: 14px;
transform: translateY(8px);
transition: transform 0.25s ease 0.02s;
}
.mk-card:hover .mk-overlay .mk-btns,
.mk-card:focus-within .mk-overlay .mk-btns {
transform: translateY(0);
}
.mk-btn {
min-width: 140px;
height: 44px;
padding: 0 18px;
border-radius: 12px;
border: 1px solid #111827;
background: #111827;
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
letter-spacing: 0.5px;
text-decoration: none;
transition: all 0.2s ease;
}
.mk-btn:hover {
filter: brightness(1.07);
transform: translateY(-1px);
}
.mk-btn--ghost {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.65);
color: #fff;
}
/* 标题(单行省略) */
.mk-title {
margin: 0 16px;
font-size: 18px;
line-height: 1.5;
font-weight: 700;
color: #111827;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 标签 */
.mk-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding: 8px 16px 0;
}
.mk-tag {
font-size: 12px;
color: #111827;
background: #f3f4f6;
border: 1px solid #e5e7eb;
padding: 2px 8px;
border-radius: 10px;
}
/* 炫彩"推荐"标签 */
.mk-tag--rainbow {
color: #fff;
border: none;
background-image: var(--rainbow);
background-size: 220% 220%;
animation: mkRainbowShift 6s ease infinite;
box-shadow: 0 6px 14px rgba(99, 102, 241, 0.22);
}
.mk-tag--rainbow:hover {
filter: brightness(1.05);
}
/* 降低动画偏好时,禁用流动,仅保留渐变底色 */
@media (prefers-reduced-motion: reduce) {
.mk-tag--rainbow {
animation: none;
background-size: 100% 100%;
}
}
@keyframes mkRainbowShift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* 时间 */
.mk-time {
margin: 4px 16px 0;
color: #6b7280;
font-size: 12px;
}
/* 底部信息 */
.mk-meta {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px 14px;
}
.mk-owner {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.mk-avatar {
width: 22px;
height: 22px;
border-radius: 50%;
object-fit: cover;
background: #eef2ff;
}
.mk-owner-name {
font-size: 14px;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 统计 */
.mk-stats {
display: inline-flex;
gap: 16px;
color: #6b7280;
}
.mk-stat {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
}
.mk-stat svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.mk-like {
background: transparent;
border: none;
color: inherit;
padding: 0;
cursor: pointer;
transition: color 0.2s ease;
}
.mk-like svg {
transition: transform 0.15s ease;
}
.mk-like:hover svg {
transform: scale(1.08);
}
.mk-like--active {
color: #e11d48;
}
.mk-like--active svg {
fill: currentColor;
}
/* 小屏优化 */
@media (max-width: 640px) {
.mk-btn {
min-width: 110px;
height: 40px;
}
.mk-title {
font-size: 16px;
}
}
</style>