朋友圈
This commit is contained in:
13
src/App.vue
13
src/App.vue
@@ -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>
|
||||
|
||||
@@ -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
135
src/api/modules/moment.ts
Normal 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')
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部处理接收到的消息
|
||||
*/
|
||||
|
||||
@@ -211,3 +211,6 @@ function viewAvatar() {
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
247
src/components/moment/MomentCard.vue
Normal file
247
src/components/moment/MomentCard.vue
Normal 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>
|
||||
88
src/components/moment/MomentCommentInput.vue
Normal file
88
src/components/moment/MomentCommentInput.vue
Normal 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>
|
||||
81
src/components/moment/MomentCommentList.vue
Normal file
81
src/components/moment/MomentCommentList.vue
Normal 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>
|
||||
159
src/components/moment/MomentNotificationPanel.vue
Normal file
159
src/components/moment/MomentNotificationPanel.vue
Normal 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>
|
||||
355
src/components/moment/MomentPanel.vue
Normal file
355
src/components/moment/MomentPanel.vue
Normal 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>
|
||||
396
src/components/moment/MomentPublisher.vue
Normal file
396
src/components/moment/MomentPublisher.vue
Normal 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>
|
||||
411
src/components/moment/MomentPublisherDark.vue
Normal file
411
src/components/moment/MomentPublisherDark.vue
Normal 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>
|
||||
81
src/components/moment/VisibilitySelector.vue
Normal file
81
src/components/moment/VisibilitySelector.vue
Normal 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>
|
||||
@@ -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
470
src/stores/moment.ts
Normal 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
178
src/types/moment.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成用户头像颜色
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -119,3 +119,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
178
src/views/moment/MomentDetailView.vue
Normal file
178
src/views/moment/MomentDetailView.vue
Normal 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>
|
||||
150
src/views/moment/MomentNotifyView.vue
Normal file
150
src/views/moment/MomentNotifyView.vue
Normal 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>
|
||||
176
src/views/moment/MomentView.vue
Normal file
176
src/views/moment/MomentView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user