This commit is contained in:
2026-06-13 10:40:59 +08:00
parent ebcfb0c258
commit 318ddd85a5
15 changed files with 614 additions and 30 deletions
+33
View File
@@ -10,6 +10,8 @@ from app.schemas.conversation import (
GroupCreate, GroupUpdate, MemberAdd, RoleUpdate, GroupCreate, GroupUpdate, MemberAdd, RoleUpdate,
) )
from app.services.conversation_service import ConversationService from app.services.conversation_service import ConversationService
from app.websocket.events import EventType
from app.websocket.manager import manager
router = APIRouter() router = APIRouter()
@@ -97,6 +99,16 @@ async def add_members(
service = ConversationService(db) service = ConversationService(db)
try: try:
await service.add_members(conversation_id, user.id, req.user_ids) await service.add_members(conversation_id, user.id, req.user_ids)
# 通知新成员被加入群聊
detail = await service.get_conversation_detail(conversation_id, user.id)
group_name = detail.get("name", "群聊") if detail else "群聊"
for mid in req.user_ids:
await manager.send_to_user(mid, EventType.CONVERSATION_MEMBER_ADDED, {
"conversation_id": conversation_id,
"group_name": group_name,
"added_by_user_id": user.id,
"added_by_username": user.username,
})
return {"success": True, "message": "成员已添加"} return {"success": True, "message": "成员已添加"}
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -113,6 +125,12 @@ async def remove_member(
service = ConversationService(db) service = ConversationService(db)
try: try:
await service.remove_member(conversation_id, user.id, target_user_id) await service.remove_member(conversation_id, user.id, target_user_id)
# 通知被移除的用户
await manager.send_to_user(target_user_id, EventType.CONVERSATION_MEMBER_REMOVED, {
"conversation_id": conversation_id,
"removed_by_user_id": user.id,
"removed_by_username": user.username,
})
return {"success": True, "message": "成员已移除"} return {"success": True, "message": "成员已移除"}
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -133,6 +151,21 @@ async def leave_group(
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@router.post("/{conversation_id}/dissolve")
async def dissolve_group(
conversation_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""解散群聊(仅群主)"""
service = ConversationService(db)
try:
await service.dissolve_group(conversation_id, user.id)
return {"success": True, "message": "群聊已解散"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{conversation_id}/members/{target_user_id}/role") @router.put("/{conversation_id}/members/{target_user_id}/role")
async def update_member_role( async def update_member_role(
conversation_id: str, conversation_id: str,
+27
View File
@@ -7,6 +7,8 @@ from app.dependencies import get_db, get_current_user
from app.models.user import User from app.models.user import User
from app.schemas.friend import FriendRequestCreate, FriendRead, FriendRequestRead, RemarkUpdate from app.schemas.friend import FriendRequestCreate, FriendRead, FriendRequestRead, RemarkUpdate
from app.services.friend_service import FriendService from app.services.friend_service import FriendService
from app.websocket.events import EventType
from app.websocket.manager import manager
router = APIRouter() router = APIRouter()
@@ -41,6 +43,14 @@ async def send_friend_request(
service = FriendService(db) service = FriendService(db)
try: try:
await service.send_request(user.id, req.to_user_id, req.message) await service.send_request(user.id, req.to_user_id, req.message)
# 通知接收者
await manager.send_to_user(req.to_user_id, EventType.FRIEND_REQUEST, {
"from_user_id": user.id,
"from_username": user.username,
"from_nickname": user.nickname,
"from_avatar": user.avatar_url,
"message": req.message,
})
return {"success": True, "message": "好友请求已发送"} return {"success": True, "message": "好友请求已发送"}
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -86,7 +96,24 @@ async def accept_friend_request(
"""接受好友请求""" """接受好友请求"""
service = FriendService(db) service = FriendService(db)
try: try:
# Get request details before accepting to know who sent it
request = await service.get_pending_requests(user.id)
from_user_id = None
for r in request:
if r["id"] == request_id:
from_user_id = r["from_user_id"]
break
await service.accept_request(request_id, user.id) await service.accept_request(request_id, user.id)
# Notify the requester that their request was accepted
if from_user_id:
await manager.send_to_user(from_user_id, EventType.FRIEND_ACCEPTED, {
"accepted_by_user_id": user.id,
"accepted_by_username": user.username,
"accepted_by_nickname": user.nickname,
})
return {"success": True, "message": "已添加好友"} return {"success": True, "message": "已添加好友"}
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
+2
View File
@@ -21,6 +21,8 @@ class MessageRead(BaseModel):
type: str type: str
content: str content: str
reply_to_id: str | None = None reply_to_id: str | None = None
reply_to_content: str | None = None
reply_to_sender_name: str | None = None
is_deleted: bool = False is_deleted: bool = False
created_at: datetime created_at: datetime
@@ -115,6 +115,33 @@ class ConversationService:
raise ValueError("群主不能退出,请先转让群主身份") raise ValueError("群主不能退出,请先转让群主身份")
member.left_at = datetime.utcnow() member.left_at = datetime.utcnow()
async def dissolve_group(self, conv_id: str, user_id: str):
"""解散群聊(仅群主)"""
member = await self._get_member(conv_id, user_id)
if not member:
raise ValueError("你不在该群中")
if member.role != "owner":
raise ValueError("只有群主可以解散群聊")
# 验证会话存在且为群聊
conv_result = await self.db.execute(
select(Conversation).where(Conversation.id == conv_id)
)
conv = conv_result.scalars().first()
if not conv or conv.type != "group":
raise ValueError("群聊不存在")
# 软删除所有成员(设置 left_at)
members_result = await self.db.execute(
select(ConversationMember).where(
ConversationMember.conversation_id == conv_id,
ConversationMember.left_at.is_(None),
)
)
now = datetime.utcnow()
for m in members_result.scalars().all():
m.left_at = now
async def update_member_role(self, conv_id: str, user_id: str, target_user_id: str, role: str): async def update_member_role(self, conv_id: str, user_id: str, target_user_id: str, role: str):
"""修改成员角色(仅群主)""" """修改成员角色(仅群主)"""
member = await self._get_member(conv_id, user_id) member = await self._get_member(conv_id, user_id)
+19
View File
@@ -85,6 +85,23 @@ class MessageService:
select(User).where(User.id == msg.sender_id) select(User).where(User.id == msg.sender_id)
) )
sender = sender_result.scalars().first() sender = sender_result.scalars().first()
# 获取被引用消息的信息
reply_to_content = None
reply_to_sender_name = None
if msg.reply_to_id:
reply_msg_result = await self.db.execute(
select(Message).where(Message.id == msg.reply_to_id)
)
reply_msg = reply_msg_result.scalars().first()
if reply_msg:
reply_to_content = reply_msg.content[:200] if reply_msg.content else None
reply_sender_result = await self.db.execute(
select(User).where(User.id == reply_msg.sender_id)
)
reply_sender = reply_sender_result.scalars().first()
reply_to_sender_name = reply_sender.username if reply_sender else None
message_list.append({ message_list.append({
"id": msg.id, "id": msg.id,
"conversation_id": msg.conversation_id, "conversation_id": msg.conversation_id,
@@ -94,6 +111,8 @@ class MessageService:
"type": msg.type, "type": msg.type,
"content": msg.content, "content": msg.content,
"reply_to_id": msg.reply_to_id, "reply_to_id": msg.reply_to_id,
"reply_to_content": reply_to_content,
"reply_to_sender_name": reply_to_sender_name,
"is_deleted": msg.is_deleted, "is_deleted": msg.is_deleted,
"created_at": msg.created_at, "created_at": msg.created_at,
}) })
+18
View File
@@ -4,6 +4,7 @@ import json
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import WebSocket from fastapi import WebSocket
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.websocket.events import EventType from app.websocket.events import EventType
@@ -34,6 +35,20 @@ async def handle_chat_send(ws: WebSocket, user_id: str, data: dict, db: AsyncSes
conv_service = ConversationService(db) conv_service = ConversationService(db)
detail = await conv_service.get_conversation_detail(data["conversation_id"], user_id) detail = await conv_service.get_conversation_detail(data["conversation_id"], user_id)
# 获取被引用消息的信息
reply_to_content = None
reply_to_sender_name = None
if message.reply_to_id:
from app.models.message import Message
reply_msg_result = await db.execute(
select(Message).where(Message.id == message.reply_to_id)
)
reply_msg = reply_msg_result.scalars().first()
if reply_msg:
reply_to_content = reply_msg.content[:200] if reply_msg.content else None
reply_sender = await user_service.get_by_id(reply_msg.sender_id)
reply_to_sender_name = reply_sender.username if reply_sender else None
msg_data = { msg_data = {
"id": message.id, "id": message.id,
"conversation_id": message.conversation_id, "conversation_id": message.conversation_id,
@@ -42,6 +57,9 @@ async def handle_chat_send(ws: WebSocket, user_id: str, data: dict, db: AsyncSes
"sender_avatar": sender.avatar_url if sender else None, "sender_avatar": sender.avatar_url if sender else None,
"type": message.type, "type": message.type,
"content": message.content, "content": message.content,
"reply_to_id": message.reply_to_id,
"reply_to_content": reply_to_content,
"reply_to_sender_name": reply_to_sender_name,
"created_at": message.created_at.isoformat(), "created_at": message.created_at.isoformat(),
} }
+4 -1
View File
@@ -11,7 +11,7 @@ export const chatApi = {
getConversationDetail: (id: string) => api.get(`/conversations/${id}`), getConversationDetail: (id: string) => api.get(`/conversations/${id}`),
updateGroup: (id: string, data: { name?: string; description?: string }) => updateGroup: (id: string, data: { name?: string; description?: string; avatar_url?: string }) =>
api.put(`/conversations/${id}`, data), api.put(`/conversations/${id}`, data),
addMembers: (convId: string, userIds: string[]) => addMembers: (convId: string, userIds: string[]) =>
@@ -23,6 +23,9 @@ export const chatApi = {
leaveGroup: (convId: string) => leaveGroup: (convId: string) =>
api.post(`/conversations/${convId}/leave`), api.post(`/conversations/${convId}/leave`),
dissolveGroup: (convId: string) =>
api.post(`/conversations/${convId}/dissolve`),
getMessages: (conversationId: string, before?: string, limit = 50) => { getMessages: (conversationId: string, before?: string, limit = 50) => {
const params: Record<string, any> = { limit } const params: Record<string, any> = { limit }
if (before) params.before = before if (before) params.before = before
+28 -1
View File
@@ -11,6 +11,7 @@ const maxReconnectAttempts = 10
// 共享状态 // 共享状态
const connected = ref(false) const connected = ref(false)
const onlineUsers = ref(new Map<string, boolean>()) const onlineUsers = ref(new Map<string, boolean>())
const typingUsers = ref(new Map<string, { username: string; timeout: ReturnType<typeof setTimeout> }>())
export function useWebSocket() { export function useWebSocket() {
@@ -80,11 +81,37 @@ export function useWebSocket() {
onlineUsers.value.set(event.data.user_id, false) onlineUsers.value.set(event.data.user_id, false)
console.log(`用户 ${event.data.user_id} 下线`) console.log(`用户 ${event.data.user_id} 下线`)
break break
case 'chat.typing':
if (event.data.conversation_id && event.data.username) {
const key = `${event.data.conversation_id}:${event.data.user_id}`
const existing = typingUsers.value.get(key)
if (existing) clearTimeout(existing.timeout)
typingUsers.value.set(key, {
username: event.data.username,
timeout: setTimeout(() => typingUsers.value.delete(key), 3000),
})
}
break
case 'friend.request': case 'friend.request':
console.log('收到好友请求:', event.data) console.log('收到好友请求:', event.data)
// 通过自定义事件通知全局 // 通过自定义事件通知全局
window.dispatchEvent(new CustomEvent('qingye:friend-request', { detail: event.data })) window.dispatchEvent(new CustomEvent('qingye:friend-request', { detail: event.data }))
break break
case 'friend.accepted':
console.log('好友请求已被接受:', event.data)
window.dispatchEvent(new CustomEvent('qingye:friend-accepted', { detail: event.data }))
break
case 'conversation.member_added':
console.log('被加入群聊:', event.data)
window.dispatchEvent(new CustomEvent('qingye:member-added', { detail: event.data }))
// 刷新会话列表
chatStore.fetchConversations()
break
case 'conversation.member_removed':
console.log('被移出群聊:', event.data)
window.dispatchEvent(new CustomEvent('qingye:member-removed', { detail: event.data }))
chatStore.fetchConversations()
break
case 'error': case 'error':
console.error('服务端错误:', event.data.message) console.error('服务端错误:', event.data.message)
break break
@@ -95,5 +122,5 @@ export function useWebSocket() {
return onlineUsers.value.get(userId) === true return onlineUsers.value.get(userId) === true
} }
return { connected, onlineUsers, connect, disconnect, send, isUserOnline } return { connected, onlineUsers, typingUsers, connect, disconnect, send, isUserOnline }
} }
+30 -1
View File
@@ -49,6 +49,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat' import { useChatStore } from '@/stores/chat'
import { useUiStore } from '@/stores/ui' import { useUiStore } from '@/stores/ui'
@@ -59,6 +60,7 @@ const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
const chatStore = useChatStore() const chatStore = useChatStore()
const uiStore = useUiStore() const uiStore = useUiStore()
const naiveMsg = useMessage()
const { connect, connected: wsConnected } = useWebSocket() const { connect, connected: wsConnected } = useWebSocket()
const isMobile = computed(() => uiStore.isMobile) const isMobile = computed(() => uiStore.isMobile)
@@ -68,8 +70,29 @@ const hideSecondary = computed(() => {
const pendingRequestCount = ref(0) const pendingRequestCount = ref(0)
function onFriendRequest() { function onFriendRequest(e: Event) {
pendingRequestCount.value++ pendingRequestCount.value++
const detail = (e as CustomEvent).detail
const name = detail?.from_nickname || detail?.from_username || '有人'
naiveMsg.info(`${name} 请求添加你为好友`)
}
function onFriendAccepted(e: Event) {
const detail = (e as CustomEvent).detail
const name = detail?.accepted_by_nickname || detail?.accepted_by_username || '好友'
naiveMsg.success(`${name} 已接受你的好友请求`)
}
function onMemberAdded(e: Event) {
const detail = (e as CustomEvent).detail
const groupName = detail?.group_name || '群聊'
naiveMsg.info(`你被加入群聊「${groupName}`)
}
function onMemberRemoved(e: Event) {
const detail = (e as CustomEvent).detail
const name = detail?.removed_by_username || '管理员'
naiveMsg.warning(`你被 ${name} 移出了群聊`)
} }
const activeFeature = computed(() => { const activeFeature = computed(() => {
@@ -89,6 +112,9 @@ const totalUnread = computed(() =>
onMounted(async () => { onMounted(async () => {
window.addEventListener('qingye:friend-request', onFriendRequest) window.addEventListener('qingye:friend-request', onFriendRequest)
window.addEventListener('qingye:friend-accepted', onFriendAccepted)
window.addEventListener('qingye:member-added', onMemberAdded)
window.addEventListener('qingye:member-removed', onMemberRemoved)
try { try {
await auth.fetchProfile() await auth.fetchProfile()
} catch { } catch {
@@ -101,6 +127,9 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('qingye:friend-request', onFriendRequest) window.removeEventListener('qingye:friend-request', onFriendRequest)
window.removeEventListener('qingye:friend-accepted', onFriendAccepted)
window.removeEventListener('qingye:member-added', onMemberAdded)
window.removeEventListener('qingye:member-removed', onMemberRemoved)
}) })
async function loadPendingRequests() { async function loadPendingRequests() {
+1 -1
View File
@@ -71,7 +71,7 @@ const routes: RouteRecordRaw[] = [
path: 'moments', path: 'moments',
name: 'Moments', name: 'Moments',
components: { components: {
secondary: () => import('@/views/moments/MomentsFeedView.vue'), secondary: () => import('@/views/moments/MomentsSidebar.vue'),
default: () => import('@/views/moments/MomentsFeedView.vue'), default: () => import('@/views/moments/MomentsFeedView.vue'),
}, },
}, },
+123 -4
View File
@@ -8,7 +8,8 @@
</div> </div>
<div class="header-info"> <div class="header-info">
<span class="room-name">{{ conversationName }}</span> <span class="room-name">{{ conversationName }}</span>
<span v-if="convDetail?.type === 'group'" class="member-count">{{ convDetail.members?.length || 0 }} 位成员</span> <span v-if="typingText" class="typing-indicator">{{ typingText }}</span>
<span v-else-if="convDetail?.type === 'group'" class="member-count">{{ convDetail.members?.length || 0 }} 位成员</span>
<span v-else class="member-count" :class="{ online: isOtherOnline }">{{ isOtherOnline ? '在线' : '离线' }}</span> <span v-else class="member-count" :class="{ online: isOtherOnline }">{{ isOtherOnline ? '在线' : '离线' }}</span>
</div> </div>
<n-button v-if="convDetail?.type === 'group'" quaternary size="small" @click="showGroupInfo = !showGroupInfo">群信息 </n-button> <n-button v-if="convDetail?.type === 'group'" quaternary size="small" @click="showGroupInfo = !showGroupInfo">群信息 </n-button>
@@ -29,6 +30,7 @@
<p class="no-msg-hint">发送第一条消息</p> <p class="no-msg-hint">发送第一条消息</p>
</div> </div>
<div v-for="(msg, idx) in allDisplayMessages" :key="msg.id || 'pending-' + idx" <div v-for="(msg, idx) in allDisplayMessages" :key="msg.id || 'pending-' + idx"
:data-msg-id="msg.id"
class="message-row" :class="{ class="message-row" :class="{
own: msg.sender_id === auth.user?.id, other: msg.sender_id !== auth.user?.id, own: msg.sender_id === auth.user?.id, other: msg.sender_id !== auth.user?.id,
pending: msg._pending, failed: msg._failed, pending: msg._pending, failed: msg._failed,
@@ -45,7 +47,13 @@
<div v-if="msg.sender_id !== auth.user?.id && convDetail?.type === 'group'" class="sender-name">{{ msg.sender_name }}</div> <div v-if="msg.sender_id !== auth.user?.id && convDetail?.type === 'group'" class="sender-name">{{ msg.sender_name }}</div>
<div class="bubble" :class="{ 'bubble-self': msg.sender_id === auth.user?.id, 'bubble-other': msg.sender_id !== auth.user?.id }" <div class="bubble" :class="{ 'bubble-self': msg.sender_id === auth.user?.id, 'bubble-other': msg.sender_id !== auth.user?.id }"
@contextmenu.prevent="onMsgContext($event, msg)"> @contextmenu.prevent="onMsgContext($event, msg)">
<img v-if="msg.type === 'image'" :src="msg.content" class="msg-image" @click="previewImage(msg.content)" /> <!-- 引用消息块 -->
<div v-if="msg.reply_to_id" class="reply-quote" @click.stop="scrollToMsg(msg.reply_to_id)">
<span class="reply-quote-author">{{ msg.reply_to_sender_name || '用户' }}</span>
<span class="reply-quote-text">{{ msg.reply_to_content || '消息不可用' }}</span>
</div>
<n-image v-if="msg.type === 'image'" :src="msg.content"
:img-props="{ style: 'max-width:240px;border-radius:10px;display:block;cursor:pointer' }" />
<span v-else>{{ msg.content }}</span> <span v-else>{{ msg.content }}</span>
</div> </div>
<div class="msg-meta"> <div class="msg-meta">
@@ -64,6 +72,7 @@
<!-- 右键菜单 --> <!-- 右键菜单 -->
<div v-if="ctxMenu.show" class="ctx-menu" :style="{ top: ctxMenu.y + 'px', left: ctxMenu.x + 'px' }"> <div v-if="ctxMenu.show" class="ctx-menu" :style="{ top: ctxMenu.y + 'px', left: ctxMenu.x + 'px' }">
<div class="ctx-item" @click="replyToMsg"> 回复</div>
<div class="ctx-item" @click="copyMsg">📋 复制</div> <div class="ctx-item" @click="copyMsg">📋 复制</div>
<div v-if="ctxMenu.msg?.sender_id === auth.user?.id && !ctxMenu.msg?._pending" class="ctx-item danger" @click="deleteMsg">🗑 删除</div> <div v-if="ctxMenu.msg?.sender_id === auth.user?.id && !ctxMenu.msg?._pending" class="ctx-item danger" @click="deleteMsg">🗑 删除</div>
</div> </div>
@@ -75,6 +84,14 @@
<!-- 输入框 --> <!-- 输入框 -->
<div class="input-bar"> <div class="input-bar">
<!-- 回复预览条 -->
<div v-if="replyingTo" class="reply-bar">
<div class="reply-bar-content">
<span class="reply-bar-label">回复 {{ replyingTo.sender_name || '用户' }}</span>
<span class="reply-bar-text">{{ (replyingTo.content || '').slice(0, 60) }}{{ (replyingTo.content || '').length > 60 ? '...' : '' }}</span>
</div>
<span class="reply-bar-close" @click="cancelReply"></span>
</div>
<div class="input-actions"> <div class="input-actions">
<span class="action-icon" @click="showEmoji = !showEmoji">😊</span> <span class="action-icon" @click="showEmoji = !showEmoji">😊</span>
<span class="action-icon" @click="triggerImageUpload">🖼</span> <span class="action-icon" @click="triggerImageUpload">🖼</span>
@@ -108,7 +125,7 @@ const route = useRoute()
const chatStore = useChatStore() const chatStore = useChatStore()
const auth = useAuthStore() const auth = useAuthStore()
const uiStore = useUiStore() const uiStore = useUiStore()
const { send, connected } = useWebSocket() const { send, connected, typingUsers } = useWebSocket()
const inputText = ref('') const inputText = ref('')
const messageListRef = ref<HTMLElement>() const messageListRef = ref<HTMLElement>()
@@ -118,8 +135,31 @@ const convDetail = ref<any>(null)
const showGroupInfo = ref(false) const showGroupInfo = ref(false)
const showEmoji = ref(false) const showEmoji = ref(false)
const isOtherOnline = ref(false) const isOtherOnline = ref(false)
const replyingTo = ref<any>(null)
const lastTypingSent = ref(0)
const emojis = EMOJIS const emojis = EMOJIS
// Typing indicator: get users typing in current conversation
const currentTypingUsers = computed(() => {
const convId = route.params.id as string
if (!convId) return []
const users: string[] = []
typingUsers.value.forEach((val, key) => {
if (key.startsWith(convId + ':') && val.username) {
users.push(val.username)
}
})
return users
})
const typingText = computed(() => {
const users = currentTypingUsers.value
if (users.length === 0) return ''
if (users.length === 1) return `${users[0]} 正在输入...`
if (users.length === 2) return `${users[0]}${users[1]} 正在输入...`
return `${users[0]}${users.length} 人正在输入...`
})
// All messages for display (real + optimistic pending) // All messages for display (real + optimistic pending)
const allDisplayMessages = computed(() => chatStore.allMessages()) const allDisplayMessages = computed(() => chatStore.allMessages())
@@ -199,6 +239,17 @@ watch(() => chatStore.pendingMessages.length, () => {
nextTick(scrollToBottom) nextTick(scrollToBottom)
}) })
// Send typing event when user types (throttled to once per 2 seconds)
watch(inputText, () => {
const convId = chatStore.activeConversation
if (!convId || !inputText.value.trim()) return
const now = Date.now()
if (now - lastTypingSent.value > 2000) {
lastTypingSent.value = now
send('chat.typing', { conversation_id: convId })
}
})
function scrollToBottom() { function scrollToBottom() {
if (messageListRef.value) { if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight messageListRef.value.scrollTop = messageListRef.value.scrollHeight
@@ -212,6 +263,8 @@ function sendMessage() {
inputText.value = '' inputText.value = ''
showEmoji.value = false showEmoji.value = false
const replyToId = replyingTo.value?.id || null
// Add optimistic pending message // Add optimistic pending message
const tempId = 'temp-' + Date.now() const tempId = 'temp-' + Date.now()
chatStore.addPendingMessage({ chatStore.addPendingMessage({
@@ -221,11 +274,44 @@ function sendMessage() {
sender_name: auth.user?.nickname || auth.user?.username, sender_name: auth.user?.nickname || auth.user?.username,
content: text, content: text,
type: 'text', type: 'text',
reply_to_id: replyToId,
reply_to_content: replyingTo.value?.content?.slice(0, 200) || null,
reply_to_sender_name: replyingTo.value?.sender_name || null,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}) })
// Send via WebSocket // Send via WebSocket
send('chat.send', { conversation_id: chatStore.activeConversation, content: text, type: 'text' }) send('chat.send', {
conversation_id: chatStore.activeConversation,
content: text,
type: 'text',
reply_to_id: replyToId,
})
// Clear reply state
replyingTo.value = null
}
function replyToMsg() {
if (ctxMenu.msg) {
replyingTo.value = { ...ctxMenu.msg }
}
ctxMenu.show = false
}
function cancelReply() {
replyingTo.value = null
}
function scrollToMsg(msgId: string) {
const el = messageListRef.value
if (!el) return
const target = el.querySelector(`[data-msg-id="${msgId}"]`)
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
target.classList.add('highlight')
setTimeout(() => target.classList.remove('highlight'), 1500)
}
} }
function insertEmoji(e: string) { function insertEmoji(e: string) {
@@ -308,6 +394,8 @@ function formatTimeDivider(time: string) {
.member-count { font-size: 12px; color: var(--color-text-hint); } .member-count { font-size: 12px; color: var(--color-text-hint); }
.member-count.online { color: var(--color-success); } .member-count.online { color: var(--color-success); }
.member-count.online::before { content: ''; display: inline-block; width: 6px; height: 6px; background: var(--color-success); border-radius: 50%; margin-right: 4px; } .member-count.online::before { content: ''; display: inline-block; width: 6px; height: 6px; background: var(--color-success); border-radius: 50%; margin-right: 4px; }
.typing-indicator { font-size: 12px; color: var(--color-primary); font-style: italic; animation: typing-fade 1.5s ease infinite; }
@keyframes typing-fade { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.chat-body { flex: 1; display: flex; overflow: hidden; position: relative; } .chat-body { flex: 1; display: flex; overflow: hidden; position: relative; }
.message-list { flex: 1; overflow-y: auto; padding: 16px 20px; } .message-list { flex: 1; overflow-y: auto; padding: 16px 20px; }
@@ -347,6 +435,37 @@ function formatTimeDivider(time: string) {
.msg-image { max-width: 240px; border-radius: 10px; display: block; cursor: pointer; } .msg-image { max-width: 240px; border-radius: 10px; display: block; cursor: pointer; }
.system-msg { text-align: center; font-size: 12px; color: var(--color-text-hint); margin: 12px 0; padding: 4px 16px; background: rgba(0,0,0,0.03); border-radius: 12px; display: inline-block; } .system-msg { text-align: center; font-size: 12px; color: var(--color-text-hint); margin: 12px 0; padding: 4px 16px; background: rgba(0,0,0,0.03); border-radius: 12px; display: inline-block; }
/* Reply quote inside bubble */
.reply-quote {
padding: 6px 10px; margin-bottom: 6px; border-radius: 6px;
background: rgba(0,0,0,0.06); border-left: 3px solid var(--color-primary);
cursor: pointer; font-size: 12px; line-height: 1.4;
}
.bubble-self .reply-quote { background: rgba(255,255,255,0.2); border-left-color: rgba(255,255,255,0.6); }
.reply-quote-author { font-weight: 600; color: var(--color-primary); display: block; margin-bottom: 2px; }
.bubble-self .reply-quote-author { color: rgba(255,255,255,0.9); }
.reply-quote-text { color: var(--color-text-secondary); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.bubble-self .reply-quote-text { color: rgba(255,255,255,0.7); }
/* Highlight animation for scrollToMsg */
.message-row.highlight .bubble { animation: highlight-pulse 1.5s ease; }
@keyframes highlight-pulse {
0%, 100% { box-shadow: none; }
30% { box-shadow: 0 0 0 3px var(--color-primary-lighter); }
}
/* Reply bar above input */
.reply-bar {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: var(--color-primary-lightest); border-radius: 8px; margin-bottom: 6px;
border-left: 3px solid var(--color-primary);
}
.reply-bar-content { flex: 1; min-width: 0; }
.reply-bar-label { display: block; font-size: 12px; font-weight: 600; color: var(--color-primary); }
.reply-bar-text { display: block; font-size: 12px; color: var(--color-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.reply-bar-close { cursor: pointer; color: var(--color-text-hint); font-size: 14px; padding: 2px; }
.reply-bar-close:hover { color: var(--color-text-primary); }
/* Context menu */ /* Context menu */
.ctx-menu { position: fixed; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,0.12); z-index: 999; min-width: 120px; overflow: hidden; } .ctx-menu { position: fixed; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,0.12); z-index: 999; min-width: 120px; overflow: hidden; }
.ctx-item { padding: 8px 16px; font-size: 13px; cursor: pointer; transition: background 0.15s; } .ctx-item { padding: 8px 16px; font-size: 13px; cursor: pointer; transition: background 0.15s; }
+152 -2
View File
@@ -7,9 +7,14 @@
<div v-if="detail" class="panel-body"> <div v-if="detail" class="panel-body">
<!-- 群头像和名称 --> <!-- 群头像和名称 -->
<div class="group-header"> <div class="group-header">
<n-avatar :size="64" round :style="{ background: 'var(--color-primary)', fontSize: '24px' }"> <div class="avatar-upload-wrap" :class="{ clickable: isAdmin }" @click="isAdmin && triggerAvatarUpload()">
<n-avatar v-if="detail.avatar_url" :src="detail.avatar_url" :size="64" round />
<n-avatar v-else :size="64" round :style="{ background: 'var(--color-primary)', fontSize: '24px' }">
{{ (detail.name || '群')[0] }} {{ (detail.name || '群')[0] }}
</n-avatar> </n-avatar>
<div v-if="isAdmin" class="avatar-overlay">📷</div>
</div>
<input ref="avatarInput" type="file" accept="image/*" style="display:none" @change="handleAvatarUpload" />
<div class="group-meta"> <div class="group-meta">
<span class="group-name">{{ detail.name || '未命名群聊' }}</span> <span class="group-name">{{ detail.name || '未命名群聊' }}</span>
<span class="group-desc">{{ detail.description || '暂无简介' }}</span> <span class="group-desc">{{ detail.description || '暂无简介' }}</span>
@@ -48,18 +53,51 @@
<!-- 操作按钮 --> <!-- 操作按钮 -->
<div class="section actions"> <div class="section actions">
<n-button v-if="isOwner" type="error" ghost block>解散群聊</n-button> <n-button v-if="isOwner" type="error" ghost block @click="dissolveGroup">解散群聊</n-button>
<n-button v-else type="error" ghost block @click="leaveGroup">退出群聊</n-button> <n-button v-else type="error" ghost block @click="leaveGroup">退出群聊</n-button>
</div> </div>
</div> </div>
<!-- 添加成员弹窗 -->
<div v-if="showAddMember" class="modal-overlay" @click.self="showAddMember = false">
<div class="add-member-modal">
<div class="modal-header">
<h3>添加群成员</h3>
<span class="close-btn" @click="showAddMember = false"></span>
</div>
<div class="modal-body">
<div v-if="availableFriends.length === 0" class="empty-friends">
<p>没有可添加的好友</p>
</div>
<div v-for="friend in availableFriends" :key="friend.friend_user_id" class="friend-select-item">
<n-avatar :size="32" round :style="{ background: 'var(--color-primary)' }">
{{ (friend.remark || friend.nickname || friend.username || '?')[0] }}
</n-avatar>
<span class="friend-select-name">{{ friend.remark || friend.nickname || friend.username }}</span>
<n-checkbox :checked="selectedNewMembers.includes(friend.friend_user_id)"
@update:checked="toggleSelectMember(friend.friend_user_id)" />
</div>
</div>
<div class="modal-footer">
<n-button size="small" @click="showAddMember = false">取消</n-button>
<n-button size="small" type="primary" :disabled="selectedNewMembers.length === 0"
:loading="addingMembers" @click="addMembers">确认添加 ({{ selectedNewMembers.length }})</n-button>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui' import { useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { chatApi } from '@/api/chat' import { chatApi } from '@/api/chat'
import { friendsApi } from '@/api/friends'
import api from '@/api/client'
const router = useRouter()
const props = defineProps<{ conversationId: string; detail: any }>() const props = defineProps<{ conversationId: string; detail: any }>()
const emit = defineEmits<{ close: []; updated: [] }>() const emit = defineEmits<{ close: []; updated: [] }>()
@@ -68,6 +106,10 @@ const auth = useAuthStore()
const message = useMessage() const message = useMessage()
const editName = ref('') const editName = ref('')
const showAddMember = ref(false) const showAddMember = ref(false)
const friends = ref<any[]>([])
const selectedNewMembers = ref<string[]>([])
const addingMembers = ref(false)
const avatarInput = ref<HTMLInputElement>()
const myRole = computed(() => { const myRole = computed(() => {
const me = props.detail?.members?.find((m: any) => m.user_id === auth.user?.id) const me = props.detail?.members?.find((m: any) => m.user_id === auth.user?.id)
@@ -80,6 +122,68 @@ watch(() => props.detail, (d) => {
if (d) editName.value = d.name || '' if (d) editName.value = d.name || ''
}, { immediate: true }) }, { immediate: true })
// Load friends when add member modal opens
watch(showAddMember, async (val) => {
if (val) {
selectedNewMembers.value = []
try {
const { data } = await friendsApi.getFriends()
friends.value = data
} catch {}
}
})
// Friends not already in the group
const availableFriends = computed(() => {
const memberIds = new Set((props.detail?.members || []).map((m: any) => m.user_id))
return friends.value.filter((f: any) => !memberIds.has(f.friend_user_id))
})
function toggleSelectMember(userId: string) {
const idx = selectedNewMembers.value.indexOf(userId)
if (idx === -1) {
selectedNewMembers.value.push(userId)
} else {
selectedNewMembers.value.splice(idx, 1)
}
}
async function addMembers() {
if (selectedNewMembers.value.length === 0) return
addingMembers.value = true
try {
await chatApi.addMembers(props.conversationId, selectedNewMembers.value)
message.success('成员已添加')
showAddMember.value = false
emit('updated')
} catch (e: any) {
message.error(e.response?.data?.detail || '添加失败')
} finally {
addingMembers.value = false
}
}
function triggerAvatarUpload() {
avatarInput.value?.click()
}
async function handleAvatarUpload(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
try {
const formData = new FormData()
formData.append('file', file)
const { data } = await api.post('/uploads/file', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
await chatApi.updateGroup(props.conversationId, { avatar_url: data.url })
message.success('群头像已更新')
emit('updated')
} catch (e: any) {
message.error(e.response?.data?.detail || '上传失败')
}
target.value = ''
}
function roleLabel(role: string) { function roleLabel(role: string) {
if (role === 'owner') return '群主' if (role === 'owner') return '群主'
if (role === 'admin') return '管理员' if (role === 'admin') return '管理员'
@@ -115,6 +219,18 @@ async function leaveGroup() {
message.error(e.response?.data?.detail || '退出失败') message.error(e.response?.data?.detail || '退出失败')
} }
} }
async function dissolveGroup() {
if (!confirm('确定要解散该群聊吗?此操作不可恢复。')) return
try {
await chatApi.dissolveGroup(props.conversationId)
message.success('群聊已解散')
emit('close')
router.push('/chat')
} catch (e: any) {
message.error(e.response?.data?.detail || '解散失败')
}
}
</script> </script>
<style scoped> <style scoped>
@@ -133,6 +249,13 @@ async function leaveGroup() {
.panel-body { flex: 1; overflow-y: auto; padding: 16px; } .panel-body { flex: 1; overflow-y: auto; padding: 16px; }
.group-header { text-align: center; margin-bottom: 20px; } .group-header { text-align: center; margin-bottom: 20px; }
.group-meta { margin-top: 8px; } .group-meta { margin-top: 8px; }
.avatar-upload-wrap { position: relative; display: inline-block; }
.avatar-upload-wrap.clickable { cursor: pointer; }
.avatar-overlay {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.3); border-radius: 50%; opacity: 0; transition: opacity 0.2s; font-size: 20px;
}
.avatar-upload-wrap.clickable:hover .avatar-overlay { opacity: 1; }
.group-name { font-size: 18px; font-weight: 600; display: block; } .group-name { font-size: 18px; font-weight: 600; display: block; }
.group-desc { font-size: 13px; color: var(--color-text-secondary); display: block; margin-top: 4px; } .group-desc { font-size: 13px; color: var(--color-text-secondary); display: block; margin-top: 4px; }
.member-count { font-size: 12px; color: var(--color-text-hint); display: block; margin-top: 2px; } .member-count { font-size: 12px; color: var(--color-text-hint); display: block; margin-top: 2px; }
@@ -153,4 +276,31 @@ async function leaveGroup() {
.member-role.owner { background: #FFF3E0; color: #F57C00; } .member-role.owner { background: #FFF3E0; color: #F57C00; }
.member-role.admin { background: #E3F2FD; color: #1976D2; } .member-role.admin { background: #E3F2FD; color: #1976D2; }
.actions { padding-top: 12px; border-top: 1px solid var(--color-border); } .actions { padding-top: 12px; border-top: 1px solid var(--color-border); }
/* Add member modal */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.add-member-modal {
width: 400px; max-height: 500px; background: var(--color-surface); border-radius: 16px;
box-shadow: 0 12px 40px rgba(0,0,0,0.15); display: flex; flex-direction: column;
}
.add-member-modal .modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 20px 24px 16px; border-bottom: 1px solid var(--color-border);
}
.add-member-modal .modal-header h3 { margin: 0; font-size: 18px; }
.modal-body { flex: 1; overflow-y: auto; padding: 12px 16px; }
.modal-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 12px 24px; border-top: 1px solid var(--color-border);
}
.empty-friends { text-align: center; padding: 30px; color: var(--color-text-hint); font-size: 13px; }
.friend-select-item {
display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px;
cursor: pointer; transition: background 0.15s;
}
.friend-select-item:hover { background: var(--color-primary-lightest); }
.friend-select-name { flex: 1; font-size: 14px; }
</style> </style>
+61 -8
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="moment-card"> <div class="moment-card">
<!-- 头部头像 + 用户名 + 时间 --> <!-- 头部头像 + 用户名 + 时间 + 删除 -->
<div class="moment-header"> <div class="moment-header">
<n-avatar :size="40" round :style="{ background: 'var(--color-primary)' }"> <n-avatar :size="40" round :style="{ background: 'var(--color-primary)' }">
{{ (moment.nickname || moment.username || '?')[0] }} {{ (moment.nickname || moment.username || '?')[0] }}
@@ -9,17 +9,21 @@
<span class="author-name">{{ moment.nickname || moment.username }}</span> <span class="author-name">{{ moment.nickname || moment.username }}</span>
<span class="post-time">{{ formatTime(moment.created_at) }}</span> <span class="post-time">{{ formatTime(moment.created_at) }}</span>
</div> </div>
<span v-if="isMine" class="delete-btn" title="删除" @click="handleDelete">🗑</span>
</div> </div>
<!-- 内容 --> <!-- 内容 -->
<div class="moment-content">{{ moment.content }}</div> <div class="moment-content">{{ moment.content }}</div>
<!-- 图片 --> <!-- 图片 -->
<div v-if="moment.images && moment.images.length > 0" class="moment-images" :class="`grid-${Math.min(moment.images.length, 3)}`"> <n-image-group v-if="moment.images && moment.images.length > 0">
<div class="moment-images" :class="`grid-${Math.min(moment.images.length, 3)}`">
<div v-for="(img, i) in moment.images" :key="i" class="image-item"> <div v-for="(img, i) in moment.images" :key="i" class="image-item">
<img :src="img" :alt="`图片${i + 1}`" /> <n-image :src="img" :alt="`图片${i + 1}`" object-fit="cover"
:img-props="{ style: 'width:100%;height:100%;object-fit:cover' }" />
</div> </div>
</div> </div>
</n-image-group>
<!-- 操作栏 --> <!-- 操作栏 -->
<div class="moment-actions"> <div class="moment-actions">
@@ -35,25 +39,35 @@
<div v-if="showComments" class="comments-section"> <div v-if="showComments" class="comments-section">
<div v-if="comments.length > 0" class="comments-list"> <div v-if="comments.length > 0" class="comments-list">
<div v-for="comment in comments" :key="comment.id" class="comment-item"> <div v-for="comment in comments" :key="comment.id" class="comment-item">
<div class="comment-content-row">
<span class="comment-author">{{ comment.nickname || comment.username }}</span> <span class="comment-author">{{ comment.nickname || comment.username }}</span>
<span v-if="comment.reply_to_username" class="comment-reply"> <span v-if="comment.reply_to_username" class="comment-reply">
回复 <span class="comment-author">{{ comment.reply_to_username }}</span> 回复 <span class="comment-author">{{ comment.reply_to_username }}</span>
</span> </span>
<span class="comment-text">{{ comment.content }}</span> <span class="comment-text">{{ comment.content }}</span>
<span class="comment-reply-btn" @click="setReplyTarget(comment)">回复</span>
</div> </div>
</div> </div>
</div>
<div class="comment-input-wrapper">
<div v-if="replyTarget" class="reply-indicator">
<span>回复 @{{ replyTarget.nickname || replyTarget.username }}</span>
<span class="reply-cancel" @click="replyTarget = null"></span>
</div>
<div class="comment-input"> <div class="comment-input">
<n-input v-model:value="commentText" placeholder="写评论..." size="small" <n-input v-model:value="commentText" :placeholder="replyTarget ? `回复 @${replyTarget.nickname || replyTarget.username}...` : '写评论...'" size="small"
@keydown.enter.prevent="submitComment" /> @keydown.enter.prevent="submitComment" />
<n-button size="tiny" type="primary" :disabled="!commentText.trim()" @click="submitComment">发送</n-button> <n-button size="tiny" type="primary" :disabled="!commentText.trim()" @click="submitComment">发送</n-button>
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { momentsApi } from '@/api/moments' import { momentsApi } from '@/api/moments'
import { useAuthStore } from '@/stores/auth'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn' import 'dayjs/locale/zh-cn'
@@ -65,11 +79,16 @@ const props = defineProps<{ moment: any }>()
const emit = defineEmits<{ const emit = defineEmits<{
'toggle-like': [momentId: string] 'toggle-like': [momentId: string]
'comment': [momentId: string, content: string] 'comment': [momentId: string, content: string]
'delete': [momentId: string]
}>() }>()
const auth = useAuthStore()
const showComments = ref(false) const showComments = ref(false)
const comments = ref<any[]>([]) const comments = ref<any[]>([])
const commentText = ref('') const commentText = ref('')
const replyTarget = ref<any>(null)
const isMine = computed(() => props.moment.user_id === auth.user?.id)
watch(showComments, async (val) => { watch(showComments, async (val) => {
if (val && comments.value.length === 0) { if (val && comments.value.length === 0) {
@@ -80,13 +99,26 @@ watch(showComments, async (val) => {
} }
}) })
function setReplyTarget(comment: any) {
replyTarget.value = comment
}
async function handleDelete() {
if (!confirm('确定要删除这条动态吗?')) return
try {
emit('delete', props.moment.id)
} catch {}
}
async function submitComment() { async function submitComment() {
if (!commentText.value.trim()) return if (!commentText.value.trim()) return
try { try {
const { data } = await momentsApi.addComment(props.moment.id, commentText.value.trim()) const replyToId = replyTarget.value?.id || undefined
const { data } = await momentsApi.addComment(props.moment.id, commentText.value.trim(), replyToId)
comments.value.push(data) comments.value.push(data)
emit('comment', props.moment.id, commentText.value.trim()) emit('comment', props.moment.id, commentText.value.trim())
commentText.value = '' commentText.value = ''
replyTarget.value = null
} catch {} } catch {}
} }
@@ -110,6 +142,12 @@ function formatTime(time: string) {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
} }
.moment-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } .moment-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.delete-btn {
margin-left: auto; font-size: 14px; cursor: pointer;
opacity: 0; transition: opacity 0.2s; padding: 4px;
}
.moment-card:hover .delete-btn { opacity: 0.6; }
.delete-btn:hover { opacity: 1 !important; }
.header-info { display: flex; flex-direction: column; } .header-info { display: flex; flex-direction: column; }
.author-name { font-weight: 600; font-size: 14px; } .author-name { font-weight: 600; font-size: 14px; }
.post-time { font-size: 11px; color: var(--color-text-hint); } .post-time { font-size: 11px; color: var(--color-text-hint); }
@@ -124,7 +162,7 @@ function formatTime(time: string) {
.moment-images.grid-2 { grid-template-columns: 1fr 1fr; } .moment-images.grid-2 { grid-template-columns: 1fr 1fr; }
.moment-images.grid-3 { grid-template-columns: 1fr 1fr 1fr; } .moment-images.grid-3 { grid-template-columns: 1fr 1fr 1fr; }
.image-item { aspect-ratio: 1; overflow: hidden; border-radius: 6px; cursor: pointer; } .image-item { aspect-ratio: 1; overflow: hidden; border-radius: 6px; cursor: pointer; }
.image-item img { width: 100%; height: 100%; object-fit: cover; } .image-item :deep(img) { width: 100%; height: 100%; object-fit: cover; }
.moment-actions { .moment-actions {
display: flex; gap: 16px; padding-top: 8px; display: flex; gap: 16px; padding-top: 8px;
@@ -143,9 +181,24 @@ function formatTime(time: string) {
font-size: 13px; padding: 4px 0; line-height: 1.5; font-size: 13px; padding: 4px 0; line-height: 1.5;
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.comment-content-row { display: inline; }
.comment-author { color: var(--color-primary); font-weight: 500; cursor: pointer; } .comment-author { color: var(--color-primary); font-weight: 500; cursor: pointer; }
.comment-reply { color: var(--color-text-hint); } .comment-reply { color: var(--color-text-hint); }
.comment-text { color: var(--color-text-primary); } .comment-text { color: var(--color-text-primary); }
.comment-reply-btn {
font-size: 11px; color: var(--color-text-hint); cursor: pointer;
margin-left: 6px; opacity: 0; transition: opacity 0.2s;
}
.comment-item:hover .comment-reply-btn { opacity: 1; }
.comment-reply-btn:hover { color: var(--color-primary); }
.comment-input { display: flex; gap: 6px; margin-top: 6px; } .comment-input-wrapper { margin-top: 6px; }
.reply-indicator {
display: flex; align-items: center; justify-content: space-between;
padding: 4px 8px; margin-bottom: 4px; background: var(--color-primary-lightest);
border-radius: 6px; font-size: 12px; color: var(--color-primary);
}
.reply-cancel { cursor: pointer; font-size: 12px; padding: 2px; }
.reply-cancel:hover { color: var(--color-text-primary); }
.comment-input { display: flex; gap: 6px; }
</style> </style>
@@ -30,6 +30,7 @@
:moment="moment" :moment="moment"
@toggle-like="handleToggleLike" @toggle-like="handleToggleLike"
@comment="handleComment" @comment="handleComment"
@delete="handleDelete"
/> />
</div> </div>
@@ -156,6 +157,15 @@ async function handleComment(momentId: string, content: string) {
message.error('评论失败') message.error('评论失败')
} }
} }
async function handleDelete(momentId: string) {
try {
await momentsStore.deleteMoment(momentId)
message.success('已删除')
} catch {
message.error('删除失败')
}
}
</script> </script>
<style scoped> <style scoped>
@@ -0,0 +1,67 @@
<template>
<div class="moments-sidebar">
<div class="panel-header">
<h3 class="panel-title">朋友圈</h3>
</div>
<div class="sidebar-content">
<div class="sidebar-intro">
<div class="intro-icon">🌿</div>
<p class="intro-text">分享你的生活点滴</p>
</div>
<div class="sidebar-stats">
<div class="stat-item">
<span class="stat-value">{{ momentsStore.feed.length }}</span>
<span class="stat-label">动态</span>
</div>
</div>
<div class="sidebar-tips">
<div class="tip-item" @click="showCompose = true">
<span class="tip-icon"></span>
<span class="tip-text">发布新动态</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useMomentsStore } from '@/stores/moments'
const momentsStore = useMomentsStore()
const showCompose = ref(false)
</script>
<style scoped>
.moments-sidebar {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-header {
padding: 16px;
border-bottom: 1px solid var(--color-border);
}
.panel-title { margin: 0; font-size: 16px; font-weight: 600; }
.sidebar-content { flex: 1; padding: 16px; }
.sidebar-intro {
text-align: center; padding: 30px 16px; margin-bottom: 16px;
background: var(--color-primary-lightest); border-radius: 12px;
}
.intro-icon { font-size: 36px; margin-bottom: 8px; }
.intro-text { font-size: 14px; color: var(--color-text-secondary); margin: 0; }
.sidebar-stats {
display: flex; justify-content: center; gap: 24px; margin-bottom: 20px;
}
.stat-item { text-align: center; }
.stat-value { display: block; font-size: 20px; font-weight: 600; color: var(--color-primary); }
.stat-label { font-size: 12px; color: var(--color-text-hint); }
.sidebar-tips { margin-top: 16px; }
.tip-item {
display: flex; align-items: center; gap: 8px; padding: 10px 12px;
border-radius: 8px; cursor: pointer; transition: background 0.15s;
}
.tip-item:hover { background: var(--color-primary-lightest); }
.tip-icon { font-size: 16px; }
.tip-text { font-size: 14px; color: var(--color-text-primary); }
</style>