This commit is contained in:
2026-06-13 11:02:47 +08:00
parent 318ddd85a5
commit 68678304ff
15 changed files with 659 additions and 78 deletions
+25
View File
@@ -7,6 +7,7 @@ from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.schemas.message import MessageSend, MessagePage, MarkReadRequest
from app.services.message_service import MessageService
from app.services.conversation_service import ConversationService
router = APIRouter()
@@ -27,6 +28,30 @@ async def get_messages(
raise HTTPException(status_code=403, detail=str(e))
@router.get("/{conversation_id}/messages/search")
async def search_messages(
conversation_id: str,
q: str = Query(..., min_length=1, max_length=200),
limit: int = Query(20, ge=1, le=50),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""在会话内搜索消息(用户级)"""
# 验证成员身份
conv_service = ConversationService(db)
detail = await conv_service.get_conversation_detail(conversation_id, user.id)
if not detail:
raise HTTPException(status_code=403, detail="无权访问该会话")
service = MessageService(db)
results = await service.search_messages(
conversation_id=conversation_id,
keyword=q,
limit=limit,
)
return {"results": results}
@router.put("/{conversation_id}/messages/{message_id}/read")
async def mark_as_read(
conversation_id: str,
+11 -3
View File
@@ -247,10 +247,18 @@ class ConversationService:
ConversationMember.left_at.is_(None),
)
)
member_rows = members_result.scalars().all()
# 批量获取所有成员用户信息
member_user_ids = [m.user_id for m in member_rows]
users_result = await self.db.execute(
select(User).where(User.id.in_(member_user_ids))
)
users_map = {u.id: u for u in users_result.scalars().all()}
members = []
for m in members_result.scalars().all():
user_result = await self.db.execute(select(User).where(User.id == m.user_id))
user = user_result.scalars().first()
for m in member_rows:
user = users_map.get(m.user_id)
if user:
members.append({
"id": m.id,
+26 -8
View File
@@ -102,12 +102,20 @@ class FriendService:
result = await self.db.execute(
select(Friend).where(Friend.user_id == user_id)
)
friendships = result.scalars().all()
if not friendships:
return []
# 批量获取所有好友用户
friend_user_ids = [f.friend_user_id for f in friendships]
users_result = await self.db.execute(
select(User).where(User.id.in_(friend_user_ids))
)
users_map = {u.id: u for u in users_result.scalars().all()}
friends = []
for friendship in result.scalars().all():
user_result = await self.db.execute(
select(User).where(User.id == friendship.friend_user_id)
)
user = user_result.scalars().first()
for friendship in friendships:
user = users_map.get(friendship.friend_user_id)
if user:
friends.append({
"id": friendship.id,
@@ -128,10 +136,20 @@ class FriendService:
FriendRequest.status == "pending",
).order_by(FriendRequest.created_at.desc())
)
requests_list = result.scalars().all()
if not requests_list:
return []
# 批量获取所有发送者
from_user_ids = list(set(r.from_user_id for r in requests_list))
users_result = await self.db.execute(
select(User).where(User.id.in_(from_user_ids))
)
users_map = {u.id: u for u in users_result.scalars().all()}
requests = []
for req in result.scalars().all():
from_user = await self.db.execute(select(User).where(User.id == req.from_user_id))
fu = from_user.scalars().first()
for req in requests_list:
fu = users_map.get(req.from_user_id)
requests.append({
"id": req.id,
"from_user_id": req.from_user_id,
+26 -13
View File
@@ -77,29 +77,42 @@ class MessageService:
has_more = len(messages) > limit
messages = messages[:limit]
# 获取发送者信息
# 批量预加载发送者信息
from app.models.user import User
sender_ids = list(set(m.sender_id for m in messages))
senders_result = await self.db.execute(
select(User).where(User.id.in_(sender_ids))
)
senders_map = {u.id: u for u in senders_result.scalars().all()}
# 批量预加载被引用消息
reply_to_ids = list(set(m.reply_to_id for m in messages if m.reply_to_id))
reply_msgs_map: dict[str, Message] = {}
if reply_to_ids:
reply_result = await self.db.execute(
select(Message).where(Message.id.in_(reply_to_ids))
)
reply_msgs_map = {m.id: m for m in reply_result.scalars().all()}
# 批量预加载被引用消息的发送者
reply_sender_ids = list(set(rm.sender_id for rm in reply_msgs_map.values()))
reply_senders_result = await self.db.execute(
select(User).where(User.id.in_(reply_sender_ids))
)
reply_senders_map = {u.id: u for u in reply_senders_result.scalars().all()}
message_list = []
for msg in reversed(messages):
sender_result = await self.db.execute(
select(User).where(User.id == msg.sender_id)
)
sender = sender_result.scalars().first()
sender = senders_map.get(msg.sender_id)
# 获取被引用消息的信息
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()
reply_msg = reply_msgs_map.get(msg.reply_to_id)
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_sender = reply_senders_map.get(reply_msg.sender_id)
reply_to_sender_name = reply_sender.username if reply_sender else None
message_list.append({
+57 -43
View File
@@ -82,7 +82,7 @@ class MomentService:
unique.sort(key=lambda x: x.created_at, reverse=True)
unique = unique[:limit]
return [await self._moment_to_dict(m, user_id) for m in unique]
return await self._moments_to_dicts(unique, user_id)
async def get_user_moments(self, user_id: str, viewer_id: str | None = None,
cursor: str | None = None, limit: int = 20) -> list[dict]:
@@ -116,7 +116,7 @@ class MomentService:
if viewer_id == user_id:
filtered.append(m)
return [await self._moment_to_dict(m, viewer_id) for m in filtered]
return await self._moments_to_dicts(filtered, viewer_id)
async def delete_moment(self, moment_id: str, user_id: str):
"""删除动态(仅作者)"""
@@ -243,56 +243,70 @@ class MomentService:
raise ValueError("只能删除自己的评论")
await self.db.delete(comment)
async def _moment_to_dict(self, moment: Moment, viewer_id: str | None) -> dict:
"""将 Moment ORM 对象转为前端需要的字典"""
user_result = await self.db.execute(select(User).where(User.id == moment.user_id))
user = user_result.scalars().first()
async def _moments_to_dicts(self, moments: list[Moment], viewer_id: str | None) -> list[dict]:
"""批量将 Moment ORM 对象转为前端需要的字典(优化 N+1 查询)"""
if not moments:
return []
# 点赞数
like_count_result = await self.db.execute(
select(func.count(MomentLike.id)).where(MomentLike.moment_id == moment.id)
moment_ids = [m.id for m in moments]
user_ids = list(set(m.user_id for m in moments))
# 批量获取所有作者
users_result = await self.db.execute(select(User).where(User.id.in_(user_ids)))
users_map = {u.id: u for u in users_result.scalars().all()}
# 批量获取点赞数
like_counts_result = await self.db.execute(
select(MomentLike.moment_id, func.count(MomentLike.id))
.where(MomentLike.moment_id.in_(moment_ids))
.group_by(MomentLike.moment_id)
)
like_count = like_count_result.scalar() or 0
like_counts_map = dict(like_counts_result.all())
# 是否已点赞
is_liked = False
# 批量获取评论数
comment_counts_result = await self.db.execute(
select(MomentComment.moment_id, func.count(MomentComment.id))
.where(MomentComment.moment_id.in_(moment_ids))
.group_by(MomentComment.moment_id)
)
comment_counts_map = dict(comment_counts_result.all())
# 批量获取当前用户的点赞状态
liked_moment_ids = set()
if viewer_id:
like_result = await self.db.execute(
select(MomentLike).where(
MomentLike.moment_id == moment.id,
liked_result = await self.db.execute(
select(MomentLike.moment_id).where(
MomentLike.moment_id.in_(moment_ids),
MomentLike.user_id == viewer_id,
)
)
is_liked = like_result.scalars().first() is not None
liked_moment_ids = {r[0] for r in liked_result.all()}
# 评论数
comment_count_result = await self.db.execute(
select(func.count(MomentComment.id)).where(MomentComment.moment_id == moment.id)
)
comment_count = comment_count_result.scalar() or 0
result = []
for moment in moments:
user = users_map.get(moment.user_id)
images = []
if moment.images:
try:
images = json.loads(moment.images)
except Exception:
pass
# 解析图片
images = []
if moment.images:
try:
images = json.loads(moment.images)
except:
pass
return {
"id": moment.id,
"user_id": moment.user_id,
"username": user.username if user else "未知",
"nickname": user.nickname if user else None,
"avatar_url": user.avatar_url if user else None,
"content": moment.content,
"images": images,
"visibility": moment.visibility,
"like_count": like_count,
"is_liked": is_liked,
"comment_count": comment_count,
"created_at": moment.created_at,
}
result.append({
"id": moment.id,
"user_id": moment.user_id,
"username": user.username if user else "未知",
"nickname": user.nickname if user else None,
"avatar_url": user.avatar_url if user else None,
"content": moment.content,
"images": images,
"visibility": moment.visibility,
"like_count": like_counts_map.get(moment.id, 0),
"is_liked": moment.id in liked_moment_ids,
"comment_count": comment_counts_map.get(moment.id, 0),
"created_at": moment.created_at,
})
return result
async def _get_friend_ids(self, user_id: str) -> list[str]:
"""获取好友ID列表"""
+6 -2
View File
@@ -1,5 +1,5 @@
<template>
<n-config-provider :theme-overrides="themeOverrides">
<n-config-provider :theme="uiStore.isDark ? darkTheme : undefined" :theme-overrides="themeOverrides">
<n-notification-provider>
<n-message-provider>
<n-dialog-provider>
@@ -11,7 +11,11 @@
</template>
<script setup lang="ts">
import { darkTheme } from 'naive-ui'
import type { GlobalThemeOverrides } from 'naive-ui'
import { useUiStore } from '@/stores/ui'
const uiStore = useUiStore()
/** 青绿色主题配置 */
const themeOverrides: GlobalThemeOverrides = {
@@ -65,6 +69,6 @@ const themeOverrides: GlobalThemeOverrides = {
<style>
body {
margin: 0;
background-color: #F5FBF9;
background-color: var(--color-bg);
}
</style>
+3
View File
@@ -37,4 +37,7 @@ export const chatApi = {
deleteMessage: (conversationId: string, messageId: string) =>
api.delete(`/conversations/${conversationId}/messages/${messageId}`),
searchMessages: (conversationId: string, keyword: string) =>
api.get(`/conversations/${conversationId}/messages/search`, { params: { q: keyword } }),
}
+30
View File
@@ -35,6 +35,36 @@
--color-bubble-other-text: #1A2E2A;
}
/* 深色模式 */
[data-theme="dark"] {
--color-primary: #26A69A;
--color-primary-light: #4DB6AC;
--color-primary-lighter: #80CBC4;
--color-primary-lightest: #1A3B38;
--color-primary-dark: #009688;
--color-primary-darker: #00695C;
--color-bg: #0D1514;
--color-surface: #162220;
--color-surface-elevated: #1C2B28;
--color-text-primary: #E0F2F1;
--color-text-secondary: #9DB5AE;
--color-text-hint: #607D76;
--color-border: #2D4340;
--color-success: #66BB6A;
--color-warning: #FFA726;
--color-error: #EF5350;
--color-unread: #FF6B6B;
--color-bubble-self: #00796B;
--color-bubble-self-text: #E0F2F1;
--color-bubble-other: #1C2B28;
--color-bubble-other-text: #E0F2F1;
}
* {
box-sizing: border-box;
}
+27 -5
View File
@@ -69,11 +69,25 @@ const routes: RouteRecordRaw[] = [
// 朋友圈
{
path: 'moments',
name: 'Moments',
components: {
secondary: () => import('@/views/moments/MomentsSidebar.vue'),
default: () => import('@/views/moments/MomentsFeedView.vue'),
},
children: [
{
path: '',
name: 'Moments',
components: {
secondary: () => import('@/views/moments/MomentsSidebar.vue'),
default: () => import('@/views/moments/MomentsFeedView.vue'),
},
},
{
path: 'user/:userId',
name: 'UserMoments',
meta: { hideSecondary: true },
components: {
secondary: () => import('@/views/moments/MomentsSidebar.vue'),
default: () => import('@/views/moments/UserMomentsView.vue'),
},
},
],
},
// 设置
@@ -108,6 +122,14 @@ const routes: RouteRecordRaw[] = [
default: () => import('@/views/settings/NotificationSettingsView.vue'),
},
},
{
path: 'appearance',
name: 'SettingsAppearance',
components: {
secondary: () => import('@/views/settings/SettingsSidebar.vue'),
default: () => import('@/views/settings/AppearanceSettingsView.vue'),
},
},
{
path: 'about',
name: 'SettingsAbout',
+20 -2
View File
@@ -9,6 +9,12 @@ export const useUiStore = defineStore('ui', () => {
const chatStyle = ref<ChatStyle>((localStorage.getItem('chatStyle') as ChatStyle) || 'bubble')
const sidebarCollapsed = ref(false)
const isMobile = ref(window.innerWidth < 768)
const isDark = ref(localStorage.getItem('theme') === 'dark')
// Apply theme on init
if (isDark.value) {
document.documentElement.setAttribute('data-theme', 'dark')
}
function setLayoutMode(mode: LayoutMode) {
layoutMode.value = mode
@@ -24,13 +30,25 @@ export const useUiStore = defineStore('ui', () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
function toggleDark() {
isDark.value = !isDark.value
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
}
function setDark(val: boolean) {
isDark.value = val
localStorage.setItem('theme', val ? 'dark' : 'light')
document.documentElement.setAttribute('data-theme', val ? 'dark' : 'light')
}
// 监听窗口大小
window.addEventListener('resize', () => {
isMobile.value = window.innerWidth < 768
})
return {
layoutMode, chatStyle, sidebarCollapsed, isMobile,
setLayoutMode, setChatStyle, toggleSidebar,
layoutMode, chatStyle, sidebarCollapsed, isMobile, isDark,
setLayoutMode, setChatStyle, toggleSidebar, toggleDark, setDark,
}
})
+161
View File
@@ -13,6 +13,27 @@
<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>
<n-button quaternary size="small" @click="toggleSearch" title="搜索">🔍</n-button>
</div>
<!-- 搜索栏 -->
<div v-if="showSearch" class="search-bar">
<n-input v-model:value="searchQuery" placeholder="搜索消息..." size="small" clearable
@keydown.enter.prevent="doSearch" style="flex: 1" />
<n-button size="tiny" type="primary" :loading="isSearching" :disabled="!searchQuery.trim()" @click="doSearch">搜索</n-button>
<n-button size="tiny" quaternary @click="showSearch = false; searchResults = []"></n-button>
</div>
<!-- 搜索结果 -->
<div v-if="searchResults.length > 0" class="search-results">
<div class="search-results-header">
<span>找到 {{ searchResults.length }} 条结果</span>
</div>
<div v-for="r in searchResults" :key="r.id" class="search-result-item" @click="scrollToMsg(r.id)">
<span class="result-sender">{{ r.sender_name }}</span>
<span class="result-content">{{ r.content?.slice(0, 80) }}{{ (r.content?.length || 0) > 80 ? '...' : '' }}</span>
<span class="result-time">{{ formatTime(r.created_at) }}</span>
</div>
</div>
<div class="chat-body">
@@ -73,10 +94,39 @@
<!-- 右键菜单 -->
<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="forwardMsg"> 转发</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>
<!-- 转发选择器 -->
<div v-if="showForwardModal" class="modal-overlay" @click.self="showForwardModal = false">
<div class="forward-modal">
<div class="modal-header">
<h3>转发消息</h3>
<span class="close-btn" @click="showForwardModal = false"></span>
</div>
<div class="forward-body">
<div class="forward-preview">
<span class="forward-label">转发内容</span>
<span class="forward-text">{{ forwardContent?.slice(0, 100) }}{{ (forwardContent?.length || 0) > 100 ? '...' : '' }}</span>
</div>
<div class="forward-section-label">选择会话</div>
<div v-for="conv in chatStore.conversations" :key="conv.id" class="forward-conv-item"
:class="{ selected: forwardTarget === conv.id }" @click="forwardTarget = conv.id">
<n-avatar :size="32" round :style="{ background: 'var(--color-primary)' }">
{{ (conv.name || '?')[0] }}
</n-avatar>
<span class="forward-conv-name">{{ conv.name }}</span>
</div>
</div>
<div class="modal-footer">
<n-button size="small" @click="showForwardModal = false">取消</n-button>
<n-button size="small" type="primary" :disabled="!forwardTarget" @click="doForward">发送</n-button>
</div>
</div>
</div>
<GroupInfoPanel v-if="showGroupInfo && convDetail?.type === 'group'"
:conversation-id="String(route.params.id)" :detail="convDetail"
@close="showGroupInfo = false" @updated="loadDetail" />
@@ -110,6 +160,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick, watch, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMessage } from 'naive-ui'
import { useChatStore } from '@/stores/chat'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
@@ -122,6 +173,7 @@ import dayjs from 'dayjs'
const EMOJIS = ['😀','😃','😄','😁','😆','😅','🤣','😂','🙂','😊','😇','🥰','😍','🤩','😘','😗','😋','😛','😜','🤪','😝','🤑','🤗','🤭','🤫','🤔','😐','😑','😶','😏','😒','🙄','😬','😮','😯','😲','😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','🤬','😈','👿','💀','☠️','💩','🤡','👻','👽','🤖','👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👍','👎','✊','👊','🤛','🤜','👏','🙌','🤝','🙏','💪','❤️','🧡','💛','💚','💙','💜','🖤','💔','❣️','💕','💞','💓','💗','💖','💘','💝','💟','🔥','✨','🌟','⭐','💫','🎉','🎊','🎈','🎁','🎀','🏆','🥇','🎯','🎮','🎵','🎶','🎸','🎵','✈️','🚀','🌈','☀️','🌙','⛅','❄️','🌸','🌺','🌻','🌹','🍀','🌿','🌳','🌴','🐱','🐶','🐼','🦊','🦁','🐻','🐰','🐨','🐸','🐳','🦋','🌺','🍳','☕','🍺','🍕','🍔','🍟','🍦','🍰','🎂','🍫','🍬','☕','🍷','🍸','🏢','🏠','🏡','⛪','🕌','🏰','🗼','🗽','⛲','⛰️','🌋','🏖️','🏜️','🏝️','🏟️','🎯','🎨','🎬','🎤','🎧','🎼','🎹','🥁','🎺','🎸','🎻','🎲','♟️','🎮','🕹️','🧩','🎰','🚗','🚕','🚙','🚌','🚎','🏎️','🚓','🚑','🚒','🚐','🛻','🚚','🚛','🚜','🛵','🏍️','🚲','🛴','🛹','🚏','🛣️','🛤️','🛢️','⛽','🚨','🚥','🚦','🛑','🚧','⚓','⛵','🛶','🚤','🛳️','⛽','✈️','🛫','🛬','🪂','💺','🚁','🚟','🚠','🚡','🛩️','🚀','🛸','🎆','🎇','🎈','🎉','🎊','🎋','🎍','🎎','🎏','🎐','🎑','🧧','🎀','🎁','🎗️','🎟️','🎫','🎖️','🏆','🏅','🥇','🥈','🥉','⚽','⚾','🥎','🏀','排球','🏈','🏉','🎾','🥏','🎳','🏏','🏑','🏒','🥍','🏓','🏸','🥊','🥋','🥅','⛳','⛸️','🎣','🤿','🎽','🎿','🛷','🥌','🎯','🪀','🪁','🎱','🔮','🪄','🧿','🎮','🕹️','🎰','🎲','♟️','🧩','🧸','🪅','🪆','🖼️','🎨','🧵','🪡','🧶','🪢']
const route = useRoute()
const message = useMessage()
const chatStore = useChatStore()
const auth = useAuthStore()
const uiStore = useUiStore()
@@ -137,6 +189,14 @@ const showEmoji = ref(false)
const isOtherOnline = ref(false)
const replyingTo = ref<any>(null)
const lastTypingSent = ref(0)
const showSearch = ref(false)
const searchQuery = ref('')
const searchResults = ref<any[]>([])
const isSearching = ref(false)
const showForwardModal = ref(false)
const forwardTarget = ref<string | null>(null)
const forwardContent = ref('')
const forwardSender = ref('')
const emojis = EMOJIS
// Typing indicator: get users typing in current conversation
@@ -303,6 +363,49 @@ function cancelReply() {
replyingTo.value = null
}
function toggleSearch() {
showSearch.value = !showSearch.value
if (!showSearch.value) {
searchResults.value = []
searchQuery.value = ''
}
}
async function doSearch() {
if (!searchQuery.value.trim() || !chatStore.activeConversation) return
isSearching.value = true
try {
const { data } = await chatApi.searchMessages(chatStore.activeConversation, searchQuery.value.trim())
searchResults.value = data.results || []
} catch {
searchResults.value = []
} finally {
isSearching.value = false
}
}
function forwardMsg() {
if (!ctxMenu.msg) return
forwardContent.value = ctxMenu.msg.content || ''
forwardSender.value = ctxMenu.msg.sender_name || '用户'
forwardTarget.value = null
showForwardModal.value = true
ctxMenu.show = false
}
async function doForward() {
if (!forwardTarget.value || !forwardContent.value) return
const text = `[转发自 ${forwardSender.value}]: ${forwardContent.value}`
send('chat.send', {
conversation_id: forwardTarget.value,
content: text,
type: 'text',
})
message.success('已转发')
showForwardModal.value = false
forwardTarget.value = null
}
function scrollToMsg(msgId: string) {
const el = messageListRef.value
if (!el) return
@@ -466,12 +569,70 @@ function formatTimeDivider(time: string) {
.reply-bar-close { cursor: pointer; color: var(--color-text-hint); font-size: 14px; padding: 2px; }
.reply-bar-close:hover { color: var(--color-text-primary); }
/* Search bar */
.search-bar {
display: flex; align-items: center; gap: 8px; padding: 8px 20px;
border-bottom: 1px solid var(--color-border); background: var(--color-surface);
}
.search-results {
max-height: 240px; overflow-y: auto; border-bottom: 1px solid var(--color-border);
background: var(--color-surface-elevated);
}
.search-results-header {
padding: 6px 16px; font-size: 12px; color: var(--color-text-hint);
border-bottom: 1px solid var(--color-border); background: var(--color-surface);
}
.search-result-item {
display: flex; align-items: center; gap: 8px; padding: 8px 16px;
cursor: pointer; transition: background 0.15s; border-bottom: 1px solid var(--color-border);
}
.search-result-item:hover { background: var(--color-primary-lightest); }
.result-sender { font-size: 12px; color: var(--color-primary); font-weight: 500; flex-shrink: 0; }
.result-content { flex: 1; font-size: 13px; color: var(--color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.result-time { font-size: 11px; color: var(--color-text-hint); flex-shrink: 0; }
/* 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; }
.ctx-item:hover { background: var(--color-primary-lightest); }
.ctx-item.danger { color: #EF5350; }
/* Forward 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;
}
.forward-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;
}
.forward-modal .modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 20px 24px 16px; border-bottom: 1px solid var(--color-border);
}
.forward-modal .modal-header h3 { margin: 0; font-size: 18px; }
.forward-modal .close-btn { cursor: pointer; font-size: 18px; color: var(--color-text-hint); }
.forward-modal .close-btn:hover { color: var(--color-text-primary); }
.forward-body { flex: 1; overflow-y: auto; padding: 16px 24px; }
.forward-preview {
padding: 10px 12px; background: var(--color-primary-lightest); border-radius: 8px;
margin-bottom: 12px; border-left: 3px solid var(--color-primary);
}
.forward-label { font-size: 12px; color: var(--color-text-hint); display: block; margin-bottom: 4px; }
.forward-text { font-size: 13px; color: var(--color-text-primary); line-height: 1.5; }
.forward-section-label { font-size: 13px; color: var(--color-text-secondary); margin-bottom: 8px; font-weight: 500; }
.forward-conv-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px;
cursor: pointer; transition: background 0.15s;
}
.forward-conv-item:hover { background: var(--color-primary-lightest); }
.forward-conv-item.selected { background: var(--color-primary-lightest); border: 1px solid var(--color-primary); }
.forward-conv-name { font-size: 14px; }
.forward-modal .modal-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 12px 24px; border-top: 1px solid var(--color-border);
}
/* Input bar */
.input-bar { position: relative; display: flex; align-items: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--color-border); background: var(--color-surface); }
.input-actions { display: flex; gap: 4px; padding-bottom: 4px; }
+11 -2
View File
@@ -2,11 +2,12 @@
<div class="moment-card">
<!-- 头部头像 + 用户名 + 时间 + 删除 -->
<div class="moment-header">
<n-avatar :size="40" round :style="{ background: 'var(--color-primary)' }">
<n-avatar :size="40" round :style="{ background: 'var(--color-primary)', cursor: isMine ? 'default' : 'pointer' }"
@click="goToUserMoments">
{{ (moment.nickname || moment.username || '?')[0] }}
</n-avatar>
<div class="header-info">
<span class="author-name">{{ moment.nickname || moment.username }}</span>
<span class="author-name" :style="{ cursor: isMine ? 'default' : 'pointer' }" @click="goToUserMoments">{{ 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>
@@ -66,6 +67,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { momentsApi } from '@/api/moments'
import { useAuthStore } from '@/stores/auth'
import dayjs from 'dayjs'
@@ -83,6 +85,7 @@ const emit = defineEmits<{
}>()
const auth = useAuthStore()
const router = useRouter()
const showComments = ref(false)
const comments = ref<any[]>([])
const commentText = ref('')
@@ -90,6 +93,12 @@ const replyTarget = ref<any>(null)
const isMine = computed(() => props.moment.user_id === auth.user?.id)
function goToUserMoments() {
if (props.moment.user_id && !isMine.value) {
router.push(`/moments/user/${props.moment.user_id}`)
}
}
watch(showComments, async (val) => {
if (val && comments.value.length === 0) {
try {
@@ -0,0 +1,117 @@
<template>
<div class="user-moments">
<div class="panel-header">
<n-button quaternary circle size="small" @click="$router.push('/moments')"></n-button>
<h3 class="panel-title">{{ userName }} 的朋友圈</h3>
</div>
<div class="moments-content">
<div v-if="isLoading" class="loading">加载中...</div>
<div v-else-if="moments.length === 0" class="empty">
<div style="font-size: 48px">🌿</div>
<p style="color: var(--color-text-secondary)">还没有动态</p>
</div>
<div v-else class="moment-list">
<MomentCard
v-for="moment in moments"
:key="moment.id"
:moment="moment"
@toggle-like="handleToggleLike"
@comment="handleComment"
@delete="handleDelete"
/>
</div>
<div v-if="hasMore && moments.length > 0" class="load-more">
<n-button text size="small" @click="loadMore">加载更多</n-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMessage } from 'naive-ui'
import { momentsApi } from '@/api/moments'
import MomentCard from './MomentCard.vue'
const route = useRoute()
const message = useMessage()
const moments = ref<any[]>([])
const isLoading = ref(false)
const hasMore = ref(true)
const cursor = ref<string | null>(null)
const userName = ref('用户')
onMounted(() => {
const userId = route.params.userId as string
if (userId) fetchUserMoments(userId, true)
})
async function fetchUserMoments(userId: string, refresh = false) {
if (isLoading.value) return
isLoading.value = true
try {
const cur = refresh ? undefined : cursor.value || undefined
const { data } = await momentsApi.getUserMoments(userId, cur)
if (refresh) {
moments.value = data
} else {
moments.value = [...moments.value, ...data]
}
if (data.length > 0) {
cursor.value = data[data.length - 1].id
if (!userName.value || userName.value === '用户') {
userName.value = data[0].nickname || data[0].username || '用户'
}
}
if (data.length < 20) hasMore.value = false
} catch {
message.error('加载失败')
} finally {
isLoading.value = false
}
}
function loadMore() {
const userId = route.params.userId as string
fetchUserMoments(userId)
}
async function handleToggleLike(momentId: string) {
try {
const { data } = await momentsApi.toggleLike(momentId)
const m = moments.value.find((m) => m.id === momentId)
if (m) {
m.is_liked = data.is_liked
m.like_count = data.is_liked ? m.like_count + 1 : Math.max(0, m.like_count - 1)
}
} catch { message.error('操作失败') }
}
async function handleComment(momentId: string, content: string) {
message.success('评论成功')
}
async function handleDelete(momentId: string) {
try {
await momentsApi.deleteMoment(momentId)
moments.value = moments.value.filter((m) => m.id !== momentId)
message.success('已删除')
} catch { message.error('删除失败') }
}
</script>
<style scoped>
.user-moments { display: flex; flex-direction: column; height: 100%; }
.panel-header {
display: flex; align-items: center; gap: 8px;
padding: 16px; border-bottom: 1px solid var(--color-border);
}
.panel-title { margin: 0; font-size: 16px; font-weight: 600; }
.moments-content { flex: 1; overflow-y: auto; padding: 12px; }
.loading { text-align: center; padding: 40px; color: var(--color-text-hint); }
.empty { text-align: center; padding: 60px 20px; }
.moment-list { display: flex; flex-direction: column; gap: 12px; }
.load-more { text-align: center; padding: 12px; }
</style>
@@ -0,0 +1,135 @@
<template>
<div class="appearance-settings">
<h2 class="page-title">外观设置</h2>
<!-- 深色模式 -->
<div class="setting-section">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">深色模式</span>
<span class="setting-desc">切换深色/浅色主题</span>
</div>
<n-switch :value="uiStore.isDark" @update:value="uiStore.toggleDark()" />
</div>
</div>
<!-- 会话列表布局 -->
<div class="setting-section">
<div class="setting-row vertical">
<span class="setting-label">会话列表布局</span>
<n-button-group size="small">
<n-button :type="uiStore.layoutMode === 'list' ? 'primary' : 'default'" @click="uiStore.setLayoutMode('list')">
列表
</n-button>
<n-button :type="uiStore.layoutMode === 'card' ? 'primary' : 'default'" @click="uiStore.setLayoutMode('card')">
卡片
</n-button>
<n-button :type="uiStore.layoutMode === 'waterfall' ? 'primary' : 'default'" @click="uiStore.setLayoutMode('waterfall')">
瀑布流
</n-button>
</n-button-group>
</div>
<div class="preview-area">
<div class="layout-preview" :class="uiStore.layoutMode">
<div v-for="i in 3" :key="i" class="preview-item">
<div class="preview-avatar"></div>
<div class="preview-lines">
<div class="preview-line long"></div>
<div class="preview-line short"></div>
</div>
</div>
</div>
</div>
</div>
<!-- 聊天气泡风格 -->
<div class="setting-section">
<div class="setting-row vertical">
<span class="setting-label">聊天气泡风格</span>
<n-button-group size="small">
<n-button :type="uiStore.chatStyle === 'bubble' ? 'primary' : 'default'" @click="uiStore.setChatStyle('bubble')">
气泡
</n-button>
<n-button :type="uiStore.chatStyle === 'classic' ? 'primary' : 'default'" @click="uiStore.setChatStyle('classic')">
经典
</n-button>
<n-button :type="uiStore.chatStyle === 'compact' ? 'primary' : 'default'" @click="uiStore.setChatStyle('compact')">
紧凑
</n-button>
</n-button-group>
</div>
<div class="preview-area">
<div class="chat-preview" :class="uiStore.chatStyle">
<div class="chat-msg other">
<div class="chat-avatar"></div>
<div class="chat-bubble">你好</div>
</div>
<div class="chat-msg own">
<div class="chat-bubble">最近怎么样</div>
<div class="chat-avatar"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useUiStore } from '@/stores/ui'
const uiStore = useUiStore()
</script>
<style scoped>
.appearance-settings { padding: 24px; max-width: 600px; }
.page-title { font-size: 20px; font-weight: 600; margin: 0 0 24px; color: var(--color-text-primary); }
.setting-section {
margin-bottom: 24px; padding: 16px; background: var(--color-surface);
border-radius: 12px; border: 1px solid var(--color-border);
}
.setting-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.setting-row.vertical { flex-direction: column; align-items: flex-start; gap: 8px; }
.setting-info { display: flex; flex-direction: column; gap: 2px; }
.setting-label { font-size: 14px; font-weight: 500; color: var(--color-text-primary); }
.setting-desc { font-size: 12px; color: var(--color-text-hint); }
/* Layout preview */
.preview-area { margin-top: 12px; padding: 12px; background: var(--color-bg); border-radius: 8px; }
.layout-preview { display: flex; flex-direction: column; gap: 6px; }
.layout-preview .preview-item {
display: flex; align-items: center; gap: 8px; padding: 6px 8px;
background: var(--color-surface); border-radius: 6px;
}
.layout-preview.card .preview-item { padding: 10px; border: 1px solid var(--color-border); border-radius: 10px; }
.layout-preview.waterfall { flex-direction: row; flex-wrap: wrap; }
.layout-preview.waterfall .preview-item { width: calc(50% - 3px); }
.preview-avatar { width: 28px; height: 28px; border-radius: 50%; background: var(--color-primary-lighter); flex-shrink: 0; }
.preview-lines { flex: 1; }
.preview-line { height: 6px; border-radius: 3px; background: var(--color-border); margin-bottom: 4px; }
.preview-line:last-child { margin-bottom: 0; }
.preview-line.long { width: 70%; }
.preview-line.short { width: 40%; }
/* Chat preview */
.chat-preview { display: flex; flex-direction: column; gap: 8px; }
.chat-msg { display: flex; align-items: flex-start; gap: 6px; }
.chat-msg.own { justify-content: flex-end; }
.chat-avatar { width: 24px; height: 24px; border-radius: 50%; background: var(--color-primary-lighter); flex-shrink: 0; }
.chat-bubble {
padding: 6px 10px; font-size: 12px; line-height: 1.4; max-width: 60%;
}
.chat-preview.bubble .chat-bubble {
border-radius: 12px;
}
.chat-preview.bubble .other .chat-bubble { background: var(--color-bubble-other); color: var(--color-bubble-other-text); border-bottom-left-radius: 4px; }
.chat-preview.bubble .own .chat-bubble { background: var(--color-bubble-self); color: var(--color-bubble-self-text); border-bottom-right-radius: 4px; }
.chat-preview.classic .chat-bubble {
background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 4px;
}
.chat-preview.compact .chat-bubble {
background: transparent; padding: 2px 0; border-radius: 0; font-size: 11px;
}
</style>
@@ -16,6 +16,10 @@
<span class="menu-icon">🔔</span>
<span class="menu-label">通知设置</span>
</router-link>
<router-link to="/settings/appearance" class="menu-item" active-class="active">
<span class="menu-icon">🎨</span>
<span class="menu-label">外观设置</span>
</router-link>
<router-link to="/settings/about" class="menu-item" active-class="active">
<span class="menu-icon"></span>
<span class="menu-label">关于青叶</span>