朋友圈

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

@@ -38,10 +38,14 @@ import GroupCallWindow from '@/components/chat/GroupCallWindow.vue'
import GroupCallIncoming from '@/components/chat/GroupCallIncoming.vue'
import { useWebRTCStore } from '@/stores/webrtc'
import { useChatStore } from '@/stores/chat'
import { useMomentStore } from '@/stores/moment'
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
import { wsManager } from '@/api/websocket'
import type { MomentNotifPayload } from '@/types/moment'
const webrtcStore = useWebRTCStore()
const chatStore = useChatStore()
const momentStore = useMomentStore()
const groupWebRTC = useGroupWebRTC()
const isMobile = ref(window.innerWidth < 768)
@@ -52,13 +56,22 @@ const handleResize = () => {
isMobile.value = window.innerWidth < 768
}
// 朋友圈通知处理器
const handleMomentNotification = (payload: MomentNotifPayload) => {
momentStore.handleWsNotification(payload)
}
onMounted(() => {
window.addEventListener('resize', handleResize)
// 确保监听启动
groupWebRTC.initListener()
// 注册朋友圈通知处理器
wsManager.onMomentNotification(handleMomentNotification)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
// 移除朋友圈通知处理器
wsManager.offMomentNotification(handleMomentNotification)
})
</script>

View File

