From 05a84220076ab93c17c7ef2ad8a72c6c747e491d Mon Sep 17 00:00:00 2001 From: liqi Date: Mon, 8 Dec 2025 09:07:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=8B=E5=8F=8B=E5=9C=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.vue | 13 + src/api/modules/attachment.ts | 13 +- src/api/modules/moment.ts | 135 +++++ src/api/websocket/index.ts | 37 +- src/components/common/UserInfoCard.vue | 3 + src/components/moment/MomentCard.vue | 247 +++++++++ src/components/moment/MomentCommentInput.vue | 88 ++++ src/components/moment/MomentCommentList.vue | 81 +++ .../moment/MomentNotificationPanel.vue | 159 ++++++ src/components/moment/MomentPanel.vue | 355 +++++++++++++ src/components/moment/MomentPublisher.vue | 396 +++++++++++++++ src/components/moment/MomentPublisherDark.vue | 411 +++++++++++++++ src/components/moment/VisibilitySelector.vue | 81 +++ src/router/index.ts | 19 + src/stores/moment.ts | 470 ++++++++++++++++++ src/types/moment.ts | 178 +++++++ src/utils/format.ts | 7 + src/utils/storage.ts | 8 +- src/views/chat/ChatView.vue | 30 +- src/views/contact/README.md | 3 + src/views/moment/MomentDetailView.vue | 178 +++++++ src/views/moment/MomentNotifyView.vue | 150 ++++++ src/views/moment/MomentView.vue | 176 +++++++ 23 files changed, 3229 insertions(+), 9 deletions(-) create mode 100644 src/api/modules/moment.ts create mode 100644 src/components/moment/MomentCard.vue create mode 100644 src/components/moment/MomentCommentInput.vue create mode 100644 src/components/moment/MomentCommentList.vue create mode 100644 src/components/moment/MomentNotificationPanel.vue create mode 100644 src/components/moment/MomentPanel.vue create mode 100644 src/components/moment/MomentPublisher.vue create mode 100644 src/components/moment/MomentPublisherDark.vue create mode 100644 src/components/moment/VisibilitySelector.vue create mode 100644 src/stores/moment.ts create mode 100644 src/types/moment.ts create mode 100644 src/views/moment/MomentDetailView.vue create mode 100644 src/views/moment/MomentNotifyView.vue create mode 100644 src/views/moment/MomentView.vue diff --git a/src/App.vue b/src/App.vue index b53c7e9..4121994 100644 --- a/src/App.vue +++ b/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) }) diff --git a/src/api/modules/attachment.ts b/src/api/modules/attachment.ts index 407aec9..02fdfcb 100644 --- a/src/api/modules/attachment.ts +++ b/src/api/modules/attachment.ts @@ -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('/attachments/upload', formData, { diff --git a/src/api/modules/moment.ts b/src/api/modules/moment.ts new file mode 100644 index 0000000..eeac723 --- /dev/null +++ b/src/api/modules/moment.ts @@ -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('/moments', data) +} + +/** + * 获取好友动态列表 + */ +export const getMoments = (page = 1, pageSize = 20) => { + return request.get>('/moments', { + params: { page, page_size: pageSize }, + }) +} + +/** + * 获取动态详情 + */ +export const getMomentDetail = (id: number) => { + return request.get(`/moments/${id}`) +} + +/** + * 删除动态 + */ +export const deleteMoment = (id: number) => { + return request.delete(`/moments/${id}`) +} + +/** + * 获取指定用户的动态列表 + */ +export const getUserMoments = (userId: string, page = 1, pageSize = 20) => { + return request.get>(`/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(`/moments/${momentId}/likes`) +} + +// ========================================== +// 评论相关 +// ========================================== + +/** + * 获取动态的评论列表 + */ +export const getMomentComments = (momentId: number) => { + return request.get(`/moments/${momentId}/comments`) +} + +/** + * 发表评论 + */ +export const createComment = (momentId: number, data: CreateCommentRequest) => { + return request.post(`/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>('/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') +} diff --git a/src/api/websocket/index.ts b/src/api/websocket/index.ts index 1a2a399..054e4ea 100644 --- a/src/api/websocket/index.ts +++ b/src/api/websocket/index.ts @@ -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)) + } + /** * 内部处理接收到的消息 */ diff --git a/src/components/common/UserInfoCard.vue b/src/components/common/UserInfoCard.vue index 8037151..094a4b8 100644 --- a/src/components/common/UserInfoCard.vue +++ b/src/components/common/UserInfoCard.vue @@ -211,3 +211,6 @@ function viewAvatar() { + + + diff --git a/src/components/moment/MomentCard.vue b/src/components/moment/MomentCard.vue new file mode 100644 index 0000000..86afa46 --- /dev/null +++ b/src/components/moment/MomentCard.vue @@ -0,0 +1,247 @@ + + + + + diff --git a/src/components/moment/MomentCommentInput.vue b/src/components/moment/MomentCommentInput.vue new file mode 100644 index 0000000..cfe8648 --- /dev/null +++ b/src/components/moment/MomentCommentInput.vue @@ -0,0 +1,88 @@ + + + diff --git a/src/components/moment/MomentCommentList.vue b/src/components/moment/MomentCommentList.vue new file mode 100644 index 0000000..95d9cfd --- /dev/null +++ b/src/components/moment/MomentCommentList.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/components/moment/MomentNotificationPanel.vue b/src/components/moment/MomentNotificationPanel.vue new file mode 100644 index 0000000..a3b9035 --- /dev/null +++ b/src/components/moment/MomentNotificationPanel.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/src/components/moment/MomentPanel.vue b/src/components/moment/MomentPanel.vue new file mode 100644 index 0000000..0c29e91 --- /dev/null +++ b/src/components/moment/MomentPanel.vue @@ -0,0 +1,355 @@ + + + + + diff --git a/src/components/moment/MomentPublisher.vue b/src/components/moment/MomentPublisher.vue new file mode 100644 index 0000000..1c95161 --- /dev/null +++ b/src/components/moment/MomentPublisher.vue @@ -0,0 +1,396 @@ + + + + + diff --git a/src/components/moment/MomentPublisherDark.vue b/src/components/moment/MomentPublisherDark.vue new file mode 100644 index 0000000..2422a33 --- /dev/null +++ b/src/components/moment/MomentPublisherDark.vue @@ -0,0 +1,411 @@ + + + + + diff --git a/src/components/moment/VisibilitySelector.vue b/src/components/moment/VisibilitySelector.vue new file mode 100644 index 0000000..a56ebe6 --- /dev/null +++ b/src/components/moment/VisibilitySelector.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/router/index.ts b/src/router/index.ts index 0313e43..52e2c1f 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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({ diff --git a/src/stores/moment.ts b/src/stores/moment.ts new file mode 100644 index 0000000..b0053b5 --- /dev/null +++ b/src/stores/moment.ts @@ -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([]) + // 当前查看的动态详情 + const currentMoment = ref(null) + // 通知列表 + const notifications = ref([]) + // 未读通知数 + 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, + } +}) diff --git a/src/types/moment.ts b/src/types/moment.ts new file mode 100644 index 0000000..6b328be --- /dev/null +++ b/src/types/moment.ts @@ -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 { + 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 = { + 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 [] + } +} diff --git a/src/utils/format.ts b/src/utils/format.ts index 2a270dc..b73e508 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -145,6 +145,13 @@ export function parseText(text: string): string { return text.replace(urlRegex, '$1') } +/** + * 格式化相对时间(别名,用于朋友圈等场景) + */ +export function formatRelativeTime(timestamp: number | string): string { + return formatTime(timestamp) +} + /** * 生成用户头像颜色 */ diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 92ee167..ecb6afb 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -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 diff --git a/src/views/chat/ChatView.vue b/src/views/chat/ChatView.vue index 7abd268..fb989a8 100644 --- a/src/views/chat/ChatView.vue +++ b/src/views/chat/ChatView.vue @@ -26,6 +26,16 @@ +
+ +
+
+
+ + +
+ +
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() diff --git a/src/views/contact/README.md b/src/views/contact/README.md index 0d7a292..c7cf6cc 100644 --- a/src/views/contact/README.md +++ b/src/views/contact/README.md @@ -119,3 +119,6 @@ + + + diff --git a/src/views/moment/MomentDetailView.vue b/src/views/moment/MomentDetailView.vue new file mode 100644 index 0000000..011403b --- /dev/null +++ b/src/views/moment/MomentDetailView.vue @@ -0,0 +1,178 @@ + + + diff --git a/src/views/moment/MomentNotifyView.vue b/src/views/moment/MomentNotifyView.vue new file mode 100644 index 0000000..1ca325c --- /dev/null +++ b/src/views/moment/MomentNotifyView.vue @@ -0,0 +1,150 @@ + + + diff --git a/src/views/moment/MomentView.vue b/src/views/moment/MomentView.vue new file mode 100644 index 0000000..ce9965d --- /dev/null +++ b/src/views/moment/MomentView.vue @@ -0,0 +1,176 @@ + + + + +