feat: 添加微语功能

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

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Jiao77
2026-03-05 16:56:48 +08:00
parent 5986f116ec
commit 0961bbd1b7
11 changed files with 2276 additions and 0 deletions

View File

@@ -0,0 +1,518 @@
<template>
<div class="micro-page">
<!-- 发布框 -->
<div class="card mb-6">
<textarea
ref="textareaRef"
v-model="newContent"
placeholder="分享你的想法..."
class="w-full p-4 bg-muted/50 border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary-500"
rows="4"
></textarea>
<div class="flex items-center justify-between mt-3">
<div class="flex items-center gap-2">
<!-- Emoji 按钮 -->
<div class="relative">
<button
@click="showEmojiPicker = !showEmojiPicker"
class="btn-ghost p-2 rounded-lg"
title="添加表情"
>
<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="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<!-- Emoji 选择面板 -->
<div
v-if="showEmojiPicker"
class="absolute left-0 top-full mt-1 bg-white dark:bg-gray-800 border border-border rounded-lg shadow-lg p-3 z-20 w-72"
@click.stop
>
<div class="flex flex-wrap gap-1">
<button
v-for="emoji in commonEmojis"
:key="emoji"
@click="insertEmoji(emoji)"
class="w-8 h-8 flex items-center justify-center text-xl hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
>
{{ emoji }}
</button>
</div>
</div>
</div>
<!-- 图片上传按钮 -->
<button
@click="triggerImageUpload"
class="btn-ghost p-2 rounded-lg"
title="上传图片"
>
<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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</button>
<input
ref="imageInput"
type="file"
accept="image/*"
multiple
class="hidden"
@change="handleImageSelect"
/>
<!-- 标签按钮 -->
<div class="relative" ref="tagInputRef">
<button
@click="showTagInput = !showTagInput"
class="btn-ghost p-2 rounded-lg"
title="添加标签"
>
<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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
</button>
<!-- 标签输入面板 -->
<div
v-if="showTagInput"
class="absolute left-0 top-full mt-1 bg-white dark:bg-gray-800 border border-border rounded-lg shadow-lg p-3 z-20 w-64"
@click.stop
>
<div class="flex gap-2 mb-2">
<input
v-model="tagInput"
type="text"
placeholder="输入标签(回车添加)"
class="flex-1 px-2 py-1 text-sm border border-border rounded bg-background focus:outline-none focus:ring-1 focus:ring-primary-500"
@keydown.enter="addTag"
/>
<button
@click="addTag"
class="px-2 py-1 text-sm bg-primary-500 text-white rounded hover:bg-primary-600"
>
添加
</button>
</div>
<div v-if="newTags.length > 0" class="flex flex-wrap gap-1">
<span
v-for="(tag, index) in newTags"
:key="index"
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300 rounded-full"
>
#{{ tag }}
<button @click="removeTag(index)" class="hover:text-red-500">×</button>
</span>
</div>
<div class="mt-2 pt-2 border-t border-border text-xs text-foreground/40">
最多添加 5 个标签
</div>
</div>
</div>
</div>
<button
@click="publishMicro"
:disabled="!newContent.trim() || publishing"
class="btn-primary disabled:opacity-50"
>
{{ publishing ? '发布中...' : '发布' }}
</button>
</div>
<!-- 已选标签显示 -->
<div v-if="newTags.length > 0 && !showTagInput" class="mt-2 flex flex-wrap gap-1">
<span
v-for="(tag, index) in newTags"
:key="index"
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300 rounded-full"
>
#{{ tag }}
<button @click="removeTag(index)" class="hover:text-red-500">×</button>
</span>
</div>
<!-- 图片预览 -->
<div v-if="newImages.length > 0 || uploadingImages" class="mt-3 grid grid-cols-3 gap-2">
<!-- 上传进度 -->
<div v-if="uploadingImages" class="relative aspect-square rounded-lg overflow-hidden bg-muted flex flex-col items-center justify-center p-2">
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden mb-2">
<div
class="h-full bg-primary-500 transition-all duration-100 rounded-full"
:style="{ width: uploadProgress + '%' }"
></div>
</div>
<span class="text-xs text-foreground/60">{{ uploadProgress }}%</span>
</div>
<div
v-for="(img, index) in newImages"
:key="index"
class="relative aspect-square rounded-lg overflow-hidden bg-muted"
>
<img :src="img" class="w-full h-full object-cover" />
<button
@click="removeImage(index)"
class="absolute top-1 right-1 p-1 bg-black/50 rounded-full text-white hover:bg-black/70"
>
<svg class="w-4 h-4" 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>
</div>
</div>
</div>
<!-- 微语列表 -->
<div v-if="loading" class="text-center py-8">
<div class="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto"></div>
<p class="mt-2 text-foreground/40">加载中...</p>
</div>
<div v-else-if="micros.length === 0" class="text-center py-16 text-foreground/40">
<svg class="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
<p>暂无微语来发布第一条吧</p>
</div>
<div v-else class="space-y-4">
<MicroPost
v-for="micro in micros"
:key="micro.id"
:id="micro.id"
:author="micro.user?.nickname || micro.user?.username || '博主'"
:content="micro.content"
:images="parseImages(micro.images)"
:tags="parseTags(micro.tags)"
:created-at="micro.created_at"
:like-count="micro.like_count"
:comment-count="micro.comment_count"
:is-liked="micro.is_liked"
:api-base-url="apiBaseUrl"
@like="handleLikeUpdate"
@tag-click="handleTagClick"
/>
</div>
<!-- 加载更多 -->
<div v-if="pagination.totalPage > 1" class="text-center py-4">
<button
v-if="pagination.page < pagination.totalPage"
@click="loadMore"
:disabled="loadingMore"
class="btn-secondary"
>
{{ loadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import MicroPost from './MicroPost.vue';
interface Props {
apiBaseUrl?: string;
imageUploadUrl?: string;
imageUploadToken?: string;
}
const props = withDefaults(defineProps<Props>(), {
apiBaseUrl: 'http://localhost:8080/api',
imageUploadUrl: 'https://picturebed.jiao77.cn/api/index.php',
imageUploadToken: 'blog',
});
// 定义事件
const emit = defineEmits<{
(e: 'published'): void;
}>();
// 状态
const micros = ref<any[]>([]);
const loading = ref(true);
const loadingMore = ref(false);
const publishing = ref(false);
const newContent = ref('');
const newImages = ref<string[]>([]);
const newTags = ref<string[]>([]);
const tagInput = ref('');
const imageInput = ref<HTMLInputElement | null>(null);
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const tagInputRef = ref<HTMLElement | null>(null);
const uploadingImages = ref(false);
const uploadProgress = ref(0);
const showEmojiPicker = ref(false);
const showTagInput = ref(false);
const currentTag = ref('');
// 常用 emoji 列表
const commonEmojis = [
'😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂',
'😉', '😌', '😍', '🥰', '😘', '😋', '😛', '😜', '🤪', '😝',
'🤗', '🤔', '🤭', '🤫', '🤥', '😶', '😐', '😑', '😏', '😒',
'🙄', '😬', '😮', '🥱', '😴', '🤤', '😷', '🤒', '🤕', '🤢',
'👍', '👎', '👏', '🙌', '🤝', '🙏', '💪', '🎉', '🎊', '💯',
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '💔', '❣️', '💕',
'🔥', '⭐', '🌟', '✨', '💫', '🎯', '🏆', '🚀', '💡', '📌',
];
const pagination = ref({
page: 1,
pageSize: 20,
total: 0,
totalPage: 0,
});
// 计算属性
const isLoggedIn = computed(() => {
if (typeof window === 'undefined') return false;
return !!localStorage.getItem('token');
});
// 获取认证头
function getAuthHeaders(): Record<string, string> {
if (typeof window === 'undefined') return {};
const token = localStorage.getItem('token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
// 加载微语列表
async function loadMicros(page = 1) {
try {
let url = `${props.apiBaseUrl}/micro?page=${page}&page_size=${pagination.value.pageSize}`;
if (currentTag.value) {
url += `&tag=${encodeURIComponent(currentTag.value)}`;
}
const response = await fetch(url, { headers: getAuthHeaders() });
if (response.ok) {
const data = await response.json();
if (page === 1) {
micros.value = data.data || [];
} else {
micros.value.push(...(data.data || []));
}
pagination.value = data.pagination || pagination.value;
}
} catch (error) {
console.error('Failed to load micros:', error);
} finally {
loading.value = false;
loadingMore.value = false;
}
}
// 加载更多
async function loadMore() {
loadingMore.value = true;
await loadMicros(pagination.value.page + 1);
}
// 发布微语
async function publishMicro() {
if (!newContent.value.trim() || publishing.value) return;
// 检查登录状态
if (!isLoggedIn.value) {
alert('请先登录');
return;
}
publishing.value = true;
try {
const response = await fetch(`${props.apiBaseUrl}/micro`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders(),
},
body: JSON.stringify({
content: newContent.value,
images: newImages.value,
tags: newTags.value,
}),
});
if (response.ok) {
const data = await response.json();
// 添加到列表开头
micros.value.unshift(data);
// 清空输入
newContent.value = '';
newImages.value = [];
newTags.value = [];
// 触发事件通知侧边栏刷新
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('micro-published'));
}
} else {
const error = await response.json();
alert(error.error || '发布失败');
}
} catch (error) {
console.error('Failed to publish:', error);
alert('发布失败,请稍后重试');
} finally {
publishing.value = false;
}
}
// 触发图片上传
function triggerImageUpload() {
imageInput.value?.click();
}
// 处理图片选择
async function handleImageSelect(event: Event) {
const target = event.target as HTMLInputElement;
const files = target.files;
if (!files || files.length === 0) return;
uploadingImages.value = true;
uploadProgress.value = 0;
// 模拟进度条:使用 1-e^(-x) 函数约5秒完成
const totalDuration = 5000; // 5秒
const interval = 100; // 每100ms更新一次
let elapsed = 0;
const progressTimer = setInterval(() => {
elapsed += interval;
// 1 - e^(-t/1000) 函数t单位ms除以1000让曲线更平滑
const progress = (1 - Math.exp(-elapsed / 1500)) * 100;
uploadProgress.value = Math.min(Math.round(progress), 95); // 最多到95%真正完成后到100%
}, interval);
try {
for (const file of Array.from(files)) {
if (!file.type.startsWith('image/')) continue;
if (file.size > 5 * 1024 * 1024) {
alert('图片大小不能超过 5MB');
continue;
}
const formData = new FormData();
formData.append('image', file);
formData.append('token', props.imageUploadToken);
const response = await fetch(props.imageUploadUrl, {
method: 'POST',
body: formData,
});
const data = await response.json();
if (data.result === 'success' && data.url) {
newImages.value.push(data.url);
}
}
} catch (error) {
console.error('Failed to upload image:', error);
} finally {
clearInterval(progressTimer);
uploadProgress.value = 100;
// 短暂显示100%后隐藏
setTimeout(() => {
uploadingImages.value = false;
uploadProgress.value = 0;
}, 300);
}
// 清空 input
target.value = '';
}
// 移除图片
function removeImage(index: number) {
newImages.value.splice(index, 1);
}
// 添加标签
function addTag() {
const tag = tagInput.value.trim();
if (tag && !newTags.value.includes(tag) && newTags.value.length < 5) {
newTags.value.push(tag);
tagInput.value = '';
}
}
// 移除标签
function removeTag(index: number) {
newTags.value.splice(index, 1);
}
// 插入 emoji
function insertEmoji(emoji: string) {
if (textareaRef.value) {
const start = textareaRef.value.selectionStart;
const end = textareaRef.value.selectionEnd;
const text = newContent.value;
newContent.value = text.substring(0, start) + emoji + text.substring(end);
showEmojiPicker.value = false;
// 恢复焦点
textareaRef.value.focus();
textareaRef.value.setSelectionRange(start + emoji.length, start + emoji.length);
} else {
newContent.value += emoji;
showEmojiPicker.value = false;
}
}
// 解析图片 JSON
function parseImages(imagesJson: string): string[] {
if (!imagesJson) return [];
try {
return JSON.parse(imagesJson);
} catch {
return [];
}
}
// 解析标签 JSON
function parseTags(tagsJson: string): string[] {
if (!tagsJson) return [];
try {
return JSON.parse(tagsJson);
} catch {
return [];
}
}
// 处理标签点击
function handleTagClick(tag: string) {
// 触发事件通知侧边栏和父组件
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('tag-filter', { detail: tag }));
}
}
// 处理点赞更新
function handleLikeUpdate(id: number, liked: boolean) {
const micro = micros.value.find(m => m.id === id);
if (micro) {
micro.is_liked = liked;
micro.like_count += liked ? 1 : -1;
}
}
onMounted(() => {
loadMicros();
// 点击外部关闭 emoji 选择器和标签输入
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
// 关闭 emoji 选择器
if (!target.closest('.relative') || !target.closest('button')) {
showEmojiPicker.value = false;
}
// 关闭标签输入面板
if (tagInputRef.value && !tagInputRef.value.contains(target)) {
showTagInput.value = false;
}
});
// 监听标签筛选事件
window.addEventListener('tag-filter', ((e: CustomEvent) => {
currentTag.value = e.detail || '';
loadMicros(1);
}) as EventListener);
});
</script>