@@ -75,7 +82,7 @@ import DropdownMenu from '~/components/DropdownMenu.vue'
import ToolTip from '~/components/ToolTip.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
-import { fetchUnreadCount, notificationState } from '~/utils/notification'
+import { useUnreadCount } from '~/composables/useUnreadCount'
import { useIsMobile } from '~/utils/screen'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
import { toast } from '~/main'
@@ -93,7 +100,7 @@ const props = defineProps({
const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile()
-const unreadCount = computed(() => notificationState.unreadCount)
+const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
const avatar = ref('')
const showSearch = ref(false)
const searchDropdown = ref(null)
@@ -182,15 +189,18 @@ const goToNewPost = () => {
}
const refrechData = async () => {
- await fetchUnreadCount()
window.dispatchEvent(new Event('refresh-home'))
}
+const goToMessages = () => {
+ navigateTo('/messages');
+};
+
const headerMenuItems = computed(() => [
{ text: '设置', onClick: goToSettings },
{ text: '个人主页', onClick: goToProfile },
{ text: '退出', onClick: goToLogout },
-])
+]);
/** 其余逻辑保持不变 */
const iconClass = computed(() => {
@@ -215,9 +225,8 @@ onMounted(async () => {
}
const updateUnread = async () => {
if (authState.loggedIn) {
- await fetchUnreadCount()
- } else {
- notificationState.unreadCount = 0
+ // Initialize the unread count composable
+ fetchUnreadCount();
}
}
@@ -226,7 +235,7 @@ onMounted(async () => {
watch(
() => authState.loggedIn,
- async () => {
+ async (isLoggedIn) => {
await updateAvatar()
await updateUnread()
},
@@ -379,9 +388,27 @@ onMounted(async () => {
}
.rss-icon,
-.new-post-icon {
+.new-post-icon,
+.messages-icon {
font-size: 18px;
cursor: pointer;
+ position: relative;
+}
+
+.unread-badge {
+ position: absolute;
+ top: -5px;
+ right: -10px;
+ background-color: #ff4d4f;
+ color: white;
+ border-radius: 50%;
+ padding: 2px 5px;
+ font-size: 10px;
+ font-weight: bold;
+ line-height: 1;
+ min-width: 16px;
+ text-align: center;
+ box-sizing: border-box;
}
.rss-icon {
diff --git a/frontend_nuxt/components/MessageEditor.vue b/frontend_nuxt/components/MessageEditor.vue
new file mode 100644
index 000000000..865b1c127
--- /dev/null
+++ b/frontend_nuxt/components/MessageEditor.vue
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend_nuxt/composables/useUnreadCount.js b/frontend_nuxt/composables/useUnreadCount.js
new file mode 100644
index 000000000..381b67d6a
--- /dev/null
+++ b/frontend_nuxt/composables/useUnreadCount.js
@@ -0,0 +1,93 @@
+import { ref, watch, onMounted } from 'vue';
+import { useWebSocket } from './useWebSocket';
+import { getToken } from '~/utils/auth';
+
+const count = ref(0);
+let isInitialized = false;
+let wsSubscription = null;
+
+export function useUnreadCount() {
+ const config = useRuntimeConfig();
+ const API_BASE_URL = config.public.apiBaseUrl;
+ const { subscribe, isConnected, connect } = useWebSocket();
+
+ const fetchUnreadCount = async () => {
+ const token = getToken();
+ if (!token) {
+ count.value = 0;
+ return;
+ }
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/messages/unread-count`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (response.ok) {
+ const data = await response.json();
+ count.value = data;
+ }
+ } catch (error) {
+ console.error('Failed to fetch unread count:', error);
+ }
+ };
+
+ const initialize = async () => {
+ const token = getToken();
+ if (!token) {
+ count.value = 0;
+ return;
+ }
+
+ // 总是获取最新的未读数量
+ fetchUnreadCount();
+
+ // 确保WebSocket连接
+ if (!isConnected.value) {
+ connect(token);
+ }
+
+ // 设置WebSocket监听
+ await setupWebSocketListener();
+ };
+
+ const setupWebSocketListener = async () => {
+ // 只有在还没有订阅的情况下才设置监听
+ if (!wsSubscription) {
+
+ watch(isConnected, (newValue) => {
+ if (newValue && !wsSubscription) {
+ const destination = `/user/queue/unread-count`;
+ wsSubscription = subscribe(destination, (message) => {
+ const unreadCount = parseInt(message.body, 10);
+ if (!isNaN(unreadCount)) {
+ count.value = unreadCount;
+ }
+ });
+ }
+ }, { immediate: true });
+ }
+ };
+
+ // 自动初始化逻辑 - 确保每次调用都能获取到未读数量并设置监听
+ const token = getToken();
+ if (token) {
+ if (!isInitialized) {
+ isInitialized = true;
+ initialize(); // 完整初始化,包括WebSocket监听
+ } else {
+ // 即使已经初始化,也要确保获取最新的未读数量并确保WebSocket监听存在
+ fetchUnreadCount();
+
+ // 确保WebSocket连接和监听都存在
+ if (!isConnected.value) {
+ connect(token);
+ }
+ setupWebSocketListener();
+ }
+ }
+
+ return {
+ count,
+ fetchUnreadCount,
+ initialize,
+ };
+}
\ No newline at end of file
diff --git a/frontend_nuxt/composables/useWebSocket.js b/frontend_nuxt/composables/useWebSocket.js
new file mode 100644
index 000000000..389af3f1a
--- /dev/null
+++ b/frontend_nuxt/composables/useWebSocket.js
@@ -0,0 +1,85 @@
+import { ref } from 'vue';
+import { Client } from '@stomp/stompjs';
+import SockJS from 'sockjs-client/dist/sockjs.min.js';
+import { useRuntimeConfig } from '#app';
+
+const client = ref(null);
+const isConnected = ref(false);
+
+const connect = (token) => {
+ if (isConnected.value) {
+ return;
+ }
+
+ const config = useRuntimeConfig();
+ const API_BASE_URL = config.public.apiBaseUrl;
+ const socketUrl = `${API_BASE_URL}/ws`;
+
+ const socket = new SockJS(socketUrl);
+ const stompClient = new Client({
+ webSocketFactory: () => socket,
+ connectHeaders: {
+ Authorization: `Bearer ${token}`,
+ },
+ debug: function (str) {
+ },
+ reconnectDelay: 5000,
+ heartbeatIncoming: 4000,
+ heartbeatOutgoing: 4000,
+ });
+
+ stompClient.onConnect = (frame) => {
+ isConnected.value = true;
+ };
+
+ stompClient.onStompError = (frame) => {
+ console.error('WebSocket STOMP error:', frame);
+ };
+
+ stompClient.activate();
+ client.value = stompClient;
+};
+
+const disconnect = () => {
+ if (client.value) {
+ isConnected.value = false;
+ client.value.deactivate();
+ client.value = null;
+ }
+};
+
+const subscribe = (destination, callback) => {
+
+ if (!isConnected.value || !client.value || !client.value.connected) {
+ return null;
+ }
+
+ try {
+ const subscription = client.value.subscribe(destination, (message) => {
+ try {
+ if (destination.includes('/queue/unread-count')) {
+ callback(message);
+ } else {
+ const parsedMessage = JSON.parse(message.body);
+ callback(parsedMessage);
+ }
+ } catch (error) {
+ callback(message);
+ }
+ });
+
+ return subscription;
+ } catch (error) {
+ return null;
+ }
+};
+
+export function useWebSocket() {
+ return {
+ client,
+ isConnected,
+ connect,
+ disconnect,
+ subscribe,
+ };
+}
\ No newline at end of file
diff --git a/frontend_nuxt/package.json b/frontend_nuxt/package.json
index 2b6d110e9..a543255f0 100644
--- a/frontend_nuxt/package.json
+++ b/frontend_nuxt/package.json
@@ -21,6 +21,8 @@
"vue-echarts": "^7.0.3",
"vue-toastification": "^2.0.0-rc.5",
"flatpickr": "^4.6.13",
- "vue-flatpickr-component": "^12.0.0"
+ "vue-flatpickr-component": "^12.0.0",
+ "@stomp/stompjs": "^7.0.0",
+ "sockjs-client": "^1.6.1"
}
}
diff --git a/frontend_nuxt/pages/messages/[id].vue b/frontend_nuxt/pages/messages/[id].vue
new file mode 100644
index 000000000..62035b31f
--- /dev/null
+++ b/frontend_nuxt/pages/messages/[id].vue
@@ -0,0 +1,490 @@
+
+
+
+
+
+
加载中...
+
{{ error }}
+
+
+
+
+
+
![avatar]()
+
+
+
+ {{ formatMessageTime(msg.createdAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend_nuxt/pages/messages/index.vue b/frontend_nuxt/pages/messages/index.vue
new file mode 100644
index 000000000..085a1d96c
--- /dev/null
+++ b/frontend_nuxt/pages/messages/index.vue
@@ -0,0 +1,375 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+ {{ convo.lastMessage ? convo.lastMessage.content : '暂无消息' }}
+
+
+ {{ convo.unreadCount }}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend_nuxt/pages/users/[id].vue b/frontend_nuxt/pages/users/[id].vue
index b873add16..4523565df 100644
--- a/frontend_nuxt/pages/users/[id].vue
+++ b/frontend_nuxt/pages/users/[id].vue
@@ -27,7 +27,15 @@
>
取消关注
-
+
+