feat: 为评论区添加图片上传和 Emoji 选择器功能
主要变更: - 新增图片上传功能,支持上传到外部图床并插入 Markdown 格式图片 - 新增灯箱功能,支持图片点击放大、滚轮缩放、拖拽移动 - 新增 Emoji 选择器,提供 70 个常用表情快捷插入 - 移除未使用的 pages 集合定义以消除警告 技术细节: - 图床 API 支持自定义配置 (URL/Token) - 灯箱缩放范围 50%-300%,带平滑过渡动画 - Emoji 选择器支持点击外部自动关闭 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
@@ -10,7 +10,63 @@
|
|||||||
class="w-full p-4 border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
|
class="w-full p-4 border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
|
||||||
rows="4"
|
rows="4"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="flex justify-end mt-2">
|
<!-- 工具栏 -->
|
||||||
|
<div class="flex justify-between items-center mt-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Emoji 按钮 -->
|
||||||
|
<div class="relative emoji-picker-container">
|
||||||
|
<button
|
||||||
|
@click.stop="showEmojiPicker = !showEmojiPicker"
|
||||||
|
class="flex items-center gap-1 px-3 py-1.5 text-sm text-foreground/60 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded transition-colors"
|
||||||
|
title="添加表情"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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>
|
||||||
|
<span>表情</span>
|
||||||
|
</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"
|
||||||
|
:disabled="uploadingImage"
|
||||||
|
class="flex items-center gap-1 px-3 py-1.5 text-sm text-foreground/60 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded transition-colors disabled:opacity-50"
|
||||||
|
title="上传图片"
|
||||||
|
>
|
||||||
|
<svg v-if="!uploadingImage" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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>
|
||||||
|
<svg v-else class="w-5 h-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ uploadingImage ? '上传中...' : '图片' }}</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="imageInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleImageUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="submitComment"
|
@click="submitComment"
|
||||||
:disabled="!newComment.trim() || submitting"
|
:disabled="!newComment.trim() || submitting"
|
||||||
@@ -19,6 +75,8 @@
|
|||||||
{{ submitting ? '发布中...' : '发布评论' }}
|
{{ submitting ? '发布中...' : '发布评论' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 上传错误提示 -->
|
||||||
|
<p v-if="uploadError" class="text-sm text-red-500 mt-1">{{ uploadError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 未登录提示 -->
|
<!-- 未登录提示 -->
|
||||||
@@ -56,7 +114,7 @@
|
|||||||
<span class="font-medium">{{ getDisplayName(comment.user) }}</span>
|
<span class="font-medium">{{ getDisplayName(comment.user) }}</span>
|
||||||
<span class="text-xs text-foreground/40">{{ formatDate(comment.created_at) }}</span>
|
<span class="text-xs text-foreground/40">{{ formatDate(comment.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-content prose prose-sm dark:prose-invert max-w-none" v-html="renderMarkdown(comment.content)"></div>
|
<div class="comment-content prose prose-sm dark:prose-invert max-w-none" v-html="renderMarkdown(comment.content)" @click="handleContentClick"></div>
|
||||||
|
|
||||||
<!-- 回复按钮 -->
|
<!-- 回复按钮 -->
|
||||||
<button
|
<button
|
||||||
@@ -76,8 +134,64 @@
|
|||||||
class="w-full p-3 border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none text-sm"
|
class="w-full p-3 border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none text-sm"
|
||||||
rows="3"
|
rows="3"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="flex justify-end gap-2 mt-2">
|
<div class="flex justify-between items-center mt-2">
|
||||||
<button @click="replyTo = null" class="btn-secondary text-sm">取消</button>
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Emoji 按钮 -->
|
||||||
|
<div class="relative emoji-picker-container">
|
||||||
|
<button
|
||||||
|
@click.stop="showReplyEmojiPicker = showReplyEmojiPicker === comment.id ? null : comment.id"
|
||||||
|
class="flex items-center gap-1 px-2 py-1 text-xs text-foreground/60 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded transition-colors"
|
||||||
|
title="添加表情"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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>
|
||||||
|
<span>表情</span>
|
||||||
|
</button>
|
||||||
|
<!-- Emoji 选择面板 -->
|
||||||
|
<div
|
||||||
|
v-if="showReplyEmojiPicker === comment.id"
|
||||||
|
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 flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
v-for="emoji in commonEmojis"
|
||||||
|
:key="emoji"
|
||||||
|
@click="insertReplyEmoji(emoji, comment.id)"
|
||||||
|
class="w-7 h-7 flex items-center justify-center text-lg hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{{ emoji }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 图片上传按钮 -->
|
||||||
|
<button
|
||||||
|
@click="triggerReplyImageUpload(comment.id)"
|
||||||
|
:disabled="uploadingReplyImage"
|
||||||
|
class="flex items-center gap-1 px-2 py-1 text-xs text-foreground/60 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded transition-colors disabled:opacity-50"
|
||||||
|
title="上传图片"
|
||||||
|
>
|
||||||
|
<svg v-if="!uploadingReplyImage" xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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>
|
||||||
|
<svg v-else class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ uploadingReplyImage ? '上传中...' : '图片' }}</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
:ref="el => replyImageInputs[comment.id] = el as HTMLInputElement"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
@change="(e) => handleReplyImageUpload(e, comment.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="replyTo = null; showReplyEmojiPicker = null" class="btn-secondary text-sm">取消</button>
|
||||||
<button
|
<button
|
||||||
@click="submitReply(comment.id)"
|
@click="submitReply(comment.id)"
|
||||||
:disabled="!replyContent.trim() || submitting"
|
:disabled="!replyContent.trim() || submitting"
|
||||||
@@ -87,6 +201,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 子评论 -->
|
<!-- 子评论 -->
|
||||||
<div v-if="comment.replies && comment.replies.length > 0" class="mt-4 ml-14 space-y-4">
|
<div v-if="comment.replies && comment.replies.length > 0" class="mt-4 ml-14 space-y-4">
|
||||||
@@ -103,7 +218,7 @@
|
|||||||
<span class="font-medium text-sm">{{ getDisplayName(reply.user) }}</span>
|
<span class="font-medium text-sm">{{ getDisplayName(reply.user) }}</span>
|
||||||
<span class="text-xs text-foreground/40">{{ formatDate(reply.created_at) }}</span>
|
<span class="text-xs text-foreground/40">{{ formatDate(reply.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-content prose prose-sm dark:prose-invert max-w-none text-sm" v-html="renderMarkdown(reply.content)"></div>
|
<div class="comment-content prose prose-sm dark:prose-invert max-w-none text-sm" v-html="renderMarkdown(reply.content)" @click="handleContentClick"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,11 +245,89 @@
|
|||||||
下一页
|
下一页
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 图片预览模态框(灯箱) -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="lightbox">
|
||||||
|
<div
|
||||||
|
v-if="previewImage"
|
||||||
|
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/90 backdrop-blur-sm select-none"
|
||||||
|
@click="closePreview"
|
||||||
|
@wheel.prevent="handleWheel"
|
||||||
|
>
|
||||||
|
<!-- 关闭按钮 -->
|
||||||
|
<button
|
||||||
|
class="absolute top-4 right-4 z-10 p-2 text-white/80 hover:text-white bg-white/10 hover:bg-white/20 rounded-full transition-all"
|
||||||
|
@click="closePreview"
|
||||||
|
aria-label="关闭预览"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 缩放控制按钮 -->
|
||||||
|
<div class="absolute top-4 left-4 z-10 flex gap-2">
|
||||||
|
<button
|
||||||
|
class="p-2 text-white/80 hover:text-white bg-white/10 hover:bg-white/20 rounded-full transition-all"
|
||||||
|
@click.stop="zoomOut"
|
||||||
|
:disabled="imageScale <= 0.5"
|
||||||
|
:class="{ 'opacity-50 cursor-not-allowed': imageScale <= 0.5 }"
|
||||||
|
aria-label="缩小"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM13 10H7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-3 py-2 text-white/80 hover:text-white bg-white/10 hover:bg-white/20 rounded-full transition-all text-sm min-w-[60px]"
|
||||||
|
@click.stop="resetZoom"
|
||||||
|
aria-label="重置缩放"
|
||||||
|
>
|
||||||
|
{{ Math.round(imageScale * 100) }}%
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-2 text-white/80 hover:text-white bg-white/10 hover:bg-white/20 rounded-full transition-all"
|
||||||
|
@click.stop="zoomIn"
|
||||||
|
:disabled="imageScale >= 3"
|
||||||
|
:class="{ 'opacity-50 cursor-not-allowed': imageScale >= 3 }"
|
||||||
|
aria-label="放大"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图片容器 -->
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden"
|
||||||
|
@click.stop
|
||||||
|
@mousedown="startDrag"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
ref="previewImgRef"
|
||||||
|
:src="previewImage"
|
||||||
|
class="rounded-lg shadow-2xl transition-transform duration-100"
|
||||||
|
:style="imageStyle"
|
||||||
|
@click.stop
|
||||||
|
@dragstart.prevent
|
||||||
|
alt="图片预览"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 提示文字 -->
|
||||||
|
<p class="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
|
||||||
|
滚轮缩放 · 拖拽移动 · ESC 或点击背景关闭
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
|
||||||
// 配置 marked 选项 - 安全模式,禁止 HTML 标签
|
// 配置 marked 选项 - 安全模式,禁止 HTML 标签
|
||||||
@@ -146,9 +339,13 @@ marked.setOptions({
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
postId: string;
|
postId: string;
|
||||||
apiBaseUrl?: string;
|
apiBaseUrl?: string;
|
||||||
|
imageUploadUrl?: string;
|
||||||
|
imageUploadToken?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const apiBaseUrl = props.apiBaseUrl || 'http://localhost:8080/api';
|
const apiBaseUrl = props.apiBaseUrl || 'http://localhost:8080/api';
|
||||||
|
const imageUploadUrl = props.imageUploadUrl || 'https://picturebed.jiao77.cn/api/index.php';
|
||||||
|
const imageUploadToken = props.imageUploadToken || 'jiao77';
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const comments = ref<any[]>([]);
|
const comments = ref<any[]>([]);
|
||||||
@@ -164,12 +361,53 @@ const pagination = ref({
|
|||||||
totalPage: 0,
|
totalPage: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 图片上传相关状态
|
||||||
|
const imageInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const uploadingImage = ref(false);
|
||||||
|
const uploadError = ref('');
|
||||||
|
const previewImage = ref<string | null>(null);
|
||||||
|
|
||||||
|
// 回复图片上传相关状态
|
||||||
|
const replyImageInputs = ref<Record<number, HTMLInputElement>>({});
|
||||||
|
const uploadingReplyImage = ref(false);
|
||||||
|
|
||||||
|
// Emoji 选择器状态
|
||||||
|
const showEmojiPicker = ref(false);
|
||||||
|
const showReplyEmojiPicker = ref<number | null>(null);
|
||||||
|
|
||||||
|
// 常用 emoji 列表
|
||||||
|
const commonEmojis = [
|
||||||
|
'😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂',
|
||||||
|
'😉', '😌', '😍', '🥰', '😘', '😋', '😛', '😜', '🤪', '😝',
|
||||||
|
'🤗', '🤔', '🤭', '🤫', '🤥', '😶', '😐', '😑', '😏', '😒',
|
||||||
|
'🙄', '😬', '😮', '🥱', '😴', '🤤', '😷', '🤒', '🤕', '🤢',
|
||||||
|
'👍', '👎', '👏', '🙌', '🤝', '🙏', '💪', '🎉', '🎊', '💯',
|
||||||
|
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '💔', '❣️', '💕',
|
||||||
|
'🔥', '⭐', '🌟', '✨', '💫', '🎯', '🏆', '🚀', '💡', '📌',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 灯箱缩放和拖拽状态
|
||||||
|
const previewImgRef = ref<HTMLImageElement | null>(null);
|
||||||
|
const imageScale = ref(1);
|
||||||
|
const imageTranslate = ref({ x: 0, y: 0 });
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const dragStart = ref({ x: 0, y: 0 });
|
||||||
|
const dragOffset = ref({ x: 0, y: 0 });
|
||||||
|
|
||||||
// 计算属性 - 仅在浏览器环境中访问 localStorage
|
// 计算属性 - 仅在浏览器环境中访问 localStorage
|
||||||
const isLoggedIn = computed(() => {
|
const isLoggedIn = computed(() => {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
return !!localStorage.getItem('token');
|
return !!localStorage.getItem('token');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 图片样式计算属性
|
||||||
|
const imageStyle = computed(() => ({
|
||||||
|
transform: `translate(${imageTranslate.value.x}px, ${imageTranslate.value.y}px) scale(${imageScale.value})`,
|
||||||
|
cursor: isDragging.value ? 'grabbing' : 'grab',
|
||||||
|
maxWidth: '90vw',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
}));
|
||||||
|
|
||||||
// 获取认证头
|
// 获取认证头
|
||||||
function getAuthHeaders(): Record<string, string> {
|
function getAuthHeaders(): Record<string, string> {
|
||||||
if (typeof window === 'undefined') return {};
|
if (typeof window === 'undefined') return {};
|
||||||
@@ -271,6 +509,161 @@ function loadPage(page: number) {
|
|||||||
loadComments();
|
loadComments();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 触发图片上传
|
||||||
|
function triggerImageUpload() {
|
||||||
|
if (imageInput.value) {
|
||||||
|
imageInput.value.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理图片上传
|
||||||
|
async function handleImageUpload(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const file = target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// 验证文件类型
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
uploadError.value = '请选择图片文件';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件大小 (最大 5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
uploadError.value = '图片大小不能超过 5MB';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadError.value = '';
|
||||||
|
uploadingImage.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
formData.append('token', imageUploadToken);
|
||||||
|
|
||||||
|
const response = await fetch(imageUploadUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.result === 'success' && data.url) {
|
||||||
|
// 插入 Markdown 图片语法到评论内容
|
||||||
|
const imageName = data.srcName || file.name.replace(/\.[^/.]+$/, '');
|
||||||
|
const markdown = ``;
|
||||||
|
|
||||||
|
// 在光标位置插入或追加到末尾
|
||||||
|
const textarea = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
if (textarea) {
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const text = newComment.value;
|
||||||
|
newComment.value = text.substring(0, start) + markdown + text.substring(end);
|
||||||
|
// 恢复焦点
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(start + markdown.length, start + markdown.length);
|
||||||
|
} else {
|
||||||
|
newComment.value += markdown;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
uploadError.value = data.message || '上传失败,请稍后重试';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload image:', error);
|
||||||
|
uploadError.value = '上传失败,请检查网络连接';
|
||||||
|
} finally {
|
||||||
|
uploadingImage.value = false;
|
||||||
|
// 清空 input 以便重复选择同一文件
|
||||||
|
if (target) {
|
||||||
|
target.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入 emoji 到评论
|
||||||
|
function insertEmoji(emoji: string) {
|
||||||
|
const textarea = document.querySelector('.comment-section textarea') as HTMLTextAreaElement;
|
||||||
|
if (textarea) {
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const text = newComment.value;
|
||||||
|
newComment.value = text.substring(0, start) + emoji + text.substring(end);
|
||||||
|
showEmojiPicker.value = false;
|
||||||
|
// 恢复焦点
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(start + emoji.length, start + emoji.length);
|
||||||
|
} else {
|
||||||
|
newComment.value += emoji;
|
||||||
|
showEmojiPicker.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入 emoji 到回复
|
||||||
|
function insertReplyEmoji(emoji: string, commentId: number) {
|
||||||
|
replyContent.value += emoji;
|
||||||
|
showReplyEmojiPicker.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发回复图片上传
|
||||||
|
function triggerReplyImageUpload(commentId: number) {
|
||||||
|
const input = replyImageInputs.value[commentId];
|
||||||
|
if (input) {
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理回复图片上传
|
||||||
|
async function handleReplyImageUpload(event: Event, commentId: number) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const file = target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// 验证文件类型
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('请选择图片文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件大小 (最大 5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('图片大小不能超过 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadingReplyImage.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
formData.append('token', imageUploadToken);
|
||||||
|
|
||||||
|
const response = await fetch(imageUploadUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.result === 'success' && data.url) {
|
||||||
|
const imageName = data.srcName || file.name.replace(/\.[^/.]+$/, '');
|
||||||
|
const markdown = ``;
|
||||||
|
replyContent.value += markdown;
|
||||||
|
} else {
|
||||||
|
alert(data.message || '上传失败,请稍后重试');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload image:', error);
|
||||||
|
alert('上传失败,请检查网络连接');
|
||||||
|
} finally {
|
||||||
|
uploadingReplyImage.value = false;
|
||||||
|
if (target) {
|
||||||
|
target.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取用户首字母
|
// 获取用户首字母
|
||||||
function getInitial(user: any): string {
|
function getInitial(user: any): string {
|
||||||
if (!user) return '?';
|
if (!user) return '?';
|
||||||
@@ -318,8 +711,142 @@ function renderMarkdown(content: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理评论区域点击事件(事件委托)
|
||||||
|
function handleContentClick(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.tagName === 'IMG') {
|
||||||
|
const img = target as HTMLImageElement;
|
||||||
|
previewImage.value = img.src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭图片预览
|
||||||
|
function closePreview() {
|
||||||
|
previewImage.value = null;
|
||||||
|
// 重置缩放和位置
|
||||||
|
imageScale.value = 1;
|
||||||
|
imageTranslate.value = { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 放大
|
||||||
|
function zoomIn() {
|
||||||
|
if (imageScale.value < 3) {
|
||||||
|
imageScale.value = Math.min(3, imageScale.value + 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缩小
|
||||||
|
function zoomOut() {
|
||||||
|
if (imageScale.value > 0.5) {
|
||||||
|
imageScale.value = Math.max(0.5, imageScale.value - 0.25);
|
||||||
|
// 缩小时限制图片不超出边界
|
||||||
|
limitTranslate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置缩放
|
||||||
|
function resetZoom() {
|
||||||
|
imageScale.value = 1;
|
||||||
|
imageTranslate.value = { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚轮缩放
|
||||||
|
function handleWheel(event: WheelEvent) {
|
||||||
|
if (!previewImage.value) return;
|
||||||
|
|
||||||
|
const delta = event.deltaY > 0 ? -0.1 : 0.1;
|
||||||
|
const newScale = Math.max(0.5, Math.min(3, imageScale.value + delta));
|
||||||
|
|
||||||
|
if (newScale !== imageScale.value) {
|
||||||
|
imageScale.value = newScale;
|
||||||
|
if (newScale < 1) {
|
||||||
|
limitTranslate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始拖拽
|
||||||
|
function startDrag(event: MouseEvent) {
|
||||||
|
if (imageScale.value <= 1) return;
|
||||||
|
|
||||||
|
isDragging.value = true;
|
||||||
|
dragStart.value = { x: event.clientX, y: event.clientY };
|
||||||
|
dragOffset.value = { ...imageTranslate.value };
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleDrag);
|
||||||
|
document.addEventListener('mouseup', stopDrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽中
|
||||||
|
function handleDrag(event: MouseEvent) {
|
||||||
|
if (!isDragging.value) return;
|
||||||
|
|
||||||
|
const dx = event.clientX - dragStart.value.x;
|
||||||
|
const dy = event.clientY - dragStart.value.y;
|
||||||
|
|
||||||
|
imageTranslate.value = {
|
||||||
|
x: dragOffset.value.x + dx,
|
||||||
|
y: dragOffset.value.y + dy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止拖拽
|
||||||
|
function stopDrag() {
|
||||||
|
isDragging.value = false;
|
||||||
|
document.removeEventListener('mousemove', handleDrag);
|
||||||
|
document.removeEventListener('mouseup', stopDrag);
|
||||||
|
limitTranslate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制拖拽范围
|
||||||
|
function limitTranslate() {
|
||||||
|
if (!previewImgRef.value) return;
|
||||||
|
|
||||||
|
const img = previewImgRef.value;
|
||||||
|
const maxOffsetX = (img.naturalWidth * imageScale.value - window.innerWidth * 0.9) / 2;
|
||||||
|
const maxOffsetY = (img.naturalHeight * imageScale.value - window.innerHeight * 0.9) / 2;
|
||||||
|
|
||||||
|
imageTranslate.value = {
|
||||||
|
x: Math.max(-maxOffsetX, Math.min(maxOffsetX, imageTranslate.value.x)),
|
||||||
|
y: Math.max(-maxOffsetY, Math.min(maxOffsetY, imageTranslate.value.y)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果图片比视口小,不允许移动
|
||||||
|
if (img.naturalWidth * imageScale.value <= window.innerWidth * 0.9) {
|
||||||
|
imageTranslate.value.x = 0;
|
||||||
|
}
|
||||||
|
if (img.naturalHeight * imageScale.value <= window.innerHeight * 0.9) {
|
||||||
|
imageTranslate.value.y = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理键盘事件
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && previewImage.value) {
|
||||||
|
previewImage.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadComments();
|
loadComments();
|
||||||
|
// 监听键盘事件
|
||||||
|
window.addEventListener('keydown', handleKeydown);
|
||||||
|
// 点击外部关闭 emoji 选择器
|
||||||
|
document.addEventListener('click', closeEmojiPickers);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 关闭所有 emoji 选择器
|
||||||
|
function closeEmojiPickers(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('.emoji-picker-container')) {
|
||||||
|
showEmojiPicker.value = false;
|
||||||
|
showReplyEmojiPicker.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown);
|
||||||
|
document.removeEventListener('click', closeEmojiPickers);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -410,7 +937,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.comment-content :deep(img) {
|
.comment-content :deep(img) {
|
||||||
@apply max-w-full rounded-lg my-2;
|
@apply max-w-full rounded-lg my-2 cursor-pointer hover:opacity-90 transition-opacity;
|
||||||
|
max-height: 400px;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-content :deep(table) {
|
.comment-content :deep(table) {
|
||||||
@@ -425,4 +954,25 @@ onMounted(() => {
|
|||||||
.comment-content :deep(th) {
|
.comment-content :deep(th) {
|
||||||
@apply bg-gray-100 dark:bg-gray-800 font-bold;
|
@apply bg-gray-100 dark:bg-gray-800 font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 灯箱过渡动画 */
|
||||||
|
.lightbox-enter-active,
|
||||||
|
.lightbox-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-enter-active img,
|
||||||
|
.lightbox-leave-active img {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-enter-from,
|
||||||
|
.lightbox-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-enter-from img,
|
||||||
|
.lightbox-leave-to img {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -22,19 +22,6 @@ const blogCollection = defineCollection({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 页面集合 (如关于页面等)
|
|
||||||
const pagesCollection = defineCollection({
|
|
||||||
type: 'content',
|
|
||||||
schema: z.object({
|
|
||||||
title: z.string(),
|
|
||||||
description: z.string().optional(),
|
|
||||||
layout: z.string().optional(),
|
|
||||||
showInNav: z.boolean().default(false),
|
|
||||||
order: z.number().default(0),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const collections = {
|
export const collections = {
|
||||||
blog: blogCollection,
|
blog: blogCollection,
|
||||||
pages: pagesCollection,
|
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user