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
+4 -1
View File
@@ -11,7 +11,7 @@ export const chatApi = {
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),
addMembers: (convId: string, userIds: string[]) =>
@@ -23,6 +23,9 @@ export const chatApi = {
leaveGroup: (convId: string) =>
api.post(`/conversations/${convId}/leave`),
dissolveGroup: (convId: string) =>
api.post(`/conversations/${convId}/dissolve`),
getMessages: (conversationId: string, before?: string, limit = 50) => {
const params: Record<string, any> = { limit }
if (before) params.before = before
+28 -1
View File
@@ -11,6 +11,7 @@ const maxReconnectAttempts = 10
// 共享状态
const connected = ref(false)
const onlineUsers = ref(new Map<string, boolean>())
const typingUsers = ref(new Map<string, { username: string; timeout: ReturnType<typeof setTimeout> }>())
export function useWebSocket() {
@@ -80,11 +81,37 @@ export function useWebSocket() {
onlineUsers.value.set(event.data.user_id, false)
console.log(`用户 ${event.data.user_id} 下线`)
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':
console.log('收到好友请求:', event.data)
// 通过自定义事件通知全局
window.dispatchEvent(new CustomEvent('qingye:friend-request', { detail: event.data }))
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':
console.error('服务端错误:', event.data.message)
break
@@ -95,5 +122,5 @@ export function useWebSocket() {
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">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
import { useUiStore } from '@/stores/ui'
@@ -59,6 +60,7 @@ const route = useRoute()
const auth = useAuthStore()
const chatStore = useChatStore()
const uiStore = useUiStore()
const naiveMsg = useMessage()
const { connect, connected: wsConnected } = useWebSocket()
const isMobile = computed(() => uiStore.isMobile)
@@ -68,8 +70,29 @@ const hideSecondary = computed(() => {
const pendingRequestCount = ref(0)
function onFriendRequest() {
function onFriendRequest(e: Event) {
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(() => {
@@ -89,6 +112,9 @@ const totalUnread = computed(() =>
onMounted(async () => {
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 {
await auth.fetchProfile()
} catch {
@@ -101,6 +127,9 @@ onMounted(async () => {
onUnmounted(() => {
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() {
+1 -1
View File
@@ -71,7 +71,7 @@ const routes: RouteRecordRaw[] = [
path: 'moments',
name: 'Moments',
components: {
secondary: () => import('@/views/moments/MomentsFeedView.vue'),
secondary: () => import('@/views/moments/MomentsSidebar.vue'),
default: () => import('@/views/moments/MomentsFeedView.vue'),
},
},
+123 -4
View File
@@ -8,7 +8,8 @@
</div>
<div class="header-info">
<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>
</div>
<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>
</div>
<div v-for="(msg, idx) in allDisplayMessages" :key="msg.id || 'pending-' + idx"
:data-msg-id="msg.id"
class="message-row" :class="{
own: msg.sender_id === auth.user?.id, other: msg.sender_id !== auth.user?.id,
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 class="bubble" :class="{ 'bubble-self': msg.sender_id === auth.user?.id, 'bubble-other': msg.sender_id !== auth.user?.id }"
@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>
</div>
<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 class="ctx-item" @click="replyToMsg"> 回复</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>
@@ -75,6 +84,14 @@
<!-- 输入框 -->
<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">
<span class="action-icon" @click="showEmoji = !showEmoji">😊</span>
<span class="action-icon" @click="triggerImageUpload">🖼</span>
@@ -108,7 +125,7 @@ const route = useRoute()
const chatStore = useChatStore()
const auth = useAuthStore()
const uiStore = useUiStore()
const { send, connected } = useWebSocket()
const { send, connected, typingUsers } = useWebSocket()
const inputText = ref('')
const messageListRef = ref<HTMLElement>()
@@ -118,8 +135,31 @@ const convDetail = ref<any>(null)
const showGroupInfo = ref(false)
const showEmoji = ref(false)
const isOtherOnline = ref(false)
const replyingTo = ref<any>(null)
const lastTypingSent = ref(0)
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)
const allDisplayMessages = computed(() => chatStore.allMessages())
@@ -199,6 +239,17 @@ watch(() => chatStore.pendingMessages.length, () => {
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() {
if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
@@ -212,6 +263,8 @@ function sendMessage() {
inputText.value = ''
showEmoji.value = false
const replyToId = replyingTo.value?.id || null
// Add optimistic pending message
const tempId = 'temp-' + Date.now()
chatStore.addPendingMessage({
@@ -221,11 +274,44 @@ function sendMessage() {
sender_name: auth.user?.nickname || auth.user?.username,
content: 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(),
})
// 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) {
@@ -308,6 +394,8 @@ function formatTimeDivider(time: string) {
.member-count { font-size: 12px; color: var(--color-text-hint); }
.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; }
.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; }
.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; }
.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 */
.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; }
+154 -4
View File
@@ -7,9 +7,14 @@
<div v-if="detail" class="panel-body">
<!-- 群头像和名称 -->
<div class="group-header">
<n-avatar :size="64" round :style="{ background: 'var(--color-primary)', fontSize: '24px' }">
{{ (detail.name || '群')[0] }}
</n-avatar>
<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] }}
</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">
<span class="group-name">{{ detail.name || '未命名群聊' }}</span>
<span class="group-desc">{{ detail.description || '暂无简介' }}</span>
@@ -48,18 +53,51 @@
<!-- 操作按钮 -->
<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>
</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>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
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 emit = defineEmits<{ close: []; updated: [] }>()
@@ -68,6 +106,10 @@ const auth = useAuthStore()
const message = useMessage()
const editName = ref('')
const showAddMember = ref(false)
const friends = ref<any[]>([])
const selectedNewMembers = ref<string[]>([])
const addingMembers = ref(false)
const avatarInput = ref<HTMLInputElement>()
const myRole = computed(() => {
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 || ''
}, { 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) {
if (role === 'owner') return '群主'
if (role === 'admin') return '管理员'
@@ -115,6 +219,18 @@ async function leaveGroup() {
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>
<style scoped>
@@ -133,6 +249,13 @@ async function leaveGroup() {
.panel-body { flex: 1; overflow-y: auto; padding: 16px; }
.group-header { text-align: center; margin-bottom: 20px; }
.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-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; }
@@ -153,4 +276,31 @@ async function leaveGroup() {
.member-role.owner { background: #FFF3E0; color: #F57C00; }
.member-role.admin { background: #E3F2FD; color: #1976D2; }
.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>
+71 -18
View File
@@ -1,6 +1,6 @@
<template>
<div class="moment-card">
<!-- 头部头像 + 用户名 + 时间 -->
<!-- 头部头像 + 用户名 + 时间 + 删除 -->
<div class="moment-header">
<n-avatar :size="40" round :style="{ background: 'var(--color-primary)' }">
{{ (moment.nickname || moment.username || '?')[0] }}
@@ -9,17 +9,21 @@
<span class="author-name">{{ moment.nickname || moment.username }}</span>
<span class="post-time">{{ formatTime(moment.created_at) }}</span>
</div>
<span v-if="isMine" class="delete-btn" title="删除" @click="handleDelete">🗑</span>
</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)}`">
<div v-for="(img, i) in moment.images" :key="i" class="image-item">
<img :src="img" :alt="`图片${i + 1}`" />
<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">
<n-image :src="img" :alt="`图片${i + 1}`" object-fit="cover"
:img-props="{ style: 'width:100%;height:100%;object-fit:cover' }" />
</div>
</div>
</div>
</n-image-group>
<!-- 操作栏 -->
<div class="moment-actions">
@@ -35,25 +39,35 @@
<div v-if="showComments" class="comments-section">
<div v-if="comments.length > 0" class="comments-list">
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<span class="comment-author">{{ comment.nickname || comment.username }}</span>
<span v-if="comment.reply_to_username" class="comment-reply">
回复 <span class="comment-author">{{ comment.reply_to_username }}</span>
</span>
<span class="comment-text">{{ comment.content }}</span>
<div class="comment-content-row">
<span class="comment-author">{{ comment.nickname || comment.username }}</span>
<span v-if="comment.reply_to_username" class="comment-reply">
回复 <span class="comment-author">{{ comment.reply_to_username }}</span>
</span>
<span class="comment-text">{{ comment.content }}</span>
<span class="comment-reply-btn" @click="setReplyTarget(comment)">回复</span>
</div>
</div>
</div>
<div class="comment-input">
<n-input v-model:value="commentText" placeholder="写评论..." size="small"
@keydown.enter.prevent="submitComment" />
<n-button size="tiny" type="primary" :disabled="!commentText.trim()" @click="submitComment">发送</n-button>
<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">
<n-input v-model:value="commentText" :placeholder="replyTarget ? `回复 @${replyTarget.nickname || replyTarget.username}...` : '写评论...'" size="small"
@keydown.enter.prevent="submitComment" />
<n-button size="tiny" type="primary" :disabled="!commentText.trim()" @click="submitComment">发送</n-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import { momentsApi } from '@/api/moments'
import { useAuthStore } from '@/stores/auth'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
@@ -65,11 +79,16 @@ const props = defineProps<{ moment: any }>()
const emit = defineEmits<{
'toggle-like': [momentId: string]
'comment': [momentId: string, content: string]
'delete': [momentId: string]
}>()
const auth = useAuthStore()
const showComments = ref(false)
const comments = ref<any[]>([])
const commentText = ref('')
const replyTarget = ref<any>(null)
const isMine = computed(() => props.moment.user_id === auth.user?.id)
watch(showComments, async (val) => {
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() {
if (!commentText.value.trim()) return
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)
emit('comment', props.moment.id, commentText.value.trim())
commentText.value = ''
replyTarget.value = null
} catch {}
}
@@ -110,6 +142,12 @@ function formatTime(time: string) {
border: 1px solid var(--color-border);
}
.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; }
.author-name { font-weight: 600; font-size: 14px; }
.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-3 { grid-template-columns: 1fr 1fr 1fr; }
.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 {
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;
color: var(--color-text-primary);
}
.comment-content-row { display: inline; }
.comment-author { color: var(--color-primary); font-weight: 500; cursor: pointer; }
.comment-reply { color: var(--color-text-hint); }
.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>
@@ -30,6 +30,7 @@
:moment="moment"
@toggle-like="handleToggleLike"
@comment="handleComment"
@delete="handleDelete"
/>
</div>
@@ -156,6 +157,15 @@ async function handleComment(momentId: string, content: string) {
message.error('评论失败')
}
}
async function handleDelete(momentId: string) {
try {
await momentsStore.deleteMoment(momentId)
message.success('已删除')
} catch {
message.error('删除失败')
}
}
</script>
<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>