@@ -6,9 +6,20 @@ import type { Attachment, PaginatedResponse } from '@/types/api'
*/
// 上传附件
export function uploadAttachment(file: File, type: 'image' | 'video' | 'file') {
export function uploadAttachment(file: File, type?: 'image' | 'video' | 'file') {
const formData = new FormData()
formData.append('file', file)
// 自动检测类型
if (!type) {
if (file.type.startsWith('image/')) {
type = 'image'
} else if (file.type.startsWith('video/')) {
type = 'video'
} else {
type = 'file'
}
}
formData.append('type', type)
return request.post<Attachment>('/attachments/upload', formData, {

135
src/api/modules/moment.ts Normal file
View File

@@ -0,0 +1,135 @@
/**
* 朋友圈相关 API
*/
import request from '../request'
import type {
Moment,
MomentLike,
MomentComment,
MomentNotification,
CreateMomentRequest,
CreateCommentRequest,
MarkReadRequest,
PaginatedResponse,
} from '@/types/moment'
// ==========================================
// 动态相关
// ==========================================
/**
* 发布动态
*/
export const createMoment = (data: CreateMomentRequest) => {
return request.post<Moment>('/moments', data)
}
/**
* 获取好友动态列表
*/
export const getMoments = (page = 1, pageSize = 20) => {
return request.get<PaginatedResponse<Moment>>('/moments', {
params: { page, page_size: pageSize },
})
}
/**
* 获取动态详情
*/
export const getMomentDetail = (id: number) => {
return request.get<Moment>(`/moments/${id}`)
}
/**
* 删除动态
*/
export const deleteMoment = (id: number) => {
return request.delete(`/moments/${id}`)
}
/**
* 获取指定用户的动态列表
*/
export const getUserMoments = (userId: string, page = 1, pageSize = 20) => {
return request.get<PaginatedResponse<Moment>>(`/moments/user/${userId}`, {
params: { page, page_size: pageSize },
})
}
// ==========================================
// 点赞相关
// ==========================================
/**
* 点赞动态
*/
export const likeMoment = (id: number) => {
return request.post(`/moments/${id}/like`)
}
/**
* 取消点赞
*/
export const unlikeMoment = (id: number) => {
return request.delete(`/moments/${id}/like`)
}
/**
* 获取动态的点赞列表
*/
export const getMomentLikes = (momentId: number) => {
return request.get<MomentLike[]>(`/moments/${momentId}/likes`)
}
// ==========================================
// 评论相关
// ==========================================
/**
* 获取动态的评论列表
*/
export const getMomentComments = (momentId: number) => {
return request.get<MomentComment[]>(`/moments/${momentId}/comments`)
}
/**
* 发表评论
*/
export const createComment = (momentId: number, data: CreateCommentRequest) => {
return request.post<MomentComment>(`/moments/${momentId}/comments`, data)
}
/**
* 删除评论
*/
export const deleteComment = (commentId: number) => {
return request.delete(`/moments/comments/${commentId}`)
}
// ==========================================
// 通知相关
// ==========================================
/**
* 获取朋友圈通知列表
*/
export const getNotifications = (page = 1, pageSize = 20) => {
return request.get<PaginatedResponse<MomentNotification>>('/moments/notifications', {
params: { page, page_size: pageSize },
})
}
/**
* 标记通知为已读
*/
export const markNotificationsRead = (data: MarkReadRequest) => {
return request.post('/moments/notifications/read', data)
}
/**
* 获取未读通知数量
*/
export const getUnreadCount = () => {
return request.get<{ count: number }>('/moments/notifications/unread-count')
}

View File

@@ -1,13 +1,15 @@
import type { ChatMessage } from '@/types/api'
import type { MomentNotifPayload } from '@/types/moment'
export interface WebSocketMessage {
request_type?: string
clientId?: string
data?: ChatMessage
data?: ChatMessage | MomentNotifPayload
}
export type MessageHandler = (message: ChatMessage) => void
export type SignalHandler = (message: ChatMessage) => void
export type MomentNotifHandler = (payload: MomentNotifPayload) => void
class WebSocketManager {
private ws: WebSocket | null = null
@@ -15,6 +17,7 @@ class WebSocketManager {
private userId: string | null = null
private messageHandlers: MessageHandler[] = []
private signalHandlers: SignalHandler[] = []
private momentNotifHandlers: MomentNotifHandler[] = []
private reconnectAttempts = 0
private maxReconnectAttempts = 5
private reconnectDelay = 3000
@@ -58,7 +61,12 @@ class WebSocketManager {
// 处理接收消息
if (payload.request_type === 'receive_message' && payload.data) {
this.handleMessage(payload.data)
this.handleMessage(payload.data as ChatMessage)
}
// 处理朋友圈通知
if (payload.request_type === 'moment_notification' && payload.data) {
this.handleMomentNotification(payload.data as MomentNotifPayload)
}
} catch (e) {
// Ignore single line parse error
@@ -97,6 +105,7 @@ class WebSocketManager {
this.userId = null
this.messageHandlers = []
this.signalHandlers = []
this.momentNotifHandlers = []
}
/**
@@ -140,6 +149,30 @@ class WebSocketManager {
}
}
/**
* 添加朋友圈通知处理器
*/
onMomentNotification(handler: MomentNotifHandler) {
this.momentNotifHandlers.push(handler)
}
/**
* 移除朋友圈通知处理器
*/
offMomentNotification(handler: MomentNotifHandler) {
const index = this.momentNotifHandlers.indexOf(handler)
if (index > -1) {
this.momentNotifHandlers.splice(index, 1)
}
}
/**
* 内部处理朋友圈通知
*/
private handleMomentNotification(payload: MomentNotifPayload) {
this.momentNotifHandlers.forEach(handler => handler(payload))
}
/**
* 内部处理接收到的消息
*/

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>

View File

@@ -35,6 +35,25 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/contact/ContactDetail.vue'),
meta: { requiresAuth: true },
},
// 朋友圈相关路由
{
path: '/moment',
name: 'Moment',
component: () => import('@/views/moment/MomentView.vue'),
meta: { requiresAuth: true },
},
{
path: '/moment/notifications',
name: 'MomentNotifications',
component: () => import('@/views/moment/MomentNotifyView.vue'),
meta: { requiresAuth: true },
},
{
path: '/moment/:id',
name: 'MomentDetail',
component: () => import('@/views/moment/MomentDetailView.vue'),
meta: { requiresAuth: true },
},
]
const router = createRouter({

470
src/stores/moment.ts Normal file
View File

@@ -0,0 +1,470 @@
/**
* 朋友圈 Store
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as momentApi from '@/api/modules/moment'
import type {
Moment,
MomentComment,
MomentNotification,
CreateMomentRequest,
CreateCommentRequest,
MomentNotifPayload,
} from '@/types/moment'
import { useToastStore } from './toast'
export const useMomentStore = defineStore('moment', () => {
// ==========================================
// 状态
// ==========================================
// 动态列表
const moments = ref<Moment[]>([])
// 当前查看的动态详情
const currentMoment = ref<Moment | null>(null)
// 通知列表
const notifications = ref<MomentNotification[]>([])
// 未读通知数
const unreadCount = ref(0)
// 加载状态
const loading = ref(false)
// 分页信息
const pagination = ref({
page: 1,
pageSize: 20,
total: 0,
hasMore: true,
})
// 通知分页
const notifPagination = ref({
page: 1,
pageSize: 20,
total: 0,
hasMore: true,
})
// ==========================================
// 计算属性
// ==========================================
const hasUnread = computed(() => unreadCount.value > 0)
// ==========================================
// 动态相关方法
// ==========================================
/**
* 获取动态列表(刷新)
*/
async function fetchMoments() {
loading.value = true
try {
pagination.value.page = 1
const res = await momentApi.getMoments(1, pagination.value.pageSize)
moments.value = res.data || []
pagination.value.total = res.total
pagination.value.hasMore = moments.value.length < res.total
} catch (error) {
console.error('获取动态列表失败:', error)
} finally {
loading.value = false
}
}
/**
* 加载更多动态
*/
async function fetchMoreMoments() {
if (!pagination.value.hasMore || loading.value) return
loading.value = true
try {
const nextPage = pagination.value.page + 1
const res = await momentApi.getMoments(nextPage, pagination.value.pageSize)
const newMoments = res.data || []
moments.value = [...moments.value, ...newMoments]
pagination.value.page = nextPage
pagination.value.total = res.total
pagination.value.hasMore = moments.value.length < res.total
} catch (error) {
console.error('加载更多动态失败:', error)
} finally {
loading.value = false
}
}
/**
* 获取用户动态列表
*/
async function fetchUserMoments(userId: string, refresh = false) {
if (refresh) {
pagination.value.page = 1
}
loading.value = true
try {
const res = await momentApi.getUserMoments(userId, pagination.value.page, pagination.value.pageSize)
if (refresh) {
moments.value = res.data || []
} else {
moments.value = [...moments.value, ...(res.data || [])]
}
pagination.value.total = res.total
pagination.value.hasMore = moments.value.length < res.total
} catch (error) {
console.error('获取用户动态失败:', error)
} finally {
loading.value = false
}
}
/**
* 获取动态详情
*/
async function fetchMomentDetail(id: number) {
loading.value = true
try {
currentMoment.value = await momentApi.getMomentDetail(id)
} catch (error) {
console.error('获取动态详情失败:', error)
throw error
} finally {
loading.value = false
}
}
/**
* 发布动态
*/
async function publishMoment(data: CreateMomentRequest) {
const toast = useToastStore()
try {
const newMoment = await momentApi.createMoment(data)
// 添加到列表顶部
moments.value.unshift(newMoment)
toast.show('发布成功', 'success')
return newMoment
} catch (error) {
console.error('发布动态失败:', error)
toast.show('发布失败', 'error')
throw error
}
}
/**
* 删除动态
*/
async function removeMoment(id: number) {
const toast = useToastStore()
try {
await momentApi.deleteMoment(id)
// 从列表中移除
moments.value = moments.value.filter((m) => m.id !== id)
if (currentMoment.value?.id === id) {
currentMoment.value = null
}
toast.show('删除成功', 'success')
} catch (error) {
console.error('删除动态失败:', error)
toast.show('删除失败', 'error')
throw error
}
}
// ==========================================
// 点赞相关方法
// ==========================================
/**
* 切换点赞状态
*/
async function toggleLike(momentId: number) {
const moment = moments.value.find((m) => m.id === momentId) || currentMoment.value
if (!moment) return
const wasLiked = moment.is_liked
try {
if (wasLiked) {
await momentApi.unlikeMoment(momentId)
moment.is_liked = false
moment.like_count = Math.max(0, moment.like_count - 1)
} else {
await momentApi.likeMoment(momentId)
moment.is_liked = true
moment.like_count += 1
}
} catch (error) {
console.error('点赞操作失败:', error)
// 恢复状态
moment.is_liked = wasLiked
throw error
}
}
// ==========================================
// 评论相关方法
// ==========================================
/**
* 添加评论
*/
async function addComment(momentId: number, data: CreateCommentRequest) {
const toast = useToastStore()
try {
const newComment = await momentApi.createComment(momentId, data)
// 更新动态的评论列表
const moment = moments.value.find((m) => m.id === momentId) || currentMoment.value
if (moment) {
if (!moment.comments) {
moment.comments = []
}
moment.comments.push(newComment)
moment.comment_count += 1
}
toast.show('评论成功', 'success')
return newComment
} catch (error) {
console.error('评论失败:', error)
toast.show('评论失败', 'error')
throw error
}
}
/**
* 删除评论
*/
async function removeComment(momentId: number, commentId: number) {
const toast = useToastStore()
try {
await momentApi.deleteComment(commentId)
// 更新动态的评论列表
const moment = moments.value.find((m) => m.id === momentId) || currentMoment.value
if (moment && moment.comments) {
moment.comments = moment.comments.filter((c) => c.id !== commentId)
moment.comment_count = Math.max(0, moment.comment_count - 1)
}
toast.show('删除成功', 'success')
} catch (error) {
console.error('删除评论失败:', error)
toast.show('删除失败', 'error')
throw error
}
}
/**
* 刷新评论列表
*/
async function refreshComments(momentId: number) {
try {
const comments = await momentApi.getMomentComments(momentId)
const moment = moments.value.find((m) => m.id === momentId) || currentMoment.value
if (moment) {
moment.comments = comments
}
return comments
} catch (error) {
console.error('刷新评论失败:', error)
throw error
}
}
// ==========================================
// 通知相关方法
// ==========================================
/**
* 获取通知列表
*/
async function fetchNotifications(refresh = false) {
if (refresh) {
notifPagination.value.page = 1
}
loading.value = true
try {
const res = await momentApi.getNotifications(
notifPagination.value.page,
notifPagination.value.pageSize
)
if (refresh) {
notifications.value = res.data || []
} else {
notifications.value = [...notifications.value, ...(res.data || [])]
}
notifPagination.value.total = res.total
notifPagination.value.hasMore = notifications.value.length < res.total
} catch (error) {
console.error('获取通知列表失败:', error)
} finally {
loading.value = false
}
}
/**
* 加载更多通知
*/
async function fetchMoreNotifications() {
if (!notifPagination.value.hasMore || loading.value) return
notifPagination.value.page += 1
await fetchNotifications(false)
}
/**
* 标记通知已读
*/
async function markAsRead(ids: number[]) {
try {
await momentApi.markNotificationsRead({ ids })
// 更新本地状态
notifications.value.forEach((n) => {
if (ids.includes(n.id)) {
n.is_read = true
}
})
// 更新未读数
await fetchUnreadCount()
} catch (error) {
console.error('标记已读失败:', error)
}
}
/**
* 标记全部已读
*/
async function markAllAsRead() {
try {
await momentApi.markNotificationsRead({ all: true })
// 更新本地状态
notifications.value.forEach((n) => {
n.is_read = true
})
unreadCount.value = 0
} catch (error) {
console.error('标记全部已读失败:', error)
}
}
/**
* 获取未读通知数量
*/
async function fetchUnreadCount() {
try {
const res = await momentApi.getUnreadCount()
unreadCount.value = res.count
} catch (error) {
console.error('获取未读数失败:', error)
}
}
// ==========================================
// WebSocket 通知处理
// ==========================================
/**
* 处理 WebSocket 推送的朋友圈通知
*/
function handleWsNotification(payload: MomentNotifPayload) {
const toast = useToastStore()
// 增加未读数
unreadCount.value += 1
// 显示 Toast 通知
let message = ''
const userName = payload.from_user?.name || '有人'
switch (payload.type) {
case 'like':
message = `${userName} 赞了你的动态`
break
case 'comment':
message = `${userName} 评论了你的动态`
break
case 'reply':
message = `${userName} 回复了你的评论`
break
case 'mention':
message = `${userName} 在动态中@了你`
break
}
toast.show(message, 'info')
// 如果当前正在查看相关动态,刷新评论
if (currentMoment.value?.id === payload.moment_id) {
refreshComments(payload.moment_id)
}
}
// ==========================================
// 重置状态
// ==========================================
function reset() {
moments.value = []
currentMoment.value = null
notifications.value = []
unreadCount.value = 0
pagination.value = {
page: 1,
pageSize: 20,
total: 0,
hasMore: true,
}
notifPagination.value = {
page: 1,
pageSize: 20,
total: 0,
hasMore: true,
}
}
return {
// 状态
moments,
currentMoment,
notifications,
unreadCount,
loading,
pagination,
notifPagination,
// 计算属性
hasUnread,
// 动态方法
fetchMoments,
fetchMoreMoments,
fetchUserMoments,
fetchMomentDetail,
publishMoment,
removeMoment,
// 点赞方法
toggleLike,
// 评论方法
addComment,
removeComment,
refreshComments,
// 通知方法
fetchNotifications,
fetchMoreNotifications,
markAsRead,
markAllAsRead,
fetchUnreadCount,
// WebSocket
handleWsNotification,
// 重置
reset,
}
})

178
src/types/moment.ts Normal file
View File

@@ -0,0 +1,178 @@
/**
* 朋友圈相关类型定义
*/
import type { User } from './api'
// 动态
export interface Moment {
id: number
user_id: string
content: string
media_type: 0 | 1 | 2 // 0=纯文字 1=图片 2=视频
media_urls: string | string[] // 后端返回JSON字符串前端解析后为数组
location?: string
visibility: 0 | 1 | 2 | 3 // 0=公开 1=仅好友 2=部分好友可见 3=部分好友不可见
visible_user_ids?: string | string[]
mention_user_ids?: string | string[]
topic_tags?: string | string[]
like_count: number
comment_count: number
is_deleted?: boolean
created_at: string
updated_at?: string
// 关联字段
user?: User
is_liked: boolean
likes?: MomentLike[]
comments?: MomentComment[]
}
// 点赞
export interface MomentLike {
id: number
moment_id: number
user_id: string
created_at: string
user?: User
}
// 评论
export interface MomentComment {
id: number
moment_id: number
user_id: string
reply_to_comment_id?: number | null
reply_to_user_id?: string
content: string
created_at: string
user?: User
reply_to_user?: User
}
// 通知
export interface MomentNotification {
id: number
user_id: string
from_user_id: string
moment_id: number
type: 1 | 2 | 3 | 4 // 1=点赞 2=评论 3=回复 4=@提及
comment_id?: number | null
is_read: boolean
created_at: string
// 关联字段
from_user?: User
moment?: Moment
comment?: MomentComment
}
// 发布动态请求
export interface CreateMomentRequest {
content: string
media_type: 0 | 1 | 2
media_urls?: string[]
location?: string
visibility: 0 | 1 | 2 | 3
visible_user_ids?: string[]
mention_user_ids?: string[]
topic_tags?: string[]
}
// 发表评论请求
export interface CreateCommentRequest {
content: string
reply_to_comment_id?: number
}
// 标记已读请求
export interface MarkReadRequest {
ids?: number[]
all?: boolean
}
// 分页响应
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
size: number
}
// WebSocket 朋友圈通知推送数据
export interface MomentNotifPayload {
type: 'like' | 'comment' | 'reply' | 'mention'
moment_id: number
from_user?: User
content?: string
comment_id?: number
}
// 动态可见性选项
export const VisibilityOptions = [
{ value: 0, label: '公开', icon: 'globe' },
{ value: 1, label: '仅好友可见', icon: 'users' },
{ value: 2, label: '部分好友可见', icon: 'user-check' },
{ value: 3, label: '部分好友不可见', icon: 'user-x' },
] as const
// 媒体类型
export const MediaTypes = {
TEXT: 0,
IMAGE: 1,
VIDEO: 2,
} as const
// 通知类型
export const NotificationTypes = {
LIKE: 1,
COMMENT: 2,
REPLY: 3,
MENTION: 4,
} as const
// 通知类型文本映射
export const NotificationTypeText: Record<number, string> = {
1: '赞了你的动态',
2: '评论了你的动态',
3: '回复了你的评论',
4: '在动态中@了你',
}
/**
* 解析媒体URL后端返回JSON字符串
*/
export function parseMediaUrls(urls: string | string[] | undefined): string[] {
if (!urls) return []
if (Array.isArray(urls)) return urls
try {
return JSON.parse(urls)
} catch {
return []
}
}
/**
* 解析话题标签
*/
export function parseTopicTags(tags: string | string[] | undefined): string[] {
if (!tags) return []
if (Array.isArray(tags)) return tags
try {
return JSON.parse(tags)
} catch {
return []
}
}
/**
* 解析@用户ID
*/
export function parseMentionUserIds(ids: string | string[] | undefined): string[] {
if (!ids) return []
if (Array.isArray(ids)) return ids
try {
return JSON.parse(ids)
} catch {
return []
}
}

View File

@@ -145,6 +145,13 @@ export function parseText(text: string): string {
return text.replace(urlRegex, '<a href="$1" target="_blank" class="text-blue-400 hover:underline pointer-events-auto">$1</a>')
}
/**
* 格式化相对时间(别名,用于朋友圈等场景)
*/
export function formatRelativeTime(timestamp: number | string): string {
return formatTime(timestamp)
}
/**
* 生成用户头像颜色
*/

View File

@@ -62,14 +62,14 @@ export const storage = {
return localStorage.getItem(REMEMBER_KEY) === 'true'
},
// Current Tab (chat/contact)
setCurrentTab(tab: 'chat' | 'contact') {
// Current Tab (chat/contact/moment)
setCurrentTab(tab: 'chat' | 'contact' | 'moment') {
localStorage.setItem(CURRENT_TAB_KEY, tab)
},
getCurrentTab(): 'chat' | 'contact' {
getCurrentTab(): 'chat' | 'contact' | 'moment' {
const tab = localStorage.getItem(CURRENT_TAB_KEY)
return (tab === 'chat' || tab === 'contact') ? tab : 'chat'
return (tab === 'chat' || tab === 'contact' || tab === 'moment') ? tab : 'chat'
},
// Contact Left Panel Mode

View File

@@ -26,6 +26,16 @@
<i class="fas fa-address-book text-xl"></i>
</div>
<div
class="text-gray-400 hover:text-primary cursor-pointer transition transform hover:scale-110 relative w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
:class="{ 'text-primary bg-white/5': currentTab === 'moment' }"
@click="currentTab = 'moment'"
title="朋友圈"
>
<i class="fas fa-camera text-xl"></i>
<div v-if="momentUnreadCount > 0" class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
</div>
<div
class="mt-auto mb-2 text-gray-500 hover:text-red-500 cursor-pointer transition transform hover:scale-110 w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
@click="handleLogout"
@@ -295,6 +305,14 @@
<GroupNotifyView v-else-if="contactStore.leftPanelMode === 'group-notify'" />
</div>
</div>
<!-- 朋友圈页面 -->
<div
v-if="currentTab === 'moment'"
class="flex-1 bg-dark h-full overflow-hidden"
>
<MomentPanel />
</div>
<ContextMenu />
<FileConfirmModal :show="fileModal.show" :type="fileModal.type" :preview="fileModal.preview" :name="fileModal.name" :size="fileModal.size" @close="fileModal.show = false" @confirm="confirmSendFile" />
<ConfirmModal
@@ -608,6 +626,7 @@ import { useChatStore } from '@/stores/chat'
import { useConversationStore } from '@/stores/conversation'
import { useContactStore } from '@/stores/contact'
import { useToastStore } from '@/stores/toast'
import { useMomentStore } from '@/stores/moment'
import { useContextMenu } from '@/composables/useContextMenu'
import { wsManager } from '@/api/websocket'
import * as messageApi from '@/api/modules/message'
@@ -637,6 +656,7 @@ import SelectContactsModal from '@/components/chat/SelectContactsModal.vue'
import GroupInfoPanel from '@/components/chat/GroupInfoPanel.vue'
import GroupChatPanel from '@/components/chat/GroupChatPanel.vue'
import UserInfoCard from '@/components/common/UserInfoCard.vue'
import MomentPanel from '@/components/moment/MomentPanel.vue'
import * as groupApi from '@/api/modules/room'
const router = useRouter()
@@ -645,12 +665,16 @@ const chatStore = useChatStore()
const conversationStore = useConversationStore()
const toastStore = useToastStore()
const contactStore = useContactStore()
const momentStore = useMomentStore()
const { showContextMenu } = useContextMenu()
const webrtcStore = useWebRTCStore()
const webrtc = webrtcStore.webrtc
// 朋友圈未读数
const momentUnreadCount = computed(() => momentStore.unreadCount)
// State
const currentTab = ref<'chat' | 'contact'>(storage.getCurrentTab())
const currentTab = ref<'chat' | 'contact' | 'moment'>(storage.getCurrentTab() as 'chat' | 'contact' | 'moment')
const searchQuery = ref('')
const inputText = ref('')
const chatVisible = ref(false)
@@ -1923,7 +1947,7 @@ function getUserEmail(user: Contact | User | null): string | null {
watch(currentTab, (newTab) => {
storage.setCurrentTab(newTab)
if (newTab === 'contact') {
if (newTab === 'contact' || newTab === 'moment') {
chatStore.setCurrentTarget(null)
storage.setSelectedConversation('')
storage.setSelectedRoomId('')
@@ -1942,6 +1966,8 @@ onMounted(async () => {
}
await loadContacts()
await conversationStore.loadConversations()
// 获取朋友圈未读数
momentStore.fetchUnreadCount()
const savedRoomId = storage.getSelectedRoomId()
const savedTargetId = storage.getSelectedConversation()

View File

@@ -119,3 +119,6 @@

View File

@@ -0,0 +1,178 @@
<template>
<div class="moment-detail h-full flex flex-col bg-gray-50">
<!-- 头部 -->
<header class="sticky top-0 z-10 bg-white border-b border-gray-200 px-4 py-3">
<div class="flex items-center">
<button @click="goBack" class="p-2 -ml-2 text-gray-600 hover:text-gray-800">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 class="text-lg font-semibold text-gray-800 ml-2">动态详情</h1>
</div>
</header>
<!-- 主内容 -->
<main class="flex-1 overflow-y-auto">
<!-- 加载中 -->
<div v-if="loading" class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="flex flex-col items-center justify-center py-16 px-4">
<svg class="w-16 h-16 text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p class="text-gray-500">{{ error }}</p>
<button @click="loadDetail" class="mt-4 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
重试
</button>
</div>
<!-- 动态内容 -->
<div v-else-if="momentStore.currentMoment" class="bg-white">
<!-- 动态主体 -->
<MomentCard
:moment="momentStore.currentMoment"
:show-full-content="true"
@like="handleLike"
@comment="openCommentInput"
@delete="handleDelete"
/>
<!-- 评论区标题 -->
<div class="px-4 py-3 bg-gray-50 border-t border-b border-gray-200">
<span class="text-gray-600 font-medium">
评论 {{ momentStore.currentMoment.comment_count }}
</span>
</div>
<!-- 评论列表 -->
<MomentCommentList
:comments="momentStore.currentMoment.comments || []"
:moment-user-id="momentStore.currentMoment.user_id"
@reply="handleReply"
@delete="handleDeleteComment"
/>
<!-- 无评论 -->
<div v-if="!momentStore.currentMoment.comments?.length" class="py-8 text-center text-gray-400">
暂无评论快来抢沙发吧
</div>
</div>
</main>
<!-- 底部评论输入栏 -->
<footer v-if="momentStore.currentMoment" class="bg-white border-t border-gray-200 px-4 py-3">
<button
@click="openCommentInput()"
class="w-full py-2.5 px-4 bg-gray-100 rounded-full text-gray-400 text-left text-sm"
>
写评论...
</button>
</footer>
<!-- 评论输入弹窗 -->
<MomentCommentInput
v-if="showCommentInput && momentStore.currentMoment"
:moment-id="momentStore.currentMoment.id"
:reply-to="replyToComment"
@close="closeCommentInput"
@commented="onCommented"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMomentStore } from '@/stores/moment'
import MomentCard from '@/components/moment/MomentCard.vue'
import MomentCommentList from '@/components/moment/MomentCommentList.vue'
import MomentCommentInput from '@/components/moment/MomentCommentInput.vue'
import type { MomentComment } from '@/types/moment'
const route = useRoute()
const router = useRouter()
const momentStore = useMomentStore()
// 状态
const loading = ref(false)
const error = ref('')
const showCommentInput = ref(false)
const replyToComment = ref<MomentComment | null>(null)
// 初始化
onMounted(() => {
loadDetail()
})
// 加载详情
async function loadDetail() {
const id = Number(route.params.id)
if (isNaN(id)) {
error.value = '无效的动态ID'
return
}
loading.value = true
error.value = ''
try {
await momentStore.fetchMomentDetail(id)
} catch (e: any) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
}
}
// 返回
function goBack() {
router.back()
}
// 点赞
async function handleLike(momentId: number) {
await momentStore.toggleLike(momentId)
}
// 打开评论输入
function openCommentInput(replyTo?: MomentComment) {
replyToComment.value = replyTo || null
showCommentInput.value = true
}
// 关闭评论输入
function closeCommentInput() {
showCommentInput.value = false
replyToComment.value = null
}
// 评论完成
function onCommented() {
closeCommentInput()
}
// 回复评论
function handleReply(comment: MomentComment) {
openCommentInput(comment)
}
// 删除评论
async function handleDeleteComment(commentId: number) {
if (!momentStore.currentMoment) return
if (confirm('确定要删除这条评论吗?')) {
await momentStore.removeComment(momentStore.currentMoment.id, commentId)
}
}
// 删除动态
async function handleDelete(momentId: number) {
if (confirm('确定要删除这条动态吗?')) {
await momentStore.removeMoment(momentId)
router.back()
}
}
</script>

View File

@@ -0,0 +1,150 @@
<template>
<div class="moment-notify h-full flex flex-col bg-gray-50">
<!-- 头部 -->
<header class="sticky top-0 z-10 bg-white border-b border-gray-200 px-4 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center">
<button @click="goBack" class="p-2 -ml-2 text-gray-600 hover:text-gray-800">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 class="text-lg font-semibold text-gray-800 ml-2">消息通知</h1>
</div>
<!-- 全部已读 -->
<button
v-if="momentStore.hasUnread"
@click="markAllRead"
class="text-sm text-blue-500 hover:text-blue-600"
>
全部已读
</button>
</div>
</header>
<!-- 通知列表 -->
<main class="flex-1 overflow-y-auto" @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-blue-500"></div>
</div>
<!-- 空状态 -->
<div v-else-if="momentStore.notifications.length === 0" class="flex flex-col items-center justify-center py-16 px-4">
<svg class="w-16 h-16 text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<p class="text-gray-500">暂无消息通知</p>
</div>
<!-- 通知列表 -->
<div v-else class="divide-y divide-gray-200 bg-white">
<div
v-for="notification in momentStore.notifications"
:key="notification.id"
@click="handleNotificationClick(notification)"
class="flex items-start gap-3 p-4 hover:bg-gray-50 cursor-pointer transition-colors"
:class="{ 'bg-blue-50/50': !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-800">{{ 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-600 text-sm mt-1 line-clamp-2">
{{ notification.comment.content }}
</p>
<!-- 动态预览 -->
<div v-if="notification.moment" class="mt-2 p-2 bg-gray-100 rounded text-sm text-gray-500 line-clamp-2">
{{ notification.moment.content || '[图片/视频]' }}
</div>
<!-- 时间 -->
<p class="text-gray-400 text-xs mt-2">{{ formatTime(notification.created_at) }}</p>
</div>
<!-- 未读标记 -->
<div v-if="!notification.is_read" class="w-2 h-2 rounded-full bg-blue-500 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-blue-500"></div>
</div>
<!-- 没有更多 -->
<div v-if="!momentStore.notifPagination.hasMore && momentStore.notifications.length > 0" class="text-center py-4 text-gray-400 text-sm">
没有更多了
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useMomentStore } from '@/stores/moment'
import Avatar from '@/components/common/Avatar.vue'
import { NotificationTypeText } from '@/types/moment'
import type { MomentNotification } from '@/types/moment'
import { formatRelativeTime } from '@/utils/format'
const router = useRouter()
const momentStore = useMomentStore()
// 初始化
onMounted(() => {
momentStore.fetchNotifications(true)
})
// 返回
function goBack() {
router.back()
}
// 滚动加载更多
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()
}
}
// 获取通知文本
function getNotificationText(type: number): string {
return NotificationTypeText[type] || ''
}
// 格式化时间
function formatTime(time: string): string {
return formatRelativeTime(time)
}
// 点击通知
async function handleNotificationClick(notification: MomentNotification) {
// 标记已读
if (!notification.is_read) {
await momentStore.markAsRead([notification.id])
}
// 跳转到动态详情
router.push(`/moment/${notification.moment_id}`)
}
// 全部已读
async function markAllRead() {
await momentStore.markAllAsRead()
}
</script>

View File

@@ -0,0 +1,176 @@
<template>
<div class="moment-view h-full flex flex-col bg-gray-50">
<!-- 头部 -->
<header class="sticky top-0 z-10 bg-white border-b border-gray-200 px-4 py-3">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold text-gray-800">朋友圈</h1>
<div class="flex items-center gap-3">
<!-- 通知入口 -->
<button
@click="goToNotifications"
class="relative p-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-full 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 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<!-- 未读数角标 -->
<span
v-if="momentStore.unreadCount > 0"
class="absolute -top-1 -right-1 bg-red-500 text-white text-xs 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 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-full 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="M12 4v16m8-8H4" />
</svg>
</button>
</div>
</div>
</header>
<!-- 动态列表 -->
<main class="flex-1 overflow-y-auto" @scroll="handleScroll">
<!-- 加载中 -->
<div v-if="momentStore.loading && momentStore.moments.length === 0" class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
<!-- 空状态 -->
<div v-else-if="momentStore.moments.length === 0" class="flex flex-col items-center justify-center py-16 px-4">
<svg class="w-16 h-16 text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p class="text-gray-500 text-center">暂无动态</p>
<p class="text-gray-400 text-sm mt-1">快去发布你的第一条动态吧</p>
</div>
<!-- 动态卡片列表 -->
<div v-else class="divide-y divide-gray-200">
<MomentCard
v-for="moment in momentStore.moments"
:key="moment.id"
:moment="moment"
@click="goToDetail(moment.id)"
@like="handleLike"
@comment="handleComment"
@delete="handleDelete"
/>
</div>
<!-- 加载更多 -->
<div v-if="momentStore.loading && momentStore.moments.length > 0" class="flex justify-center py-4">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
</div>
<!-- 没有更多 -->
<div v-if="!momentStore.pagination.hasMore && momentStore.moments.length > 0" class="text-center py-4 text-gray-400 text-sm">
没有更多了
</div>
</main>
<!-- 发布动态弹窗 -->
<MomentPublisher
v-if="showPublisher"
@close="showPublisher = false"
@published="onPublished"
/>
<!-- 评论输入弹窗 -->
<MomentCommentInput
v-if="commentingMoment"
:moment-id="commentingMoment.id"
:reply-to="replyToComment"
@close="closeCommentInput"
@commented="onCommented"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useMomentStore } from '@/stores/moment'
import MomentCard from '@/components/moment/MomentCard.vue'
import MomentPublisher from '@/components/moment/MomentPublisher.vue'
import MomentCommentInput from '@/components/moment/MomentCommentInput.vue'
import type { Moment, MomentComment } from '@/types/moment'
const router = useRouter()
const momentStore = useMomentStore()
// 状态
const showPublisher = ref(false)
const commentingMoment = ref<Moment | null>(null)
const replyToComment = ref<MomentComment | null>(null)
// 初始化
onMounted(() => {
momentStore.fetchMoments()
momentStore.fetchUnreadCount()
})
// 滚动加载更多
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()
}
}
// 跳转到详情页
function goToDetail(id: number) {
router.push(`/moment/${id}`)
}
// 跳转到通知页
function goToNotifications() {
router.push('/moment/notifications')
}
// 点赞
async function handleLike(momentId: number) {
await momentStore.toggleLike(momentId)
}
// 打开评论输入
function handleComment(moment: Moment, replyTo?: MomentComment) {
commentingMoment.value = moment
replyToComment.value = replyTo || null
}
// 关闭评论输入
function closeCommentInput() {
commentingMoment.value = null
replyToComment.value = null
}
// 评论完成
function onCommented() {
closeCommentInput()
}
// 删除动态
async function handleDelete(momentId: number) {
if (confirm('确定要删除这条动态吗?')) {
await momentStore.removeMoment(momentId)
}
}
// 发布完成
function onPublished() {
showPublisher.value = false
}
</script>
<style scoped>
.moment-view {
max-width: 100%;
}
</style>