1.5
This commit is contained in:
@@ -7,6 +7,7 @@ from app.dependencies import get_db, get_current_user
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.message import MessageSend, MessagePage, MarkReadRequest
|
from app.schemas.message import MessageSend, MessagePage, MarkReadRequest
|
||||||
from app.services.message_service import MessageService
|
from app.services.message_service import MessageService
|
||||||
|
from app.services.conversation_service import ConversationService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -27,6 +28,30 @@ async def get_messages(
|
|||||||
raise HTTPException(status_code=403, detail=str(e))
|
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")
|
@router.put("/{conversation_id}/messages/{message_id}/read")
|
||||||
async def mark_as_read(
|
async def mark_as_read(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
|
|||||||
@@ -247,10 +247,18 @@ class ConversationService:
|
|||||||
ConversationMember.left_at.is_(None),
|
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 = []
|
members = []
|
||||||
for m in members_result.scalars().all():
|
for m in member_rows:
|
||||||
user_result = await self.db.execute(select(User).where(User.id == m.user_id))
|
user = users_map.get(m.user_id)
|
||||||
user = user_result.scalars().first()
|
|
||||||
if user:
|
if user:
|
||||||
members.append({
|
members.append({
|
||||||
"id": m.id,
|
"id": m.id,
|
||||||
|
|||||||
@@ -102,12 +102,20 @@ class FriendService:
|
|||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(Friend).where(Friend.user_id == user_id)
|
select(Friend).where(Friend.user_id == user_id)
|
||||||
)
|
)
|
||||||
friends = []
|
friendships = result.scalars().all()
|
||||||
for friendship in result.scalars().all():
|
if not friendships:
|
||||||
user_result = await self.db.execute(
|
return []
|
||||||
select(User).where(User.id == friendship.friend_user_id)
|
|
||||||
|
# 批量获取所有好友用户
|
||||||
|
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))
|
||||||
)
|
)
|
||||||
user = user_result.scalars().first()
|
users_map = {u.id: u for u in users_result.scalars().all()}
|
||||||
|
|
||||||
|
friends = []
|
||||||
|
for friendship in friendships:
|
||||||
|
user = users_map.get(friendship.friend_user_id)
|
||||||
if user:
|
if user:
|
||||||
friends.append({
|
friends.append({
|
||||||
"id": friendship.id,
|
"id": friendship.id,
|
||||||
@@ -128,10 +136,20 @@ class FriendService:
|
|||||||
FriendRequest.status == "pending",
|
FriendRequest.status == "pending",
|
||||||
).order_by(FriendRequest.created_at.desc())
|
).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 = []
|
requests = []
|
||||||
for req in result.scalars().all():
|
for req in requests_list:
|
||||||
from_user = await self.db.execute(select(User).where(User.id == req.from_user_id))
|
fu = users_map.get(req.from_user_id)
|
||||||
fu = from_user.scalars().first()
|
|
||||||
requests.append({
|
requests.append({
|
||||||
"id": req.id,
|
"id": req.id,
|
||||||
"from_user_id": req.from_user_id,
|
"from_user_id": req.from_user_id,
|
||||||
|
|||||||
@@ -77,29 +77,42 @@ class MessageService:
|
|||||||
has_more = len(messages) > limit
|
has_more = len(messages) > limit
|
||||||
messages = messages[:limit]
|
messages = messages[:limit]
|
||||||
|
|
||||||
# 获取发送者信息
|
# 批量预加载发送者信息
|
||||||
from app.models.user import User
|
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 = []
|
message_list = []
|
||||||
for msg in reversed(messages):
|
for msg in reversed(messages):
|
||||||
sender_result = await self.db.execute(
|
sender = senders_map.get(msg.sender_id)
|
||||||
select(User).where(User.id == msg.sender_id)
|
|
||||||
)
|
|
||||||
sender = sender_result.scalars().first()
|
|
||||||
|
|
||||||
# 获取被引用消息的信息
|
# 获取被引用消息的信息
|
||||||
reply_to_content = None
|
reply_to_content = None
|
||||||
reply_to_sender_name = None
|
reply_to_sender_name = None
|
||||||
if msg.reply_to_id:
|
if msg.reply_to_id:
|
||||||
reply_msg_result = await self.db.execute(
|
reply_msg = reply_msgs_map.get(msg.reply_to_id)
|
||||||
select(Message).where(Message.id == msg.reply_to_id)
|
|
||||||
)
|
|
||||||
reply_msg = reply_msg_result.scalars().first()
|
|
||||||
if reply_msg:
|
if reply_msg:
|
||||||
reply_to_content = reply_msg.content[:200] if reply_msg.content else None
|
reply_to_content = reply_msg.content[:200] if reply_msg.content else None
|
||||||
reply_sender_result = await self.db.execute(
|
reply_sender = reply_senders_map.get(reply_msg.sender_id)
|
||||||
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
|
reply_to_sender_name = reply_sender.username if reply_sender else None
|
||||||
|
|
||||||
message_list.append({
|
message_list.append({
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ class MomentService:
|
|||||||
unique.sort(key=lambda x: x.created_at, reverse=True)
|
unique.sort(key=lambda x: x.created_at, reverse=True)
|
||||||
unique = unique[:limit]
|
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,
|
async def get_user_moments(self, user_id: str, viewer_id: str | None = None,
|
||||||
cursor: str | None = None, limit: int = 20) -> list[dict]:
|
cursor: str | None = None, limit: int = 20) -> list[dict]:
|
||||||
@@ -116,7 +116,7 @@ class MomentService:
|
|||||||
if viewer_id == user_id:
|
if viewer_id == user_id:
|
||||||
filtered.append(m)
|
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):
|
async def delete_moment(self, moment_id: str, user_id: str):
|
||||||
"""删除动态(仅作者)"""
|
"""删除动态(仅作者)"""
|
||||||
@@ -243,43 +243,56 @@ class MomentService:
|
|||||||
raise ValueError("只能删除自己的评论")
|
raise ValueError("只能删除自己的评论")
|
||||||
await self.db.delete(comment)
|
await self.db.delete(comment)
|
||||||
|
|
||||||
async def _moment_to_dict(self, moment: Moment, viewer_id: str | None) -> dict:
|
async def _moments_to_dicts(self, moments: list[Moment], viewer_id: str | None) -> list[dict]:
|
||||||
"""将 Moment ORM 对象转为前端需要的字典"""
|
"""批量将 Moment ORM 对象转为前端需要的字典(优化 N+1 查询)"""
|
||||||
user_result = await self.db.execute(select(User).where(User.id == moment.user_id))
|
if not moments:
|
||||||
user = user_result.scalars().first()
|
return []
|
||||||
|
|
||||||
# 点赞数
|
moment_ids = [m.id for m in moments]
|
||||||
like_count_result = await self.db.execute(
|
user_ids = list(set(m.user_id for m in moments))
|
||||||
select(func.count(MomentLike.id)).where(MomentLike.moment_id == moment.id)
|
|
||||||
|
# 批量获取所有作者
|
||||||
|
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:
|
if viewer_id:
|
||||||
like_result = await self.db.execute(
|
liked_result = await self.db.execute(
|
||||||
select(MomentLike).where(
|
select(MomentLike.moment_id).where(
|
||||||
MomentLike.moment_id == moment.id,
|
MomentLike.moment_id.in_(moment_ids),
|
||||||
MomentLike.user_id == viewer_id,
|
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()}
|
||||||
|
|
||||||
# 评论数
|
result = []
|
||||||
comment_count_result = await self.db.execute(
|
for moment in moments:
|
||||||
select(func.count(MomentComment.id)).where(MomentComment.moment_id == moment.id)
|
user = users_map.get(moment.user_id)
|
||||||
)
|
|
||||||
comment_count = comment_count_result.scalar() or 0
|
|
||||||
|
|
||||||
# 解析图片
|
|
||||||
images = []
|
images = []
|
||||||
if moment.images:
|
if moment.images:
|
||||||
try:
|
try:
|
||||||
images = json.loads(moment.images)
|
images = json.loads(moment.images)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {
|
result.append({
|
||||||
"id": moment.id,
|
"id": moment.id,
|
||||||
"user_id": moment.user_id,
|
"user_id": moment.user_id,
|
||||||
"username": user.username if user else "未知",
|
"username": user.username if user else "未知",
|
||||||
@@ -288,11 +301,12 @@ class MomentService:
|
|||||||
"content": moment.content,
|
"content": moment.content,
|
||||||
"images": images,
|
"images": images,
|
||||||
"visibility": moment.visibility,
|
"visibility": moment.visibility,
|
||||||
"like_count": like_count,
|
"like_count": like_counts_map.get(moment.id, 0),
|
||||||
"is_liked": is_liked,
|
"is_liked": moment.id in liked_moment_ids,
|
||||||
"comment_count": comment_count,
|
"comment_count": comment_counts_map.get(moment.id, 0),
|
||||||
"created_at": moment.created_at,
|
"created_at": moment.created_at,
|
||||||
}
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
async def _get_friend_ids(self, user_id: str) -> list[str]:
|
async def _get_friend_ids(self, user_id: str) -> list[str]:
|
||||||
"""获取好友ID列表"""
|
"""获取好友ID列表"""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-config-provider :theme-overrides="themeOverrides">
|
<n-config-provider :theme="uiStore.isDark ? darkTheme : undefined" :theme-overrides="themeOverrides">
|
||||||
<n-notification-provider>
|
<n-notification-provider>
|
||||||
<n-message-provider>
|
<n-message-provider>
|
||||||
<n-dialog-provider>
|
<n-dialog-provider>
|
||||||
@@ -11,7 +11,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { darkTheme } from 'naive-ui'
|
||||||
import type { GlobalThemeOverrides } from 'naive-ui'
|
import type { GlobalThemeOverrides } from 'naive-ui'
|
||||||
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
/** 青绿色主题配置 */
|
/** 青绿色主题配置 */
|
||||||
const themeOverrides: GlobalThemeOverrides = {
|
const themeOverrides: GlobalThemeOverrides = {
|
||||||
@@ -65,6 +69,6 @@ const themeOverrides: GlobalThemeOverrides = {
|
|||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: #F5FBF9;
|
background-color: var(--color-bg);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -37,4 +37,7 @@ export const chatApi = {
|
|||||||
|
|
||||||
deleteMessage: (conversationId: string, messageId: string) =>
|
deleteMessage: (conversationId: string, messageId: string) =>
|
||||||
api.delete(`/conversations/${conversationId}/messages/${messageId}`),
|
api.delete(`/conversations/${conversationId}/messages/${messageId}`),
|
||||||
|
|
||||||
|
searchMessages: (conversationId: string, keyword: string) =>
|
||||||
|
api.get(`/conversations/${conversationId}/messages/search`, { params: { q: keyword } }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,36 @@
|
|||||||
--color-bubble-other-text: #1A2E2A;
|
--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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,12 +69,26 @@ const routes: RouteRecordRaw[] = [
|
|||||||
// 朋友圈
|
// 朋友圈
|
||||||
{
|
{
|
||||||
path: 'moments',
|
path: 'moments',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
name: 'Moments',
|
name: 'Moments',
|
||||||
components: {
|
components: {
|
||||||
secondary: () => import('@/views/moments/MomentsSidebar.vue'),
|
secondary: () => import('@/views/moments/MomentsSidebar.vue'),
|
||||||
default: () => import('@/views/moments/MomentsFeedView.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'),
|
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',
|
path: 'about',
|
||||||
name: 'SettingsAbout',
|
name: 'SettingsAbout',
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ export const useUiStore = defineStore('ui', () => {
|
|||||||
const chatStyle = ref<ChatStyle>((localStorage.getItem('chatStyle') as ChatStyle) || 'bubble')
|
const chatStyle = ref<ChatStyle>((localStorage.getItem('chatStyle') as ChatStyle) || 'bubble')
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
const isMobile = ref(window.innerWidth < 768)
|
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) {
|
function setLayoutMode(mode: LayoutMode) {
|
||||||
layoutMode.value = mode
|
layoutMode.value = mode
|
||||||
@@ -24,13 +30,25 @@ export const useUiStore = defineStore('ui', () => {
|
|||||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
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', () => {
|
window.addEventListener('resize', () => {
|
||||||
isMobile.value = window.innerWidth < 768
|
isMobile.value = window.innerWidth < 768
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layoutMode, chatStyle, sidebarCollapsed, isMobile,
|
layoutMode, chatStyle, sidebarCollapsed, isMobile, isDark,
|
||||||
setLayoutMode, setChatStyle, toggleSidebar,
|
setLayoutMode, setChatStyle, toggleSidebar, toggleDark, setDark,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,27 @@
|
|||||||
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="chat-body">
|
<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 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="replyToMsg">↩️ 回复</div>
|
||||||
|
<div class="ctx-item" @click="forwardMsg">↗️ 转发</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>
|
||||||
|
|
||||||
|
<!-- 转发选择器 -->
|
||||||
|
<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'"
|
<GroupInfoPanel v-if="showGroupInfo && convDetail?.type === 'group'"
|
||||||
:conversation-id="String(route.params.id)" :detail="convDetail"
|
:conversation-id="String(route.params.id)" :detail="convDetail"
|
||||||
@close="showGroupInfo = false" @updated="loadDetail" />
|
@close="showGroupInfo = false" @updated="loadDetail" />
|
||||||
@@ -110,6 +160,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted, nextTick, watch, onUnmounted } from 'vue'
|
import { ref, reactive, computed, onMounted, nextTick, watch, onUnmounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUiStore } from '@/stores/ui'
|
import { useUiStore } from '@/stores/ui'
|
||||||
@@ -122,6 +173,7 @@ import dayjs from 'dayjs'
|
|||||||
const EMOJIS = ['😀','😃','😄','😁','😆','😅','🤣','😂','🙂','😊','😇','🥰','😍','🤩','😘','😗','😋','😛','😜','🤪','😝','🤑','🤗','🤭','🤫','🤔','😐','😑','😶','😏','😒','🙄','😬','😮','😯','😲','😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','🤬','😈','👿','💀','☠️','💩','🤡','👻','👽','🤖','👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👍','👎','✊','👊','🤛','🤜','👏','🙌','🤝','🙏','💪','❤️','🧡','💛','💚','💙','💜','🖤','💔','❣️','💕','💞','💓','💗','💖','💘','💝','💟','🔥','✨','🌟','⭐','💫','🎉','🎊','🎈','🎁','🎀','🏆','🥇','🎯','🎮','🎵','🎶','🎸','🎵','✈️','🚀','🌈','☀️','🌙','⛅','❄️','🌸','🌺','🌻','🌹','🍀','🌿','🌳','🌴','🐱','🐶','🐼','🦊','🦁','🐻','🐰','🐨','🐸','🐳','🦋','🌺','🍳','☕','🍺','🍕','🍔','🍟','🍦','🍰','🎂','🍫','🍬','☕','🍷','🍸','🏢','🏠','🏡','⛪','🕌','🏰','🗼','🗽','⛲','⛰️','🌋','🏖️','🏜️','🏝️','🏟️','🎯','🎨','🎬','🎤','🎧','🎼','🎹','🥁','🎺','🎸','🎻','🎲','♟️','🎮','🕹️','🧩','🎰','🚗','🚕','🚙','🚌','🚎','🏎️','🚓','🚑','🚒','🚐','🛻','🚚','🚛','🚜','🛵','🏍️','🚲','🛴','🛹','🚏','🛣️','🛤️','🛢️','⛽','🚨','🚥','🚦','🛑','🚧','⚓','⛵','🛶','🚤','🛳️','⛽','✈️','🛫','🛬','🪂','💺','🚁','🚟','🚠','🚡','🛩️','🚀','🛸','🎆','🎇','🎈','🎉','🎊','🎋','🎍','🎎','🎏','🎐','🎑','🧧','🎀','🎁','🎗️','🎟️','🎫','🎖️','🏆','🏅','🥇','🥈','🥉','⚽','⚾','🥎','🏀','排球','🏈','🏉','🎾','🥏','🎳','🏏','🏑','🏒','🥍','🏓','🏸','🥊','🥋','🥅','⛳','⛸️','🎣','🤿','🎽','🎿','🛷','🥌','🎯','🪀','🪁','🎱','🔮','🪄','🧿','🎮','🕹️','🎰','🎲','♟️','🧩','🧸','🪅','🪆','🖼️','🎨','🧵','🪡','🧶','🪢']
|
const EMOJIS = ['😀','😃','😄','😁','😆','😅','🤣','😂','🙂','😊','😇','🥰','😍','🤩','😘','😗','😋','😛','😜','🤪','😝','🤑','🤗','🤭','🤫','🤔','😐','😑','😶','😏','😒','🙄','😬','😮','😯','😲','😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','🤬','😈','👿','💀','☠️','💩','🤡','👻','👽','🤖','👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👍','👎','✊','👊','🤛','🤜','👏','🙌','🤝','🙏','💪','❤️','🧡','💛','💚','💙','💜','🖤','💔','❣️','💕','💞','💓','💗','💖','💘','💝','💟','🔥','✨','🌟','⭐','💫','🎉','🎊','🎈','🎁','🎀','🏆','🥇','🎯','🎮','🎵','🎶','🎸','🎵','✈️','🚀','🌈','☀️','🌙','⛅','❄️','🌸','🌺','🌻','🌹','🍀','🌿','🌳','🌴','🐱','🐶','🐼','🦊','🦁','🐻','🐰','🐨','🐸','🐳','🦋','🌺','🍳','☕','🍺','🍕','🍔','🍟','🍦','🍰','🎂','🍫','🍬','☕','🍷','🍸','🏢','🏠','🏡','⛪','🕌','🏰','🗼','🗽','⛲','⛰️','🌋','🏖️','🏜️','🏝️','🏟️','🎯','🎨','🎬','🎤','🎧','🎼','🎹','🥁','🎺','🎸','🎻','🎲','♟️','🎮','🕹️','🧩','🎰','🚗','🚕','🚙','🚌','🚎','🏎️','🚓','🚑','🚒','🚐','🛻','🚚','🚛','🚜','🛵','🏍️','🚲','🛴','🛹','🚏','🛣️','🛤️','🛢️','⛽','🚨','🚥','🚦','🛑','🚧','⚓','⛵','🛶','🚤','🛳️','⛽','✈️','🛫','🛬','🪂','💺','🚁','🚟','🚠','🚡','🛩️','🚀','🛸','🎆','🎇','🎈','🎉','🎊','🎋','🎍','🎎','🎏','🎐','🎑','🧧','🎀','🎁','🎗️','🎟️','🎫','🎖️','🏆','🏅','🥇','🥈','🥉','⚽','⚾','🥎','🏀','排球','🏈','🏉','🎾','🥏','🎳','🏏','🏑','🏒','🥍','🏓','🏸','🥊','🥋','🥅','⛳','⛸️','🎣','🤿','🎽','🎿','🛷','🥌','🎯','🪀','🪁','🎱','🔮','🪄','🧿','🎮','🕹️','🎰','🎲','♟️','🧩','🧸','🪅','🪆','🖼️','🎨','🧵','🪡','🧶','🪢']
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const message = useMessage()
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const uiStore = useUiStore()
|
const uiStore = useUiStore()
|
||||||
@@ -137,6 +189,14 @@ const showEmoji = ref(false)
|
|||||||
const isOtherOnline = ref(false)
|
const isOtherOnline = ref(false)
|
||||||
const replyingTo = ref<any>(null)
|
const replyingTo = ref<any>(null)
|
||||||
const lastTypingSent = ref(0)
|
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
|
const emojis = EMOJIS
|
||||||
|
|
||||||
// Typing indicator: get users typing in current conversation
|
// Typing indicator: get users typing in current conversation
|
||||||
@@ -303,6 +363,49 @@ function cancelReply() {
|
|||||||
replyingTo.value = null
|
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) {
|
function scrollToMsg(msgId: string) {
|
||||||
const el = messageListRef.value
|
const el = messageListRef.value
|
||||||
if (!el) return
|
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 { cursor: pointer; color: var(--color-text-hint); font-size: 14px; padding: 2px; }
|
||||||
.reply-bar-close:hover { color: var(--color-text-primary); }
|
.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 */
|
/* 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; }
|
||||||
.ctx-item:hover { background: var(--color-primary-lightest); }
|
.ctx-item:hover { background: var(--color-primary-lightest); }
|
||||||
.ctx-item.danger { color: #EF5350; }
|
.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 */
|
||||||
.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-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; }
|
.input-actions { display: flex; gap: 4px; padding-bottom: 4px; }
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
<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)', cursor: isMine ? 'default' : 'pointer' }"
|
||||||
|
@click="goToUserMoments">
|
||||||
{{ (moment.nickname || moment.username || '?')[0] }}
|
{{ (moment.nickname || moment.username || '?')[0] }}
|
||||||
</n-avatar>
|
</n-avatar>
|
||||||
<div class="header-info">
|
<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>
|
<span class="post-time">{{ formatTime(moment.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="isMine" class="delete-btn" title="删除" @click="handleDelete">🗑️</span>
|
<span v-if="isMine" class="delete-btn" title="删除" @click="handleDelete">🗑️</span>
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
|
|
||||||
<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 { momentsApi } from '@/api/moments'
|
import { momentsApi } from '@/api/moments'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@@ -83,6 +85,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
const showComments = ref(false)
|
const showComments = ref(false)
|
||||||
const comments = ref<any[]>([])
|
const comments = ref<any[]>([])
|
||||||
const commentText = ref('')
|
const commentText = ref('')
|
||||||
@@ -90,6 +93,12 @@ const replyTarget = ref<any>(null)
|
|||||||
|
|
||||||
const isMine = computed(() => props.moment.user_id === auth.user?.id)
|
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) => {
|
watch(showComments, async (val) => {
|
||||||
if (val && comments.value.length === 0) {
|
if (val && comments.value.length === 0) {
|
||||||
try {
|
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-icon">🔔</span>
|
||||||
<span class="menu-label">通知设置</span>
|
<span class="menu-label">通知设置</span>
|
||||||
</router-link>
|
</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">
|
<router-link to="/settings/about" class="menu-item" active-class="active">
|
||||||
<span class="menu-icon">ℹ️</span>
|
<span class="menu-icon">ℹ️</span>
|
||||||
<span class="menu-label">关于青叶</span>
|
<span class="menu-label">关于青叶</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user