1.5
This commit is contained in:
@@ -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