Files
NovaBlog/src/components/MicroPost.vue
Jiao77 0961bbd1b7 feat: 添加微语功能
- 新增微语页面,类似 Twitter/QQ 空间的短内容发布平台
- 添加 GitHub 风格热力图组件展示发布活动
- 支持发布微语、图片上传、标签、Emoji
- 支持点赞、评论功能
- 右侧栏显示统计数据和热门标签
- 支持按标签筛选微语
- 后端新增微语相关 API(CRUD、点赞、评论、标签)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-05 16:56:48 +08:00

352 lines
11 KiB
Vue

<template>
<article class="micro-post group">
<!-- 用户头像 -->
<div class="flex-shrink-0">
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-primary-400 to-purple-500 flex items-center justify-center text-white font-bold text-lg">
{{ getInitial(author) }}
</div>
</div>
<!-- 内容区域 -->
<div class="flex-1 min-w-0">
<!-- 作者信息 -->
<div class="flex items-center gap-2 mb-2">
<span class="font-semibold text-foreground">{{ author }}</span>
<span class="text-sm text-foreground/40">{{ formatTime(createdAt) }}</span>
</div>
<!-- 内容 -->
<div class="micro-content prose prose-sm dark:prose-invert max-w-none mb-3" v-html="renderedContent"></div>
<!-- 标签 -->
<div v-if="tags && tags.length > 0" class="flex flex-wrap gap-1 mb-3">
<span
v-for="tag in tags"
:key="tag"
class="inline-block px-2 py-0.5 text-xs bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300 rounded-full cursor-pointer hover:bg-primary-200 dark:hover:bg-primary-800 transition-colors"
@click="$emit('tag-click', tag)"
>
#{{ tag }}
</span>
</div>
<!-- 图片网格 -->
<div v-if="images && images.length > 0" class="image-grid mb-3">
<div
:class="[
'grid gap-2',
images.length === 1 ? 'grid-cols-1 max-w-md' : '',
images.length === 2 ? 'grid-cols-2 max-w-lg' : '',
images.length >= 3 ? 'grid-cols-3 max-w-xl' : ''
]"
>
<div
v-for="(image, index) in images.slice(0, 9)"
:key="index"
class="relative overflow-hidden rounded-lg cursor-pointer group/img"
:class="images.length === 1 ? 'aspect-video' : 'aspect-square'"
@click="previewImageAt(index)"
>
<img
:src="image"
:alt="`图片 ${index + 1}`"
class="w-full h-full object-cover transition-transform duration-300 group-hover/img:scale-105"
loading="lazy"
/>
<div v-if="index === 8 && images.length > 9" class="absolute inset-0 bg-black/50 flex items-center justify-center">
<span class="text-white text-xl font-bold">+{{ images.length - 9 }}</span>
</div>
</div>
</div>
</div>
<!-- 操作栏 -->
<div class="flex items-center gap-6 text-foreground/50">
<!-- 点赞 -->
<button
@click="toggleLike"
class="flex items-center gap-1.5 hover:text-red-500 transition-colors"
:class="{ 'text-red-500': liked }"
>
<svg class="w-5 h-5" :fill="liked ? 'currentColor' : 'none'" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
<span class="text-sm">{{ likeCount || '' }}</span>
</button>
<!-- 评论 -->
<button
@click="showComments = !showComments"
class="flex items-center gap-1.5 hover:text-primary-500 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span class="text-sm">{{ commentCount || '' }}</span>
</button>
<!-- 分享 -->
<button
@click="sharePost"
class="flex items-center gap-1.5 hover:text-primary-500 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
<span class="text-sm">分享</span>
</button>
</div>
<!-- 评论区 -->
<Transition name="slide">
<div v-if="showComments" class="mt-4 pt-4 border-t border-border">
<MicroCommentSection
:micro-id="id"
:api-base-url="apiBaseUrl"
/>
</div>
</Transition>
</div>
<!-- 图片预览 -->
<Teleport to="body">
<Transition name="lightbox">
<div
v-if="previewIndex !== null"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/90 backdrop-blur-sm"
@click="closePreview"
>
<button
class="absolute top-4 right-4 p-2 text-white/80 hover:text-white bg-white/10 hover:bg-white/20 rounded-full transition-all"
@click="closePreview"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button
v-if="images && images.length > 1"
class="absolute left-4 p-2 text-white/80 hover:text-white bg-white/10 hover:bg-white/20 rounded-full transition-all"
@click.stop="prevImage"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<img
v-if="images && previewIndex !== null"
:src="images[previewIndex]"
class="max-w-[90vw] max-h-[90vh] rounded-lg shadow-2xl"
@click.stop
/>
<button
v-if="images && images.length > 1"
class="absolute right-4 p-2 text-white/80 hover:text-white bg-white/10 hover:bg-white/20 rounded-full transition-all"
@click.stop="nextImage"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<div v-if="images && images.length > 1" class="absolute bottom-4 text-white/60 text-sm">
{{ previewIndex + 1 }} / {{ images.length }}
</div>
</div>
</Transition>
</Teleport>
</article>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { marked } from 'marked';
import MicroCommentSection from './MicroCommentSection.vue';
interface Props {
id: number;
author?: string;
content: string;
images?: string[];
tags?: string[];
createdAt: string;
likeCount?: number;
commentCount?: number;
isLiked?: boolean;
apiBaseUrl?: string;
}
const props = withDefaults(defineProps<Props>(), {
author: '博主',
likeCount: 0,
commentCount: 0,
isLiked: false,
tags: () => [],
apiBaseUrl: 'http://localhost:8080/api',
});
const emit = defineEmits<{
(e: 'like', id: number, liked: boolean): void;
(e: 'share', id: number): void;
(e: 'tag-click', tag: string): void;
}>();
// 状态
const liked = ref(props.isLiked);
const likeCount = ref(props.likeCount);
const showComments = ref(false);
// 图片预览
const previewIndex = ref<number | null>(null);
// 渲染 Markdown 内容
const renderedContent = computed(() => {
try {
return marked.parse(props.content, { breaks: true, gfm: true }) as string;
} catch {
return props.content;
}
});
// 获取头像首字母
function getInitial(name: string): string {
return name.charAt(0).toUpperCase();
}
// 格式化时间
function formatTime(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes} 分钟前`;
if (hours < 24) return `${hours} 小时前`;
if (days < 7) return `${days} 天前`;
return date.toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
});
}
// 切换点赞
async function toggleLike() {
try {
const response = await fetch(`${props.apiBaseUrl}/micro/${props.id}/like`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders(),
},
});
if (response.ok) {
const data = await response.json();
liked.value = data.liked;
likeCount.value = data.like_count;
emit('like', props.id, data.liked);
}
} catch (error) {
console.error('Failed to toggle like:', error);
}
}
// 分享
function sharePost() {
if (navigator.share) {
navigator.share({
title: '分享微语',
text: props.content.slice(0, 100),
url: window.location.href,
});
} else {
// 复制链接
navigator.clipboard.writeText(window.location.href);
alert('链接已复制到剪贴板');
}
emit('share', props.id);
}
// 获取认证头
function getAuthHeaders(): Record<string, string> {
if (typeof window === 'undefined') return {};
const token = localStorage.getItem('token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
// 图片预览
function previewImageAt(index: number) {
previewIndex.value = index;
}
function closePreview() {
previewIndex.value = null;
}
function prevImage() {
if (props.images && previewIndex.value !== null) {
previewIndex.value = (previewIndex.value - 1 + props.images.length) % props.images.length;
}
}
function nextImage() {
if (props.images && previewIndex.value !== null) {
previewIndex.value = (previewIndex.value + 1) % props.images.length;
}
}
</script>
<style scoped>
.micro-post {
@apply flex gap-4 p-4 bg-background border border-border rounded-xl transition-shadow duration-200;
}
.micro-post:hover {
@apply shadow-md;
}
.micro-content :deep(p) {
@apply mb-2 last:mb-0;
}
.micro-content :deep(a) {
@apply text-primary-500 hover:underline;
}
.micro-content :deep(code) {
@apply px-1.5 py-0.5 bg-muted rounded text-sm font-mono;
}
.image-grid img {
@apply transition-transform duration-300;
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.lightbox-enter-active,
.lightbox-leave-active {
transition: opacity 0.2s ease;
}
.lightbox-enter-from,
.lightbox-leave-to {
opacity: 0;
}
</style>