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.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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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列表"""
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } }),
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user