朋友圈

This commit is contained in:
2025-12-08 09:07:56 +08:00
parent c26c3ad1be
commit 05a8422007
23 changed files with 3229 additions and 9 deletions

View File

@@ -211,3 +211,6 @@ function viewAvatar() {
</style>

View File

@@ -0,0 +1,247 @@
<template>
<div class="moment-card bg-white">
<div class="p-4">
<!-- 用户信息 -->
<div class="flex items-start gap-3">
<Avatar
:name="moment.user?.name || ''"
:avatar="moment.user?.avatar"
:size="44"
class="cursor-pointer"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<span class="font-medium text-gray-800">{{ moment.user?.name }}</span>
<!-- 更多操作 -->
<div v-if="isOwner" class="relative">
<button
@click.stop="showMenu = !showMenu"
class="p-1 text-gray-400 hover:text-gray-600"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
<!-- 下拉菜单 -->
<div
v-if="showMenu"
class="absolute right-0 top-full mt-1 bg-white rounded-lg shadow-lg border border-gray-200 py-1 min-w-[100px] z-10"
>
<button
@click.stop="handleDelete"
class="w-full px-4 py-2 text-left text-red-500 hover:bg-gray-50 text-sm"
>
删除
</button>
</div>
</div>
</div>
<p class="text-gray-400 text-sm">{{ formatTime(moment.created_at) }}</p>
</div>
</div>
<!-- 文字内容 -->
<div class="mt-3">
<p
class="text-gray-700 whitespace-pre-wrap break-words"
:class="{ 'line-clamp-5': !showFullContent && !expanded }"
>
{{ moment.content }}
</p>
<!-- 展开/收起 -->
<button
v-if="!showFullContent && isLongContent"
@click.stop="expanded = !expanded"
class="text-blue-500 text-sm mt-1"
>
{{ expanded ? '收起' : '展开全文' }}
</button>
</div>
<!-- 媒体内容 -->
<div v-if="mediaUrls.length > 0" class="mt-3">
<!-- 图片 -->
<div v-if="moment.media_type === 1" class="grid gap-1" :class="gridClass">
<div
v-for="(url, index) in displayMediaUrls"
:key="index"
class="relative aspect-square bg-gray-100 rounded overflow-hidden cursor-pointer"
@click.stop="viewMedia(index)"
>
<img :src="url" class="w-full h-full object-cover" loading="lazy" />
<!-- 更多图片数量 -->
<div
v-if="index === 3 && mediaUrls.length > 4"
class="absolute inset-0 bg-black/50 flex items-center justify-center text-white text-xl font-medium"
>
+{{ mediaUrls.length - 4 }}
</div>
</div>
</div>
<!-- 视频 -->
<div v-else-if="moment.media_type === 2" class="relative aspect-video bg-black rounded overflow-hidden">
<video
:src="mediaUrls[0]"
class="w-full h-full object-contain"
controls
preload="metadata"
@click.stop
></video>
</div>
</div>
<!-- 位置信息 -->
<div v-if="moment.location" class="mt-2 flex items-center text-gray-400 text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{{ moment.location }}
</div>
<!-- 话题标签 -->
<div v-if="topicTags.length > 0" class="mt-2 flex flex-wrap gap-2">
<span
v-for="tag in topicTags"
:key="tag"
class="text-blue-500 text-sm"
>
#{{ tag }}
</span>
</div>
<!-- 互动栏 -->
<div class="mt-3 flex items-center justify-between pt-3 border-t border-gray-100">
<!-- 点赞 -->
<button
@click.stop="$emit('like', moment.id)"
class="flex items-center gap-1 text-sm transition-colors"
:class="moment.is_liked ? 'text-red-500' : 'text-gray-500 hover:text-red-500'"
>
<svg class="w-5 h-5" :fill="moment.is_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 v-if="moment.like_count > 0">{{ moment.like_count }}</span>
</button>
<!-- 评论 -->
<button
@click.stop="$emit('comment', moment)"
class="flex items-center gap-1 text-gray-500 hover:text-blue-500 text-sm 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 v-if="moment.comment_count > 0">{{ moment.comment_count }}</span>
</button>
<!-- 分享(预留) -->
<button class="flex items-center gap-1 text-gray-500 hover:text-green-500 text-sm 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>
</button>
</div>
<!-- 点赞用户列表 -->
<div v-if="moment.likes && moment.likes.length > 0" class="mt-3 bg-gray-50 rounded-lg p-3">
<div class="flex items-center gap-1 text-sm">
<svg class="w-4 h-4 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path 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-gray-600">
{{ moment.likes.map(l => l.user?.name).filter(Boolean).join('、') }}
</span>
</div>
</div>
<!-- 评论预览 -->
<div v-if="moment.comments && moment.comments.length > 0 && !showFullContent" class="mt-2 bg-gray-50 rounded-lg p-3 space-y-2">
<div
v-for="comment in moment.comments.slice(0, 3)"
:key="comment.id"
class="text-sm"
>
<span class="font-medium text-gray-700">{{ comment.user?.name }}</span>
<span v-if="comment.reply_to_user" class="text-gray-400">
回复 <span class="text-gray-700">{{ comment.reply_to_user.name }}</span>
</span>
<span class="text-gray-600">:{{ comment.content }}</span>
</div>
<button
v-if="moment.comments.length > 3"
@click.stop="$emit('click')"
class="text-blue-500 text-sm"
>
查看全部 {{ moment.comment_count }} 条评论
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import Avatar from '@/components/common/Avatar.vue'
import { useAuthStore } from '@/stores/auth'
import { parseMediaUrls, parseTopicTags } from '@/types/moment'
import { formatRelativeTime } from '@/utils/format'
import type { Moment } from '@/types/moment'
const props = defineProps<{
moment: Moment
showFullContent?: boolean
}>()
const emit = defineEmits<{
(e: 'click'): void
(e: 'like', id: number): void
(e: 'comment', moment: Moment): void
(e: 'delete', id: number): void
}>()
const authStore = useAuthStore()
// 状态
const showMenu = ref(false)
const expanded = ref(false)
// 计算属性
const isOwner = computed(() => authStore.user?.id === props.moment.user_id)
const isLongContent = computed(() => (props.moment.content?.length || 0) > 200)
const mediaUrls = computed(() => parseMediaUrls(props.moment.media_urls))
const topicTags = computed(() => parseTopicTags(props.moment.topic_tags))
const displayMediaUrls = computed(() => mediaUrls.value.slice(0, 4))
const gridClass = computed(() => {
const count = mediaUrls.value.length
if (count === 1) return 'grid-cols-1 max-w-[280px]'
if (count === 2) return 'grid-cols-2 max-w-[320px]'
if (count === 4) return 'grid-cols-2 max-w-[320px]'
return 'grid-cols-3 max-w-[360px]'
})
// 方法
function formatTime(time: string): string {
return formatRelativeTime(time)
}
function handleDelete() {
showMenu.value = false
emit('delete', props.moment.id)
}
function viewMedia(index: number) {
// TODO: 打开媒体查看器
console.log('View media:', index)
}
</script>
<style scoped>
.line-clamp-5 {
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<div class="fixed inset-0 z-50 flex flex-col justify-end bg-black/50" @click.self="$emit('close')">
<div class="bg-white rounded-t-2xl">
<!-- 头部 -->
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<span class="text-gray-600 text-sm">
{{ replyTo ? `回复 ${replyTo.user?.name}` : '写评论' }}
</span>
<button @click="$emit('close')" class="text-gray-400">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- 输入区 -->
<div class="p-4">
<textarea
ref="inputRef"
v-model="content"
:placeholder="replyTo ? `回复 ${replyTo.user?.name}...` : '写评论...'"
class="w-full h-24 resize-none border border-gray-200 rounded-lg p-3 focus:outline-none focus:border-blue-500 text-sm"
maxlength="500"
></textarea>
<div class="flex items-center justify-between mt-3">
<span class="text-gray-400 text-xs">{{ content.length }}/500</span>
<button
@click="handleSubmit"
:disabled="!content.trim() || submitting"
class="px-6 py-2 bg-blue-500 text-white rounded-full text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ submitting ? '发送中...' : '发送' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { useMomentStore } from '@/stores/moment'
import type { MomentComment } from '@/types/moment'
const props = defineProps<{
momentId: number
replyTo?: MomentComment | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'commented'): void
}>()
const momentStore = useMomentStore()
// 状态
const content = ref('')
const submitting = ref(false)
const inputRef = ref<HTMLTextAreaElement | null>(null)
// 挂载时聚焦
onMounted(() => {
nextTick(() => {
inputRef.value?.focus()
})
})
// 提交评论
async function handleSubmit() {
if (!content.value.trim() || submitting.value) return
submitting.value = true
try {
await momentStore.addComment(props.momentId, {
content: content.value.trim(),
reply_to_comment_id: props.replyTo?.id,
})
emit('commented')
} catch (error) {
// 错误已在 store 中处理
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,81 @@
<template>
<div class="comment-list">
<div
v-for="comment in comments"
:key="comment.id"
class="flex gap-3 px-4 py-3 border-b border-gray-100 last:border-b-0"
>
<!-- 头像 -->
<Avatar
:name="comment.user?.name || ''"
:avatar="comment.user?.avatar"
:size="36"
/>
<!-- 评论内容 -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between">
<div>
<span class="font-medium text-gray-800 text-sm">{{ comment.user?.name }}</span>
<span v-if="comment.reply_to_user" class="text-gray-400 text-sm">
回复 <span class="text-gray-600">{{ comment.reply_to_user.name }}</span>
</span>
</div>
<!-- 删除按钮 -->
<button
v-if="canDelete(comment)"
@click="$emit('delete', comment.id)"
class="p-1 text-gray-400 hover:text-red-500"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<!-- 评论文本 -->
<p class="text-gray-700 text-sm mt-1">{{ comment.content }}</p>
<!-- 底部操作 -->
<div class="flex items-center gap-4 mt-2">
<span class="text-gray-400 text-xs">{{ formatTime(comment.created_at) }}</span>
<button
@click="$emit('reply', comment)"
class="text-gray-400 text-xs hover:text-blue-500"
>
回复
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Avatar from '@/components/common/Avatar.vue'
import { useAuthStore } from '@/stores/auth'
import { formatRelativeTime } from '@/utils/format'
import type { MomentComment } from '@/types/moment'
const props = defineProps<{
comments: MomentComment[]
momentUserId: string
}>()
defineEmits<{
(e: 'reply', comment: MomentComment): void
(e: 'delete', commentId: number): void
}>()
const authStore = useAuthStore()
// 判断是否可以删除(评论者或动态作者)
function canDelete(comment: MomentComment): boolean {
const userId = authStore.user?.id
return userId === comment.user_id || userId === props.momentUserId
}
function formatTime(time: string): string {
return formatRelativeTime(time)
}
</script>

View File

@@ -0,0 +1,159 @@
<template>
<div class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm" @click.self="$emit('close')">
<div class="bg-panel rounded-2xl w-full max-w-lg shadow-2xl border border-gray-700 overflow-hidden animate-fade-in max-h-[80vh] flex flex-col">
<!-- 头部 -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-700 shrink-0">
<h2 class="font-bold text-white">消息通知</h2>
<div class="flex items-center gap-3">
<button
v-if="momentStore.hasUnread"
@click="markAllRead"
class="text-sm text-primary hover:text-indigo-400 transition"
>
全部已读
</button>
<button @click="$emit('close')" class="text-gray-400 hover:text-white transition">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- 通知列表 -->
<div class="flex-1 overflow-y-auto custom-scrollbar" @scroll="handleScroll">
<!-- 加载中 -->
<div v-if="momentStore.loading && momentStore.notifications.length === 0" class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- 空状态 -->
<div v-else-if="momentStore.notifications.length === 0" class="flex flex-col items-center justify-center py-16 px-4">
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mb-4">
<i class="fas fa-bell text-2xl text-gray-600"></i>
</div>
<p class="text-gray-500">暂无消息通知</p>
</div>
<!-- 通知列表 -->
<div v-else class="divide-y divide-gray-800">
<div
v-for="notification in momentStore.notifications"
:key="notification.id"
@click="handleNotificationClick(notification)"
class="flex items-start gap-3 p-4 hover:bg-white/5 cursor-pointer transition-colors"
:class="{ 'bg-primary/5': !notification.is_read }"
>
<!-- 头像 -->
<Avatar
:name="notification.from_user?.name || ''"
:avatar="notification.from_user?.avatar"
:size="40"
/>
<!-- 内容 -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-200">{{ notification.from_user?.name }}</span>
<span class="text-gray-500 text-sm">{{ getNotificationText(notification.type) }}</span>
</div>
<!-- 评论内容 -->
<p v-if="notification.comment" class="text-gray-400 text-sm mt-1 line-clamp-2">
{{ notification.comment.content }}
</p>
<!-- 动态预览 -->
<div v-if="notification.moment" class="mt-2 p-2 bg-white/5 rounded-lg text-sm text-gray-500 line-clamp-1">
{{ notification.moment.content || '[图片/视频]' }}
</div>
<!-- 时间 -->
<p class="text-gray-600 text-xs mt-2">{{ formatTime(notification.created_at) }}</p>
</div>
<!-- 未读标记 -->
<div v-if="!notification.is_read" class="w-2 h-2 rounded-full bg-primary shrink-0 mt-2"></div>
</div>
</div>
<!-- 加载更多 -->
<div v-if="momentStore.loading && momentStore.notifications.length > 0" class="flex justify-center py-4">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
</div>
<!-- 没有更多 -->
<div v-if="!momentStore.notifPagination.hasMore && momentStore.notifications.length > 0" class="text-center py-4 text-gray-600 text-sm">
没有更多了
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useMomentStore } from '@/stores/moment'
import Avatar from '@/components/common/Avatar.vue'
import { NotificationTypeText } from '@/types/moment'
import { formatRelativeTime } from '@/utils/format'
import type { MomentNotification } from '@/types/moment'
const emit = defineEmits<{
(e: 'close'): void
}>()
const momentStore = useMomentStore()
// 初始化
onMounted(() => {
momentStore.fetchNotifications(true)
})
// 方法
function getNotificationText(type: number): string {
return NotificationTypeText[type] || ''
}
function formatTime(time: string): string {
return formatRelativeTime(time)
}
function handleScroll(e: Event) {
const target = e.target as HTMLElement
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight
if (scrollBottom < 100 && !momentStore.loading) {
momentStore.fetchMoreNotifications()
}
}
async function handleNotificationClick(notification: MomentNotification) {
// 标记已读
if (!notification.is_read) {
await momentStore.markAsRead([notification.id])
}
// 关闭弹窗,让父组件选中对应动态
emit('close')
}
async function markAllRead() {
await momentStore.markAllAsRead()
}
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700 rounded-full; }
.animate-fade-in { animation: fadeIn 0.3s ease-out; }
@keyframes fadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,355 @@
<template>
<div class="moment-panel h-full flex flex-col bg-dark">
<!-- 头部 -->
<div class="h-16 border-b border-gray-800 flex items-center justify-between px-6 bg-panel/90 backdrop-blur shrink-0">
<h2 class="text-lg font-bold text-white">朋友圈</h2>
<div class="flex items-center gap-2">
<!-- 通知入口 -->
<button
@click="showNotifications = true"
class="relative p-2.5 text-gray-400 hover:text-primary rounded-xl hover:bg-white/5 transition"
title="消息通知"
>
<i class="fas fa-bell text-lg"></i>
<span
v-if="momentStore.unreadCount > 0"
class="absolute -top-0.5 -right-0.5 bg-red-500 text-white text-[10px] rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1"
>
{{ momentStore.unreadCount > 99 ? '99+' : momentStore.unreadCount }}
</span>
</button>
<!-- 发布按钮 -->
<button
@click="showPublisher = true"
class="p-2.5 text-gray-400 hover:text-primary rounded-xl hover:bg-white/5 transition"
title="发布动态"
>
<i class="fas fa-plus text-lg"></i>
</button>
</div>
</div>
<!-- 动态流 -->
<div class="flex-1 overflow-y-auto custom-scrollbar" @scroll="handleScroll">
<!-- 加载中 -->
<div v-if="momentStore.loading && momentStore.moments.length === 0" class="flex justify-center py-16">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-primary"></div>
</div>
<!-- 空状态 -->
<div v-else-if="momentStore.moments.length === 0" class="flex flex-col items-center justify-center py-24 px-4">
<div class="w-24 h-24 bg-panel rounded-full flex items-center justify-center mb-6 border border-gray-700 shadow-2xl">
<i class="fas fa-camera text-4xl text-gray-600"></i>
</div>
<p class="text-gray-400 text-lg">暂无动态</p>
<p class="text-gray-600 text-sm mt-2">快去发布你的第一条动态吧</p>
<button
@click="showPublisher = true"
class="mt-6 px-6 py-2.5 bg-primary hover:bg-indigo-600 text-white rounded-full transition"
>
发布动态
</button>
</div>
<!-- 动态列表 -->
<div v-else class="max-w-2xl mx-auto">
<div
v-for="moment in momentStore.moments"
:key="moment.id"
class="border-b border-gray-800 p-6 hover:bg-white/[0.02] transition-colors"
>
<!-- 用户信息 -->
<div class="flex items-start gap-4">
<Avatar
:name="moment.user?.name || ''"
:avatar="moment.user?.avatar"
:size="48"
class="shrink-0"
/>
<div class="flex-1 min-w-0">
<!-- 名字和时间 -->
<div class="flex items-center justify-between">
<span class="font-bold text-gray-100">{{ moment.user?.name }}</span>
<div class="flex items-center gap-2">
<span class="text-gray-500 text-sm">{{ formatTime(moment.created_at) }}</span>
<button
v-if="isOwner(moment)"
@click="handleDelete(moment)"
class="text-gray-500 hover:text-red-500 p-1 transition opacity-0 group-hover:opacity-100"
title="删除"
>
<i class="fas fa-trash-alt text-sm"></i>
</button>
</div>
</div>
<!-- 文字内容 -->
<p v-if="moment.content" class="text-gray-200 mt-3 whitespace-pre-wrap break-words leading-relaxed">
{{ moment.content }}
</p>
<!-- 媒体内容 -->
<div v-if="getMediaUrls(moment).length > 0" class="mt-4">
<!-- 图片 -->
<div v-if="moment.media_type === 1" class="grid gap-2" :class="getGridClass(getMediaUrls(moment).length)">
<div
v-for="(url, index) in getMediaUrls(moment)"
:key="index"
class="relative aspect-square bg-gray-800 rounded-xl overflow-hidden cursor-pointer group"
>
<img :src="url" class="w-full h-full object-cover transition-transform group-hover:scale-105" loading="lazy" />
</div>
</div>
<!-- 视频 -->
<div v-else-if="moment.media_type === 2" class="relative aspect-video bg-black rounded-xl overflow-hidden max-w-lg">
<video :src="getMediaUrls(moment)[0]" class="w-full h-full object-contain" controls preload="metadata"></video>
</div>
</div>
<!-- 位置信息 -->
<div v-if="moment.location" class="mt-3 flex items-center text-gray-500 text-sm">
<i class="fas fa-map-marker-alt mr-2 text-primary/60"></i>
{{ moment.location }}
</div>
<!-- 互动栏 -->
<div class="mt-4 flex items-center gap-6">
<button
@click="handleLike(moment)"
class="flex items-center gap-2 text-sm transition-colors"
:class="moment.is_liked ? 'text-red-500' : 'text-gray-500 hover:text-red-500'"
>
<i class="fas fa-heart"></i>
<span>{{ moment.like_count || 0 }}</span>
</button>
<button
@click="openCommentInput(moment)"
class="flex items-center gap-2 text-sm text-gray-500 hover:text-primary transition-colors"
>
<i class="fas fa-comment"></i>
<span>{{ moment.comment_count || 0 }}</span>
</button>
</div>
<!-- 点赞用户 -->
<div v-if="moment.likes && moment.likes.length > 0" class="mt-4 p-3 bg-white/5 rounded-xl">
<div class="flex items-center gap-2 text-sm">
<i class="fas fa-heart text-red-500"></i>
<span class="text-gray-400 line-clamp-1">
{{ moment.likes.map(l => l.user?.name).filter(Boolean).join('、') }}
</span>
</div>
</div>
<!-- 评论列表 -->
<div v-if="moment.comments && moment.comments.length > 0" class="mt-3 space-y-2">
<div
v-for="comment in moment.comments.slice(0, expandedMoments.has(moment.id) ? undefined : 3)"
:key="comment.id"
class="flex items-start gap-2 p-2 bg-white/5 rounded-lg text-sm"
>
<span class="font-medium text-gray-300 shrink-0">{{ comment.user?.name }}</span>
<span v-if="comment.reply_to_user" class="text-gray-500 shrink-0">
回复 <span class="text-gray-400">{{ comment.reply_to_user.name }}</span>
</span>
<span class="text-gray-500">:</span>
<span class="text-gray-400 flex-1 break-words">{{ comment.content }}</span>
</div>
<!-- 展开更多评论 -->
<button
v-if="moment.comments.length > 3 && !expandedMoments.has(moment.id)"
@click="expandedMoments.add(moment.id)"
class="text-primary text-sm hover:underline"
>
查看全部 {{ moment.comments.length }} 条评论
</button>
</div>
</div>
</div>
</div>
<!-- 加载更多 -->
<div v-if="momentStore.loading && momentStore.moments.length > 0" class="flex justify-center py-6">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- 没有更多 -->
<div v-if="!momentStore.pagination.hasMore && momentStore.moments.length > 0" class="text-center py-8 text-gray-600 text-sm">
— 没有更多了 —
</div>
</div>
</div>
<!-- 发布动态弹窗 -->
<MomentPublisherDark
v-if="showPublisher"
@close="showPublisher = false"
@published="onPublished"
/>
<!-- 通知弹窗 -->
<MomentNotificationPanel
v-if="showNotifications"
@close="showNotifications = false"
/>
<!-- 评论输入弹窗 -->
<div
v-if="commentingMoment"
class="fixed inset-0 bg-black/70 z-[60] flex items-end justify-center backdrop-blur-sm"
@click.self="closeCommentInput"
>
<div class="bg-panel w-full max-w-2xl rounded-t-2xl border-t border-gray-700 shadow-2xl animate-slide-up">
<!-- 回复提示 -->
<div v-if="replyTo" class="flex items-center justify-between px-5 pt-4 text-sm">
<span class="text-gray-500">回复 <span class="text-gray-300">{{ replyTo.user?.name }}</span></span>
<button @click="replyTo = null" class="text-gray-500 hover:text-white">
<i class="fas fa-times"></i>
</button>
</div>
<div class="flex gap-3 p-5">
<input
ref="commentInputRef"
v-model="commentText"
type="text"
:placeholder="replyTo ? `回复 ${replyTo.user?.name}...` : '写评论...'"
class="flex-1 bg-input rounded-2xl py-3 px-5 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
@keyup.enter="handleComment"
/>
<button
@click="handleComment"
:disabled="!commentText.trim() || commenting"
class="px-6 py-3 bg-primary hover:bg-indigo-600 text-white rounded-2xl font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ commenting ? '...' : '发送' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import { useMomentStore } from '@/stores/moment'
import { useAuthStore } from '@/stores/auth'
import Avatar from '@/components/common/Avatar.vue'
import MomentPublisherDark from './MomentPublisherDark.vue'
import MomentNotificationPanel from './MomentNotificationPanel.vue'
import { parseMediaUrls } from '@/types/moment'
import { formatRelativeTime } from '@/utils/format'
import type { Moment, MomentComment } from '@/types/moment'
const momentStore = useMomentStore()
const authStore = useAuthStore()
// 状态
const showPublisher = ref(false)
const showNotifications = ref(false)
const expandedMoments = reactive(new Set<number>())
// 评论相关
const commentingMoment = ref<Moment | null>(null)
const commentText = ref('')
const commenting = ref(false)
const replyTo = ref<MomentComment | null>(null)
const commentInputRef = ref<HTMLInputElement | null>(null)
// 初始化
onMounted(() => {
momentStore.fetchMoments()
momentStore.fetchUnreadCount()
})
// 方法
function formatTime(time: string): string {
return formatRelativeTime(time)
}
function isOwner(moment: Moment): boolean {
return authStore.user?.id === moment.user_id
}
function getMediaUrls(moment: Moment): string[] {
return parseMediaUrls(moment.media_urls)
}
function getGridClass(count: number): string {
if (count === 1) return 'grid-cols-1 max-w-xs'
if (count === 2) return 'grid-cols-2 max-w-sm'
if (count <= 4) return 'grid-cols-2 max-w-sm'
return 'grid-cols-3 max-w-md'
}
function handleScroll(e: Event) {
const target = e.target as HTMLElement
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight
if (scrollBottom < 100 && !momentStore.loading) {
momentStore.fetchMoreMoments()
}
}
async function handleLike(moment: Moment) {
await momentStore.toggleLike(moment.id)
}
async function handleDelete(moment: Moment) {
if (!confirm('确定要删除这条动态吗?')) return
await momentStore.removeMoment(moment.id)
}
function openCommentInput(moment: Moment, reply?: MomentComment) {
commentingMoment.value = moment
replyTo.value = reply || null
commentText.value = ''
nextTick(() => {
commentInputRef.value?.focus()
})
}
function closeCommentInput() {
commentingMoment.value = null
replyTo.value = null
commentText.value = ''
}
async function handleComment() {
if (!commentingMoment.value || !commentText.trim() || commenting.value) return
commenting.value = true
try {
await momentStore.addComment(commentingMoment.value.id, {
content: commentText.value.trim(),
reply_to_comment_id: replyTo.value?.id,
})
closeCommentInput()
} catch (e) {
// 错误已在 store 中处理
} finally {
commenting.value = false
}
}
function onPublished() {
showPublisher.value = false
}
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700 rounded-full hover:bg-gray-600; }
.custom-scrollbar::-webkit-scrollbar-track { @apply bg-transparent; }
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.animate-slide-up {
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,396 @@
<template>
<div class="fixed inset-0 z-50 flex flex-col bg-white">
<!-- 头部 -->
<header class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<button @click="handleClose" class="text-gray-600">取消</button>
<h2 class="font-semibold text-gray-800">发布动态</h2>
<button
@click="handlePublish"
:disabled="!canPublish || publishing"
class="px-4 py-1.5 bg-blue-500 text-white rounded-full text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ publishing ? '发布中...' : '发布' }}
</button>
</header>
<!-- 内容编辑区 -->
<main class="flex-1 overflow-y-auto p-4">
<!-- 文字输入 -->
<textarea
v-model="content"
placeholder="分享新鲜事..."
class="w-full h-32 resize-none border-none outline-none text-gray-800 text-base placeholder:text-gray-400"
maxlength="2000"
></textarea>
<!-- 字数统计 -->
<div class="text-right text-gray-400 text-sm">
{{ content.length }}/2000
</div>
<!-- 图片预览 -->
<div v-if="mediaType === 1 && images.length > 0" class="mt-4">
<div class="grid grid-cols-3 gap-2">
<div
v-for="(img, index) in images"
:key="index"
class="relative aspect-square bg-gray-100 rounded overflow-hidden"
>
<img :src="img.preview" class="w-full h-full object-cover" />
<button
@click="removeImage(index)"
class="absolute top-1 right-1 w-5 h-5 bg-black/50 rounded-full flex items-center justify-center"
>
<svg class="w-3 h-3 text-white" 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>
<!-- 添加更多 -->
<label
v-if="images.length < 9"
class="aspect-square bg-gray-100 rounded flex items-center justify-center cursor-pointer hover:bg-gray-200 transition-colors"
>
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<input type="file" accept="image/*" multiple class="hidden" @change="handleImageSelect" />
</label>
</div>
</div>
<!-- 视频预览 -->
<div v-if="mediaType === 2 && videoFile" class="mt-4">
<div class="relative aspect-video bg-black rounded overflow-hidden">
<video :src="videoPreview" class="w-full h-full object-contain" controls></video>
<button
@click="removeVideo"
class="absolute top-2 right-2 w-6 h-6 bg-black/50 rounded-full flex items-center justify-center"
>
<svg class="w-4 h-4 text-white" 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 v-if="location" class="mt-4 flex items-center text-blue-500 text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
</svg>
{{ location }}
<button @click="location = ''" class="ml-2 text-gray-400">×</button>
</div>
<!-- 话题标签 -->
<div v-if="tags.length > 0" class="mt-4 flex flex-wrap gap-2">
<span
v-for="(tag, index) in tags"
:key="index"
class="px-2 py-1 bg-blue-50 text-blue-500 rounded text-sm flex items-center"
>
#{{ tag }}
<button @click="removeTag(index)" class="ml-1 text-blue-300 hover:text-blue-500">×</button>
</span>
</div>
</main>
<!-- 底部工具栏 -->
<footer class="border-t border-gray-200 px-4 py-3">
<div class="flex items-center gap-4">
<!-- 图片 -->
<label class="cursor-pointer text-gray-600 hover:text-blue-500 transition-colors">
<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="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>
<input
type="file"
accept="image/*"
multiple
class="hidden"
@change="handleImageSelect"
:disabled="mediaType === 2"
/>
</label>
<!-- 视频 -->
<label class="cursor-pointer text-gray-600 hover:text-blue-500 transition-colors">
<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 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<input
type="file"
accept="video/*"
class="hidden"
@change="handleVideoSelect"
:disabled="mediaType === 1 && images.length > 0"
/>
</label>
<!-- 位置 -->
<button
@click="showLocationInput = true"
class="text-gray-600 hover:text-blue-500 transition-colors"
>
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
</svg>
</button>
<!-- 话题 -->
<button
@click="showTagInput = true"
class="text-gray-600 hover:text-blue-500 transition-colors"
>
<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="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
</button>
<div class="flex-1"></div>
<!-- 可见性选择 -->
<button
@click="showVisibilitySelector = true"
class="flex items-center gap-1 text-gray-600 text-sm"
>
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{{ visibilityText }}
</button>
</div>
</footer>
<!-- 位置输入弹窗 -->
<div v-if="showLocationInput" class="fixed inset-0 z-60 bg-black/50 flex items-center justify-center p-4">
<div class="bg-white rounded-lg w-full max-w-sm p-4">
<h3 class="font-medium text-gray-800 mb-3">添加位置</h3>
<input
v-model="locationInput"
type="text"
placeholder="输入位置名称"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500"
/>
<div class="flex justify-end gap-2 mt-4">
<button @click="showLocationInput = false" class="px-4 py-2 text-gray-600">取消</button>
<button @click="confirmLocation" class="px-4 py-2 bg-blue-500 text-white rounded-lg">确定</button>
</div>
</div>
</div>
<!-- 话题输入弹窗 -->
<div v-if="showTagInput" class="fixed inset-0 z-60 bg-black/50 flex items-center justify-center p-4">
<div class="bg-white rounded-lg w-full max-w-sm p-4">
<h3 class="font-medium text-gray-800 mb-3">添加话题</h3>
<input
v-model="tagInput"
type="text"
placeholder="输入话题名称(不含#)"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500"
@keyup.enter="confirmTag"
/>
<div class="flex justify-end gap-2 mt-4">
<button @click="showTagInput = false" class="px-4 py-2 text-gray-600">取消</button>
<button @click="confirmTag" class="px-4 py-2 bg-blue-500 text-white rounded-lg">添加</button>
</div>
</div>
</div>
<!-- 可见性选择弹窗 -->
<VisibilitySelector
v-if="showVisibilitySelector"
:visibility="visibility"
@select="handleVisibilitySelect"
@close="showVisibilitySelector = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useMomentStore } from '@/stores/moment'
import { useToastStore } from '@/stores/toast'
import * as attachmentApi from '@/api/modules/attachment'
import VisibilitySelector from './VisibilitySelector.vue'
import { VisibilityOptions } from '@/types/moment'
import type { CreateMomentRequest } from '@/types/moment'
const emit = defineEmits<{
(e: 'close'): void
(e: 'published'): void
}>()
const momentStore = useMomentStore()
const toast = useToastStore()
// 状态
const content = ref('')
const mediaType = ref<0 | 1 | 2>(0)
const images = ref<{ file: File; preview: string }[]>([])
const videoFile = ref<File | null>(null)
const videoPreview = ref('')
const location = ref('')
const tags = ref<string[]>([])
const visibility = ref<0 | 1 | 2 | 3>(0)
const visibleUserIds = ref<string[]>([])
const publishing = ref(false)
// 弹窗状态
const showLocationInput = ref(false)
const showTagInput = ref(false)
const showVisibilitySelector = ref(false)
const locationInput = ref('')
const tagInput = ref('')
// 计算属性
const canPublish = computed(() => {
return content.value.trim() || images.value.length > 0 || videoFile.value
})
const visibilityText = computed(() => {
return VisibilityOptions.find(v => v.value === visibility.value)?.label || '公开'
})
// 方法
function handleClose() {
if (canPublish.value) {
if (!confirm('确定要放弃编辑吗?')) return
}
emit('close')
}
async function handlePublish() {
if (!canPublish.value || publishing.value) return
publishing.value = true
try {
// 上传媒体文件
const mediaUrls: string[] = []
if (images.value.length > 0) {
for (const img of images.value) {
const res = await attachmentApi.uploadAttachment(img.file)
mediaUrls.push(res.file_url)
}
mediaType.value = 1
} else if (videoFile.value) {
const res = await attachmentApi.uploadAttachment(videoFile.value)
mediaUrls.push(res.file_url)
mediaType.value = 2
}
// 构建请求
const req: CreateMomentRequest = {
content: content.value,
media_type: mediaType.value,
media_urls: mediaUrls,
location: location.value || undefined,
visibility: visibility.value,
visible_user_ids: visibleUserIds.value.length > 0 ? visibleUserIds.value : undefined,
topic_tags: tags.value.length > 0 ? tags.value : undefined,
}
await momentStore.publishMoment(req)
emit('published')
} catch (error: any) {
toast.show(error.message || '发布失败', 'error')
} finally {
publishing.value = false
}
}
function handleImageSelect(e: Event) {
const input = e.target as HTMLInputElement
const files = input.files
if (!files) return
const remaining = 9 - images.value.length
const toAdd = Array.from(files).slice(0, remaining)
for (const file of toAdd) {
if (file.size > 10 * 1024 * 1024) {
toast.show('图片大小不能超过10MB', 'warning')
continue
}
images.value.push({
file,
preview: URL.createObjectURL(file),
})
}
if (images.value.length > 0) {
mediaType.value = 1
}
input.value = ''
}
function removeImage(index: number) {
URL.revokeObjectURL(images.value[index].preview)
images.value.splice(index, 1)
if (images.value.length === 0) {
mediaType.value = 0
}
}
function handleVideoSelect(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
if (file.size > 500 * 1024 * 1024) {
toast.show('视频大小不能超过500MB', 'warning')
return
}
videoFile.value = file
videoPreview.value = URL.createObjectURL(file)
mediaType.value = 2
input.value = ''
}
function removeVideo() {
if (videoPreview.value) {
URL.revokeObjectURL(videoPreview.value)
}
videoFile.value = null
videoPreview.value = ''
mediaType.value = 0
}
function confirmLocation() {
location.value = locationInput.value.trim()
locationInput.value = ''
showLocationInput.value = false
}
function confirmTag() {
const tag = tagInput.value.trim()
if (tag && !tags.value.includes(tag)) {
tags.value.push(tag)
}
tagInput.value = ''
showTagInput.value = false
}
function removeTag(index: number) {
tags.value.splice(index, 1)
}
function handleVisibilitySelect(v: number, userIds?: string[]) {
visibility.value = v as 0 | 1 | 2 | 3
visibleUserIds.value = userIds || []
showVisibilitySelector.value = false
}
</script>
<style scoped>
.z-60 {
z-index: 60;
}
</style>

View File

@@ -0,0 +1,411 @@
<template>
<div class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm" @click.self="handleClose">
<div class="bg-panel rounded-2xl w-full max-w-2xl shadow-2xl border border-gray-700 overflow-hidden animate-fade-in max-h-[90vh] flex flex-col">
<!-- 头部 -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-700">
<button @click="handleClose" class="text-gray-400 hover:text-white transition">取消</button>
<h2 class="font-bold text-white">发布动态</h2>
<button
@click="handlePublish"
:disabled="!canPublish || publishing"
class="px-4 py-1.5 bg-primary text-white rounded-full text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-indigo-600 transition"
>
{{ publishing ? '发布中...' : '发布' }}
</button>
</div>
<!-- 内容编辑区 -->
<div class="flex-1 overflow-y-auto p-6 custom-scrollbar">
<!-- 文字输入 -->
<textarea
v-model="content"
placeholder="分享新鲜事..."
class="w-full h-40 resize-none bg-transparent border-none outline-none text-white text-base placeholder:text-gray-500"
maxlength="2000"
></textarea>
<!-- 字数统计 -->
<div class="text-right text-gray-500 text-sm mb-4">
{{ content.length }}/2000
</div>
<!-- 图片预览 -->
<div v-if="mediaType === 1 && images.length > 0" class="mb-4">
<div class="grid grid-cols-3 gap-2">
<div
v-for="(img, index) in images"
:key="index"
class="relative aspect-square bg-gray-800 rounded-xl overflow-hidden group"
>
<img :src="img.preview" class="w-full h-full object-cover" />
<button
@click="removeImage(index)"
class="absolute top-2 right-2 w-6 h-6 bg-black/60 hover:bg-red-500 rounded-full flex items-center justify-center transition opacity-0 group-hover:opacity-100"
>
<i class="fas fa-times text-white text-xs"></i>
</button>
</div>
<!-- 添加更多 -->
<label
v-if="images.length < 9"
class="aspect-square bg-gray-800 rounded-xl flex items-center justify-center cursor-pointer hover:bg-gray-700 transition border-2 border-dashed border-gray-600"
>
<i class="fas fa-plus text-2xl text-gray-500"></i>
<input type="file" accept="image/*" multiple class="hidden" @change="handleImageSelect" />
</label>
</div>
</div>
<!-- 视频预览 -->
<div v-if="mediaType === 2 && videoFile" class="mb-4">
<div class="relative aspect-video bg-black rounded-xl overflow-hidden">
<video :src="videoPreview" class="w-full h-full object-contain" controls></video>
<button
@click="removeVideo"
class="absolute top-3 right-3 w-8 h-8 bg-black/60 hover:bg-red-500 rounded-full flex items-center justify-center transition"
>
<i class="fas fa-times text-white"></i>
</button>
</div>
</div>
<!-- 位置信息 -->
<div v-if="location" class="flex items-center justify-between p-3 bg-white/5 rounded-xl mb-3">
<div class="flex items-center gap-2 text-primary text-sm">
<i class="fas fa-map-marker-alt"></i>
{{ location }}
</div>
<button @click="location = ''" class="text-gray-500 hover:text-white transition">
<i class="fas fa-times"></i>
</button>
</div>
<!-- 话题标签 -->
<div v-if="tags.length > 0" class="flex flex-wrap gap-2 mb-3">
<span
v-for="(tag, index) in tags"
:key="index"
class="px-3 py-1 bg-primary/20 text-primary rounded-full text-sm flex items-center gap-1"
>
#{{ tag }}
<button @click="removeTag(index)" class="hover:text-white transition">
<i class="fas fa-times text-xs"></i>
</button>
</span>
</div>
</div>
<!-- 底部工具栏 -->
<div class="border-t border-gray-700 px-6 py-4">
<div class="flex items-center gap-4">
<!-- 图片 -->
<label class="cursor-pointer text-gray-400 hover:text-primary transition p-2 rounded-xl hover:bg-white/5" title="添加图片">
<i class="fas fa-image text-lg"></i>
<input
type="file"
accept="image/*"
multiple
class="hidden"
@change="handleImageSelect"
:disabled="mediaType === 2"
/>
</label>
<!-- 视频 -->
<label class="cursor-pointer text-gray-400 hover:text-primary transition p-2 rounded-xl hover:bg-white/5" title="添加视频">
<i class="fas fa-video text-lg"></i>
<input
type="file"
accept="video/*"
class="hidden"
@change="handleVideoSelect"
:disabled="mediaType === 1 && images.length > 0"
/>
</label>
<!-- 位置 -->
<button
@click="showLocationInput = true"
class="text-gray-400 hover:text-primary transition p-2 rounded-xl hover:bg-white/5"
title="添加位置"
>
<i class="fas fa-map-marker-alt text-lg"></i>
</button>
<!-- 话题 -->
<button
@click="showTagInput = true"
class="text-gray-400 hover:text-primary transition p-2 rounded-xl hover:bg-white/5"
title="添加话题"
>
<i class="fas fa-hashtag text-lg"></i>
</button>
<div class="flex-1"></div>
<!-- 可见性选择 -->
<button
@click="showVisibilitySelector = true"
class="flex items-center gap-2 text-gray-400 text-sm px-3 py-2 rounded-xl hover:bg-white/5 transition"
>
<i class="fas fa-eye"></i>
{{ visibilityText }}
<i class="fas fa-chevron-down text-xs"></i>
</button>
</div>
</div>
</div>
<!-- 位置输入弹窗 -->
<div v-if="showLocationInput" class="fixed inset-0 z-[70] bg-black/60 flex items-center justify-center p-4" @click.self="showLocationInput = false">
<div class="bg-panel w-96 rounded-2xl border border-gray-700 p-5 shadow-2xl animate-scale-in">
<h3 class="text-lg font-bold text-white mb-4">添加位置</h3>
<input
v-model="locationInput"
type="text"
placeholder="输入位置名称"
class="w-full bg-input text-white p-3 rounded-xl border border-gray-600 focus:border-primary outline-none"
@keyup.enter="confirmLocation"
/>
<div class="flex gap-3 mt-4">
<button @click="showLocationInput = false" class="flex-1 py-2.5 rounded-xl text-gray-400 hover:bg-white/5 transition">取消</button>
<button @click="confirmLocation" class="flex-1 py-2.5 rounded-xl bg-primary text-white font-bold hover:bg-indigo-600 transition">确定</button>
</div>
</div>
</div>
<!-- 话题输入弹窗 -->
<div v-if="showTagInput" class="fixed inset-0 z-[70] bg-black/60 flex items-center justify-center p-4" @click.self="showTagInput = false">
<div class="bg-panel w-96 rounded-2xl border border-gray-700 p-5 shadow-2xl animate-scale-in">
<h3 class="text-lg font-bold text-white mb-4">添加话题</h3>
<input
v-model="tagInput"
type="text"
placeholder="输入话题名称(不含#)"
class="w-full bg-input text-white p-3 rounded-xl border border-gray-600 focus:border-primary outline-none"
@keyup.enter="confirmTag"
/>
<div class="flex gap-3 mt-4">
<button @click="showTagInput = false" class="flex-1 py-2.5 rounded-xl text-gray-400 hover:bg-white/5 transition">取消</button>
<button @click="confirmTag" class="flex-1 py-2.5 rounded-xl bg-primary text-white font-bold hover:bg-indigo-600 transition">添加</button>
</div>
</div>
</div>
<!-- 可见性选择弹窗 -->
<div v-if="showVisibilitySelector" class="fixed inset-0 z-[70] bg-black/60 flex items-center justify-center p-4" @click.self="showVisibilitySelector = false">
<div class="bg-panel w-96 rounded-2xl border border-gray-700 shadow-2xl animate-scale-in overflow-hidden">
<div class="px-5 py-4 border-b border-gray-700">
<h3 class="text-lg font-bold text-white">谁可以看</h3>
</div>
<div class="py-2">
<div
v-for="option in visibilityOptions"
:key="option.value"
@click="selectVisibility(option.value)"
class="flex items-center gap-4 px-5 py-3 hover:bg-white/5 cursor-pointer transition"
>
<div class="w-10 h-10 rounded-full bg-white/5 flex items-center justify-center text-gray-400">
<i :class="option.icon"></i>
</div>
<div class="flex-1">
<p class="font-medium text-white">{{ option.label }}</p>
<p class="text-gray-500 text-sm">{{ option.desc }}</p>
</div>
<i v-if="visibility === option.value" class="fas fa-check text-primary"></i>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useMomentStore } from '@/stores/moment'
import { useToastStore } from '@/stores/toast'
import * as attachmentApi from '@/api/modules/attachment'
import type { CreateMomentRequest } from '@/types/moment'
const emit = defineEmits<{
(e: 'close'): void
(e: 'published'): void
}>()
const momentStore = useMomentStore()
const toast = useToastStore()
// 状态
const content = ref('')
const mediaType = ref<0 | 1 | 2>(0)
const images = ref<{ file: File; preview: string }[]>([])
const videoFile = ref<File | null>(null)
const videoPreview = ref('')
const location = ref('')
const tags = ref<string[]>([])
const visibility = ref<0 | 1 | 2 | 3>(0)
const publishing = ref(false)
// 弹窗状态
const showLocationInput = ref(false)
const showTagInput = ref(false)
const showVisibilitySelector = ref(false)
const locationInput = ref('')
const tagInput = ref('')
// 可见性选项
const visibilityOptions = [
{ value: 0, label: '公开', desc: '所有人可见', icon: 'fas fa-globe' },
{ value: 1, label: '仅好友可见', desc: '只有你的好友可以看到', icon: 'fas fa-users' },
{ value: 2, label: '部分好友可见', desc: '选择的好友可以看到', icon: 'fas fa-user-check' },
{ value: 3, label: '部分好友不可见', desc: '选择的好友看不到', icon: 'fas fa-user-times' },
]
// 计算属性
const canPublish = computed(() => {
return content.value.trim() || images.value.length > 0 || videoFile.value
})
const visibilityText = computed(() => {
return visibilityOptions.find(v => v.value === visibility.value)?.label || '公开'
})
// 方法
function handleClose() {
if (canPublish.value) {
if (!confirm('确定要放弃编辑吗?')) return
}
emit('close')
}
async function handlePublish() {
if (!canPublish.value || publishing.value) return
publishing.value = true
try {
const mediaUrls: string[] = []
if (images.value.length > 0) {
for (const img of images.value) {
const res = await attachmentApi.uploadAttachment(img.file)
mediaUrls.push(res.file_url)
}
mediaType.value = 1
} else if (videoFile.value) {
const res = await attachmentApi.uploadAttachment(videoFile.value)
mediaUrls.push(res.file_url)
mediaType.value = 2
}
const req: CreateMomentRequest = {
content: content.value,
media_type: mediaType.value,
media_urls: mediaUrls,
location: location.value || undefined,
visibility: visibility.value,
topic_tags: tags.value.length > 0 ? tags.value : undefined,
}
await momentStore.publishMoment(req)
emit('published')
} catch (error: any) {
toast.show(error.message || '发布失败', 'error')
} finally {
publishing.value = false
}
}
function handleImageSelect(e: Event) {
const input = e.target as HTMLInputElement
const files = input.files
if (!files) return
const remaining = 9 - images.value.length
const toAdd = Array.from(files).slice(0, remaining)
for (const file of toAdd) {
if (file.size > 10 * 1024 * 1024) {
toast.show('图片大小不能超过10MB', 'warning')
continue
}
images.value.push({
file,
preview: URL.createObjectURL(file),
})
}
if (images.value.length > 0) {
mediaType.value = 1
}
input.value = ''
}
function removeImage(index: number) {
URL.revokeObjectURL(images.value[index].preview)
images.value.splice(index, 1)
if (images.value.length === 0) {
mediaType.value = 0
}
}
function handleVideoSelect(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
if (file.size > 500 * 1024 * 1024) {
toast.show('视频大小不能超过500MB', 'warning')
return
}
videoFile.value = file
videoPreview.value = URL.createObjectURL(file)
mediaType.value = 2
input.value = ''
}
function removeVideo() {
if (videoPreview.value) {
URL.revokeObjectURL(videoPreview.value)
}
videoFile.value = null
videoPreview.value = ''
mediaType.value = 0
}
function confirmLocation() {
location.value = locationInput.value.trim()
locationInput.value = ''
showLocationInput.value = false
}
function confirmTag() {
const tag = tagInput.value.trim()
if (tag && !tags.value.includes(tag)) {
tags.value.push(tag)
}
tagInput.value = ''
showTagInput.value = false
}
function removeTag(index: number) {
tags.value.splice(index, 1)
}
function selectVisibility(v: number) {
visibility.value = v as 0 | 1 | 2 | 3
showVisibilitySelector.value = false
}
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700 rounded-full; }
.animate-fade-in { animation: fadeIn 0.3s ease-out; }
.animate-scale-in { animation: scaleIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); }
@keyframes fadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
@keyframes scaleIn { from { transform: scale(0.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }
</style>

View File

@@ -0,0 +1,81 @@
<template>
<div class="fixed inset-0 z-50 flex flex-col justify-end bg-black/50" @click.self="$emit('close')">
<div class="bg-white rounded-t-2xl max-h-[70vh] flex flex-col">
<!-- 头部 -->
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<span class="font-medium text-gray-800">谁可以看</span>
<button @click="$emit('close')" class="text-gray-400">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- 选项列表 -->
<div class="flex-1 overflow-y-auto">
<div
v-for="option in options"
:key="option.value"
@click="handleSelect(option.value)"
class="flex items-center gap-4 px-4 py-4 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-b-0"
>
<!-- 图标 -->
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
<svg v-if="option.value === 0" class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else-if="option.value === 1" class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<svg v-else-if="option.value === 2" class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</div>
<!-- 文本 -->
<div class="flex-1">
<p class="font-medium text-gray-800">{{ option.label }}</p>
<p class="text-gray-400 text-sm">{{ option.desc }}</p>
</div>
<!-- 选中标记 -->
<svg
v-if="visibility === option.value"
class="w-5 h-5 text-blue-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
visibility: number
}>()
const emit = defineEmits<{
(e: 'select', visibility: number, userIds?: string[]): void
(e: 'close'): void
}>()
const options = [
{ value: 0, label: '公开', desc: '所有人可见' },
{ value: 1, label: '仅好友可见', desc: '只有你的好友可以看到' },
{ value: 2, label: '部分好友可见', desc: '选择的好友可以看到' },
{ value: 3, label: '部分好友不可见', desc: '选择的好友看不到' },
]
function handleSelect(value: number) {
// TODO: 对于部分可见/不可见,需要弹出好友选择器
// 目前简化处理,直接选择
emit('select', value)
}
</script>