mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-05-08 03:37:28 +08:00
Merge branch 'nagisa77:main' into main
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
--background-color-blur: rgba(255, 255, 255, 0.57);
|
||||
--menu-border-color: lightgray;
|
||||
--normal-border-color: lightgray;
|
||||
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
|
||||
--menu-selected-background-color: rgba(228, 228, 228, 0.884);
|
||||
--menu-text-color: black;
|
||||
--scroller-background-color: rgba(130, 175, 180, 0.5);
|
||||
/* --normal-background-color: rgb(241, 241, 241); */
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
<template v-else-if="medal.type === 'POST'">
|
||||
{{ medal.currentPostCount }}/{{ medal.targetPostCount }}
|
||||
</template>
|
||||
<template v-else-if="medal.type === 'FEATURED'">
|
||||
{{ medal.currentFeaturedCount }}/{{ medal.targetFeaturedCount }}
|
||||
</template>
|
||||
<template v-else-if="medal.type === 'CONTRIBUTOR'">
|
||||
{{ medal.currentContributionLines }}/{{ medal.targetContributionLines }}
|
||||
</template>
|
||||
|
||||
65
frontend_nuxt/components/BaseSwitch.vue
Normal file
65
frontend_nuxt/components/BaseSwitch.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.checked)"
|
||||
/>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.2s;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
transition: 0.2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
</style>
|
||||
@@ -41,6 +41,12 @@ export default {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.timeline-item:hover {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
transition: background-color 0.2s;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.timeline-icon {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
@close="closeMilkTeaPopup"
|
||||
/>
|
||||
<NotificationSettingPopup :visible="showNotificationPopup" @close="closeNotificationPopup" />
|
||||
<MessagePopup :visible="showMessagePopup" @close="closeMessagePopup" />
|
||||
<MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" />
|
||||
|
||||
<ActivityPopup
|
||||
@@ -22,6 +23,7 @@
|
||||
import ActivityPopup from '~/components/ActivityPopup.vue'
|
||||
import MedalPopup from '~/components/MedalPopup.vue'
|
||||
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
|
||||
import MessagePopup from '~/components/MessagePopup.vue'
|
||||
import { authState } from '~/utils/auth'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
@@ -33,6 +35,7 @@ const milkTeaIcon = ref('')
|
||||
const inviteCodeIcon = ref('')
|
||||
|
||||
const showNotificationPopup = ref(false)
|
||||
const showMessagePopup = ref(false)
|
||||
const showMedalPopup = ref(false)
|
||||
const newMedals = ref([])
|
||||
|
||||
@@ -43,6 +46,9 @@ onMounted(async () => {
|
||||
await checkInviteCodeActivity()
|
||||
if (showInviteCodePopup.value) return
|
||||
|
||||
await checkMessageFeature()
|
||||
if (showMessagePopup.value) return
|
||||
|
||||
await checkNotificationSetting()
|
||||
if (showNotificationPopup.value) return
|
||||
|
||||
@@ -50,7 +56,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
const checkMilkTeaActivity = async () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
if (localStorage.getItem('milkTeaActivityPopupShown')) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||
@@ -68,7 +74,7 @@ const checkMilkTeaActivity = async () => {
|
||||
}
|
||||
|
||||
const checkInviteCodeActivity = async () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
if (localStorage.getItem('inviteCodeActivityPopupShown')) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||
@@ -86,32 +92,42 @@ const checkInviteCodeActivity = async () => {
|
||||
}
|
||||
|
||||
const closeInviteCodePopup = () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
localStorage.setItem('inviteCodeActivityPopupShown', 'true')
|
||||
showInviteCodePopup.value = false
|
||||
}
|
||||
|
||||
const closeMilkTeaPopup = () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
||||
showMilkTeaPopup.value = false
|
||||
checkNotificationSetting()
|
||||
}
|
||||
|
||||
const checkMessageFeature = async () => {
|
||||
if (!import.meta.client) return
|
||||
if (!authState.loggedIn) return
|
||||
if (localStorage.getItem('messageFeaturePopupShown')) return
|
||||
showMessagePopup.value = true
|
||||
}
|
||||
const closeMessagePopup = () => {
|
||||
if (!import.meta.client) return
|
||||
localStorage.setItem('messageFeaturePopupShown', 'true')
|
||||
showMessagePopup.value = false
|
||||
}
|
||||
|
||||
const checkNotificationSetting = async () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
if (!authState.loggedIn) return
|
||||
if (localStorage.getItem('notificationSettingPopupShown')) return
|
||||
showNotificationPopup.value = true
|
||||
}
|
||||
const closeNotificationPopup = () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
localStorage.setItem('notificationSettingPopupShown', 'true')
|
||||
showNotificationPopup.value = false
|
||||
checkNewMedals()
|
||||
}
|
||||
const checkNewMedals = async () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
if (!authState.loggedIn || !authState.userId) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
|
||||
@@ -129,7 +145,7 @@ const checkNewMedals = async () => {
|
||||
}
|
||||
}
|
||||
const closeMedalPopup = () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
|
||||
newMedals.value.forEach((m) => seen.add(m.type))
|
||||
localStorage.setItem('seenMedals', JSON.stringify([...seen]))
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
||||
<span
|
||||
v-if="isMobile && (unreadMessageCount > 0 || hasChannelUnread)"
|
||||
class="menu-unread-dot"
|
||||
></span>
|
||||
</div>
|
||||
<NuxtLink class="logo-container" :to="`/`" @click="refrechData">
|
||||
<img
|
||||
@@ -47,6 +50,16 @@
|
||||
</div>
|
||||
</ToolTip>
|
||||
|
||||
<ToolTip v-if="isLogin" content="站内信和频道" placement="bottom">
|
||||
<div class="messages-icon" @click="goToMessages">
|
||||
<i class="fas fa-comments"></i>
|
||||
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
|
||||
unreadMessageCount
|
||||
}}</span>
|
||||
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
|
||||
</div>
|
||||
</ToolTip>
|
||||
|
||||
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
|
||||
<template #trigger>
|
||||
<div class="avatar-container">
|
||||
@@ -75,7 +88,8 @@ 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 { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||
import { toast } from '~/main'
|
||||
@@ -93,7 +107,8 @@ const props = defineProps({
|
||||
|
||||
const isLogin = computed(() => authState.loggedIn)
|
||||
const isMobile = useIsMobile()
|
||||
const unreadCount = computed(() => notificationState.unreadCount)
|
||||
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
|
||||
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
|
||||
const avatar = ref('')
|
||||
const showSearch = ref(false)
|
||||
const searchDropdown = ref(null)
|
||||
@@ -182,10 +197,13 @@ const goToNewPost = () => {
|
||||
}
|
||||
|
||||
const refrechData = async () => {
|
||||
await fetchUnreadCount()
|
||||
window.dispatchEvent(new Event('refresh-home'))
|
||||
}
|
||||
|
||||
const goToMessages = () => {
|
||||
navigateTo('/message-box')
|
||||
}
|
||||
|
||||
const headerMenuItems = computed(() => [
|
||||
{ text: '设置', onClick: goToSettings },
|
||||
{ text: '个人主页', onClick: goToProfile },
|
||||
@@ -215,9 +233,10 @@ onMounted(async () => {
|
||||
}
|
||||
const updateUnread = async () => {
|
||||
if (authState.loggedIn) {
|
||||
await fetchUnreadCount()
|
||||
fetchUnreadCount()
|
||||
fetchChannelUnread()
|
||||
} else {
|
||||
notificationState.unreadCount = 0
|
||||
fetchChannelUnread()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +245,7 @@ onMounted(async () => {
|
||||
|
||||
watch(
|
||||
() => authState.loggedIn,
|
||||
async () => {
|
||||
async (isLoggedIn) => {
|
||||
await updateAvatar()
|
||||
await updateUnread()
|
||||
},
|
||||
@@ -379,9 +398,37 @@ 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;
|
||||
}
|
||||
|
||||
.unread-dot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #ff4d4f;
|
||||
}
|
||||
|
||||
.rss-icon {
|
||||
|
||||
@@ -40,7 +40,7 @@ const stopObserver = () => {
|
||||
}
|
||||
|
||||
const startObserver = () => {
|
||||
if (!process.client || props.pause || done.value) return
|
||||
if (!import.meta.client || props.pause || done.value) return
|
||||
stopObserver()
|
||||
io = new IntersectionObserver(
|
||||
async (entries) => {
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
||||
<span>tag</span>
|
||||
<span>标签</span>
|
||||
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
<div v-if="tagOpen" class="section-items">
|
||||
@@ -262,7 +262,7 @@ const gotoTag = (t) => {
|
||||
top: var(--header-height);
|
||||
width: 220px;
|
||||
background-color: var(--app-menu-background-color);
|
||||
height: calc(100vh - 20px - var(--header-height));
|
||||
height: calc(100vh - var(--header-height));
|
||||
border-right: 1px solid var(--menu-border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -348,6 +348,7 @@ const gotoTag = (t) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
|
||||
182
frontend_nuxt/components/MessageEditor.vue
Normal file
182
frontend_nuxt/components/MessageEditor.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="message-editor-container">
|
||||
<div class="message-editor-wrapper">
|
||||
<div :id="editorId" ref="vditorElement"></div>
|
||||
</div>
|
||||
<div class="message-bottom-container">
|
||||
<div class="message-submit" :class="{ disabled: isDisabled }" @click="submit">
|
||||
<template v-if="!loading"> 发送 </template>
|
||||
<template v-else> <i class="fa-solid fa-spinner fa-spin"></i> 发送中... </template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed, onMounted, onUnmounted, ref, useId, watch } from 'vue'
|
||||
import { clearVditorStorage } from '~/utils/clearVditorStorage'
|
||||
import { themeState } from '~/utils/theme'
|
||||
import {
|
||||
createVditor,
|
||||
getEditorTheme as getEditorThemeUtil,
|
||||
getPreviewTheme as getPreviewThemeUtil,
|
||||
} from '~/utils/vditor'
|
||||
import '~/assets/global.css'
|
||||
|
||||
export default {
|
||||
name: 'MessageEditor',
|
||||
emits: ['submit'],
|
||||
props: {
|
||||
editorId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const vditorInstance = ref(null)
|
||||
const text = ref('')
|
||||
const editorId = ref(props.editorId)
|
||||
if (!editorId.value) {
|
||||
editorId.value = 'editor-' + useId()
|
||||
}
|
||||
const getEditorTheme = getEditorThemeUtil
|
||||
const getPreviewTheme = getPreviewThemeUtil
|
||||
const applyTheme = () => {
|
||||
if (vditorInstance.value) {
|
||||
vditorInstance.value.setTheme(getEditorTheme(), getPreviewTheme())
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = computed(() => props.loading || props.disabled || !text.value.trim())
|
||||
|
||||
const submit = () => {
|
||||
if (!vditorInstance.value || isDisabled.value) return
|
||||
const value = vditorInstance.value.getValue()
|
||||
emit('submit', value, () => {
|
||||
if (!vditorInstance.value) return
|
||||
vditorInstance.value.setValue('')
|
||||
text.value = ''
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
vditorInstance.value = createVditor(editorId.value, {
|
||||
placeholder: '输入消息...',
|
||||
height: 150,
|
||||
toolbar: [
|
||||
'emoji',
|
||||
'bold',
|
||||
'italic',
|
||||
'strike',
|
||||
'link',
|
||||
'|',
|
||||
'list',
|
||||
'|',
|
||||
'line',
|
||||
'quote',
|
||||
'code',
|
||||
'inline-code',
|
||||
'|',
|
||||
'upload',
|
||||
],
|
||||
preview: {
|
||||
actions: [],
|
||||
markdown: { toc: false },
|
||||
},
|
||||
input(value) {
|
||||
text.value = value
|
||||
},
|
||||
after() {
|
||||
if (props.loading || props.disabled) {
|
||||
vditorInstance.value.disabled()
|
||||
}
|
||||
applyTheme()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearVditorStorage()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(val) => {
|
||||
if (!vditorInstance.value) return
|
||||
if (val) {
|
||||
vditorInstance.value.disabled()
|
||||
} else if (!props.disabled) {
|
||||
vditorInstance.value.enable()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(val) => {
|
||||
if (!vditorInstance.value) return
|
||||
if (val) {
|
||||
vditorInstance.value.disabled()
|
||||
} else if (!props.loading) {
|
||||
vditorInstance.value.enable()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => themeState.mode,
|
||||
() => {
|
||||
applyTheme()
|
||||
},
|
||||
)
|
||||
|
||||
return { submit, isDisabled, editorId }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-editor-container {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.message-bottom-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
padding: 10px;
|
||||
background-color: var(--bg-color-soft);
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
.message-submit {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.message-submit.disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.message-submit:not(.disabled):hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
</style>
|
||||
74
frontend_nuxt/components/MessagePopup.vue
Normal file
74
frontend_nuxt/components/MessagePopup.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<BasePopup :visible="visible" @close="close">
|
||||
<div class="message-popup">
|
||||
<div class="message-popup-title">📨 站内信上线啦</div>
|
||||
<div class="message-popup-text">现在可以在右上角使用站内信功能</div>
|
||||
<div class="message-popup-actions">
|
||||
<div class="message-popup-close" @click="close">知道了</div>
|
||||
<div class="message-popup-button" @click="gotoMessage">去看看</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePopup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BasePopup from '~/components/BasePopup.vue'
|
||||
|
||||
defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const gotoMessage = () => {
|
||||
emit('close')
|
||||
navigateTo('/message-box', { replace: true })
|
||||
}
|
||||
const close = () => emit('close')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.message-popup-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.message-popup-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.message-popup-button {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-popup-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.message-popup-close {
|
||||
cursor: pointer;
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.message-popup-close:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
198
frontend_nuxt/components/SearchPersonDropdown.vue
Normal file
198
frontend_nuxt/components/SearchPersonDropdown.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="search-dropdown">
|
||||
<Dropdown
|
||||
ref="dropdown"
|
||||
v-model="selected"
|
||||
:fetch-options="fetchResults"
|
||||
remote
|
||||
menu-class="search-menu"
|
||||
option-class="search-option"
|
||||
:show-search="isMobile"
|
||||
@update:search="keyword = $event"
|
||||
@close="onClose"
|
||||
>
|
||||
<template #display="{ setSearch }">
|
||||
<div class="search-input">
|
||||
<i class="search-input-icon fas fa-search"></i>
|
||||
<input
|
||||
class="text-input"
|
||||
v-model="keyword"
|
||||
placeholder="Search users"
|
||||
@input="setSearch(keyword)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<div class="search-option-item">
|
||||
<img
|
||||
:src="option.avatar || '/default-avatar.svg'"
|
||||
class="avatar"
|
||||
@error="handleAvatarError"
|
||||
/>
|
||||
<div class="result-body">
|
||||
<div class="result-main" v-html="highlight(option.username)"></div>
|
||||
<div
|
||||
v-if="option.introduction"
|
||||
class="result-sub"
|
||||
v-html="highlight(option.introduction)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { stripMarkdown } from '~/utils/markdown'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { getToken } from '~/utils/auth'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const keyword = ref('')
|
||||
const selected = ref(null)
|
||||
const results = ref([])
|
||||
const dropdown = ref(null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const toggle = () => {
|
||||
dropdown.value.toggle()
|
||||
}
|
||||
|
||||
const onClose = () => emit('close')
|
||||
|
||||
const fetchResults = async (kw) => {
|
||||
if (!kw) return []
|
||||
const res = await fetch(`${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(kw)}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
results.value = data.map((u) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
introduction: u.introduction,
|
||||
}))
|
||||
return results.value
|
||||
}
|
||||
|
||||
const highlight = (text) => {
|
||||
text = stripMarkdown(text || '')
|
||||
if (!keyword.value) return text
|
||||
const reg = new RegExp(keyword.value, 'gi')
|
||||
return text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
|
||||
}
|
||||
|
||||
const handleAvatarError = (e) => {
|
||||
e.target.src = '/default-avatar.svg'
|
||||
}
|
||||
|
||||
watch(selected, async (val) => {
|
||||
if (!val) return
|
||||
const user = results.value.find((u) => u.id === val)
|
||||
if (!user) return
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
navigateTo('/login', { replace: true })
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/messages/conversations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ recipientId: user.id }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
navigateTo(`/message-box/${data.conversationId}`, { replace: true })
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
selected.value = null
|
||||
keyword.value = ''
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
toggle,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-dropdown {
|
||||
margin-top: 20px;
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
background-color: var(--app-menu-background-color);
|
||||
color: var(--text-color);
|
||||
border: none;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
margin-left: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.search-menu {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-dropdown {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.search-option-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
:deep(.highlight) {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.result-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.result-main {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.result-sub {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
@@ -63,7 +63,7 @@ const isImageIcon = (icon) => {
|
||||
}
|
||||
|
||||
const buildTagsUrl = (kw = '') => {
|
||||
const base = API_BASE_URL || (process.client ? window.location.origin : '')
|
||||
const base = API_BASE_URL || (import.meta.client ? window.location.origin : '')
|
||||
const url = new URL('/api/tags', base)
|
||||
|
||||
if (kw) url.searchParams.set('keyword', kw)
|
||||
|
||||
92
frontend_nuxt/composables/useChannelsUnreadCount.js
Normal file
92
frontend_nuxt/composables/useChannelsUnreadCount.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useWebSocket } from './useWebSocket'
|
||||
import { getToken } from '~/utils/auth'
|
||||
|
||||
const count = ref(0)
|
||||
let isInitialized = false
|
||||
let wsSubscription = null
|
||||
|
||||
export function useChannelsUnreadCount() {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const { subscribe, isConnected, connect } = useWebSocket()
|
||||
|
||||
const fetchChannelUnread = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
count.value = 0
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/channels/unread-count`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
count.value = data
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch channel unread count:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const initialize = () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
count.value = 0
|
||||
return
|
||||
}
|
||||
fetchChannelUnread()
|
||||
if (!isConnected.value) {
|
||||
connect(token)
|
||||
}
|
||||
setupWebSocketListener()
|
||||
}
|
||||
|
||||
const setupWebSocketListener = () => {
|
||||
if (!wsSubscription) {
|
||||
watch(
|
||||
isConnected,
|
||||
(newValue) => {
|
||||
if (newValue && !wsSubscription) {
|
||||
wsSubscription = subscribe('/user/queue/channel-unread', (message) => {
|
||||
const unread = parseInt(message.body, 10)
|
||||
if (!isNaN(unread)) {
|
||||
count.value = unread
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const setFromList = (channels) => {
|
||||
count.value = Array.isArray(channels) ? channels.filter((c) => c.unreadCount > 0).length : 0
|
||||
}
|
||||
|
||||
const hasUnread = computed(() => count.value > 0)
|
||||
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
if (!isInitialized) {
|
||||
isInitialized = true
|
||||
initialize()
|
||||
} else {
|
||||
fetchChannelUnread()
|
||||
if (!isConnected.value) {
|
||||
connect(token)
|
||||
}
|
||||
setupWebSocketListener()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
hasUnread,
|
||||
fetchChannelUnread,
|
||||
initialize,
|
||||
setFromList,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// 导出一个便捷的 toast 对象
|
||||
export const toast = {
|
||||
success: async (message) => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
try {
|
||||
const { useToast } = await import('vue-toastification')
|
||||
const toastInstance = useToast()
|
||||
@@ -12,7 +12,7 @@ export const toast = {
|
||||
}
|
||||
},
|
||||
error: async (message) => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
try {
|
||||
const { useToast } = await import('vue-toastification')
|
||||
const toastInstance = useToast()
|
||||
@@ -23,7 +23,7 @@ export const toast = {
|
||||
}
|
||||
},
|
||||
warning: async (message) => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
try {
|
||||
const { useToast } = await import('vue-toastification')
|
||||
const toastInstance = useToast()
|
||||
@@ -34,7 +34,7 @@ export const toast = {
|
||||
}
|
||||
},
|
||||
info: async (message) => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
try {
|
||||
const { useToast } = await import('vue-toastification')
|
||||
const toastInstance = useToast()
|
||||
@@ -48,7 +48,7 @@ export const toast = {
|
||||
|
||||
// 导出 useToast composable
|
||||
export const useToast = () => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
return new Promise(async (resolve) => {
|
||||
try {
|
||||
const { useToast: useVueToast } = await import('vue-toastification')
|
||||
|
||||
93
frontend_nuxt/composables/useUnreadCount.js
Normal file
93
frontend_nuxt/composables/useUnreadCount.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
86
frontend_nuxt/composables/useWebSocket.js
Normal file
86
frontend_nuxt/composables/useWebSocket.js
Normal file
@@ -0,0 +1,86 @@
|
||||
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}/api/sockjs`
|
||||
|
||||
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') ||
|
||||
destination.includes('/queue/channel-unread')
|
||||
) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
473
frontend_nuxt/package-lock.json
generated
473
frontend_nuxt/package-lock.json
generated
@@ -6,6 +6,7 @@
|
||||
"": {
|
||||
"name": "frontend_nuxt",
|
||||
"dependencies": {
|
||||
"@stomp/stompjs": "^7.0.0",
|
||||
"cropperjs": "^1.6.2",
|
||||
"echarts": "^5.6.0",
|
||||
"flatpickr": "^4.6.13",
|
||||
@@ -14,6 +15,7 @@
|
||||
"markdown-it": "^14.1.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"nuxt": "latest",
|
||||
"sockjs-client": "^1.6.1",
|
||||
"vditor": "^3.11.1",
|
||||
"vue-easy-lightbox": "^1.19.0",
|
||||
"vue-echarts": "^7.0.3",
|
||||
@@ -957,9 +959,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/busboy": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.1.1.tgz",
|
||||
"integrity": "sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz",
|
||||
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
@@ -1007,6 +1009,16 @@
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
@@ -1663,9 +1675,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@netlify/zip-it-and-ship-it/node_modules/@netlify/serverless-functions-api": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@netlify/serverless-functions-api/-/serverless-functions-api-2.1.3.tgz",
|
||||
"integrity": "sha512-bNlN/hpND8xFQzpjyKxm6vJayD+bPBlOvs4lWihE7WULrphuH1UuFsoVE5386bNNGH8Rs1IH01AFsl7ALQgOlQ==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@netlify/serverless-functions-api/-/serverless-functions-api-2.2.0.tgz",
|
||||
"integrity": "sha512-eQNnGUMyatgEeFJ8iKI2DT7wXDEjbWmZ+hJpCZtfg1bVsD4JdprIhLqdrUqmrDgPG2r45sQYigO9oq8BWXO37w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -3410,9 +3422,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz",
|
||||
"integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.3.tgz",
|
||||
"integrity": "sha512-UmTdvXnLlqQNOCJnyksjPs1G4GqXNGW1LrzCe8+8QoaLhhDeTXYBgJ3k6x61WIhlHX2U+VzEJ55TtIjR/HTySA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3423,9 +3435,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz",
|
||||
"integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.3.tgz",
|
||||
"integrity": "sha512-8NoxqLpXm7VyeI0ocidh335D6OKT0UJ6fHdnIxf3+6oOerZZc+O7r+UhvROji6OspyPm+rrIdb1gTXtVIqn+Sg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3436,9 +3448,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz",
|
||||
"integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.3.tgz",
|
||||
"integrity": "sha512-csnNavqZVs1+7/hUKtgjMECsNG2cdB8F7XBHP6FfQjqhjF8rzMzb3SLyy/1BG7YSfQ+bG75Ph7DyedbUqwq1rA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3449,9 +3461,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz",
|
||||
"integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.3.tgz",
|
||||
"integrity": "sha512-r2MXNjbuYabSIX5yQqnT8SGSQ26XQc8fmp6UhlYJd95PZJkQD1u82fWP7HqvGUf33IsOC6qsiV+vcuD4SDP6iw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3462,9 +3474,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz",
|
||||
"integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.3.tgz",
|
||||
"integrity": "sha512-uluObTmgPJDuJh9xqxyr7MV61Imq+0IvVsAlWyvxAaBSNzCcmZlhfYcRhCdMaCsy46ccZa7vtDDripgs9Jkqsw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3475,9 +3487,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz",
|
||||
"integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.3.tgz",
|
||||
"integrity": "sha512-AVJXEq9RVHQnejdbFvh1eWEoobohUYN3nqJIPI4mNTMpsyYN01VvcAClxflyk2HIxvLpRcRggpX1m9hkXkpC/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3488,9 +3500,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz",
|
||||
"integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.3.tgz",
|
||||
"integrity": "sha512-byyflM+huiwHlKi7VHLAYTKr67X199+V+mt1iRgJenAI594vcmGGddWlu6eHujmcdl6TqSNnvqaXJqZdnEWRGA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3501,9 +3513,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz",
|
||||
"integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.3.tgz",
|
||||
"integrity": "sha512-aLm3NMIjr4Y9LklrH5cu7yybBqoVCdr4Nvnm8WB7PKCn34fMCGypVNpGK0JQWdPAzR/FnoEoFtlRqZbBBLhVoQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3514,9 +3526,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz",
|
||||
"integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.3.tgz",
|
||||
"integrity": "sha512-VtilE6eznJRDIoFOzaagQodUksTEfLIsvXymS+UdJiSXrPW7Ai+WG4uapAc3F7Hgs791TwdGh4xyOzbuzIZrnw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3527,9 +3539,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz",
|
||||
"integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.3.tgz",
|
||||
"integrity": "sha512-dG3JuS6+cRAL0GQ925Vppafi0qwZnkHdPeuZIxIPXqkCLP02l7ka+OCyBoDEv8S+nKHxfjvjW4OZ7hTdHkx8/w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3540,9 +3552,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz",
|
||||
"integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.3.tgz",
|
||||
"integrity": "sha512-iU8DxnxEKJptf8Vcx4XvAUdpkZfaz0KWfRrnIRrOndL0SvzEte+MTM7nDH4A2Now4FvTZ01yFAgj6TX/mZl8hQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -3553,9 +3565,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz",
|
||||
"integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.3.tgz",
|
||||
"integrity": "sha512-VrQZp9tkk0yozJoQvQcqlWiqaPnLM6uY1qPYXvukKePb0fqaiQtOdMJSxNFUZFsGw5oA5vvVokjHrx8a9Qsz2A==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -3566,9 +3578,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz",
|
||||
"integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.3.tgz",
|
||||
"integrity": "sha512-uf2eucWSUb+M7b0poZ/08LsbcRgaDYL8NCGjUeFMwCWFwOuFcZ8D9ayPl25P3pl+D2FH45EbHdfyUesQ2Lt9wA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -3579,9 +3591,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz",
|
||||
"integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.3.tgz",
|
||||
"integrity": "sha512-7tnUcDvN8DHm/9ra+/nF7lLzYHDeODKKKrh6JmZejbh1FnCNZS8zMkZY5J4sEipy2OW1d1Ncc4gNHUd0DLqkSg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -3592,9 +3604,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz",
|
||||
"integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.3.tgz",
|
||||
"integrity": "sha512-MUpAOallJim8CsJK+4Lc9tQzlfPbHxWDrGXZm2z6biaadNpvh3a5ewcdat478W+tXDoUiHwErX/dOql7ETcLqg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -3605,9 +3617,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz",
|
||||
"integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.3.tgz",
|
||||
"integrity": "sha512-F42IgZI4JicE2vM2PWCe0N5mR5vR0gIdORPqhGQ32/u1S1v3kLtbZ0C/mi9FFk7C5T0PgdeyWEPajPjaUpyoKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3618,9 +3630,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz",
|
||||
"integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.3.tgz",
|
||||
"integrity": "sha512-oLc+JrwwvbimJUInzx56Q3ujL3Kkhxehg7O1gWAYzm8hImCd5ld1F2Gry5YDjR21MNb5WCKhC9hXgU7rRlyegQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3631,9 +3643,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz",
|
||||
"integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.3.tgz",
|
||||
"integrity": "sha512-lOrQ+BVRstruD1fkWg9yjmumhowR0oLAAzavB7yFSaGltY8klttmZtCLvOXCmGE9mLIn8IBV/IFrQOWz5xbFPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3644,9 +3656,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz",
|
||||
"integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.3.tgz",
|
||||
"integrity": "sha512-vvrVKPRS4GduGR7VMH8EylCBqsDcw6U+/0nPDuIjXQRbHJc6xOBj+frx8ksfZAh6+Fptw5wHrN7etlMmQnPQVg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -3657,9 +3669,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz",
|
||||
"integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.3.tgz",
|
||||
"integrity": "sha512-fi3cPxCnu3ZeM3EwKZPgXbWoGzm2XHgB/WShKI81uj8wG0+laobmqy5wbgEwzstlbLu4MyO8C19FyhhWseYKNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3699,6 +3711,12 @@
|
||||
"integrity": "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/@stomp/stompjs": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.1.1.tgz",
|
||||
"integrity": "sha512-chcDs6YkAnKp1FqzwhGvh3i7v0+/ytzqWdKYw6XzINEKAzke/iD00dNgFPWSZEqktHOK+C1gSzXhLkLbARIaZw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
|
||||
@@ -3723,9 +3741,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
|
||||
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
|
||||
"version": "24.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
|
||||
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -3767,13 +3785,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.39.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.1.tgz",
|
||||
"integrity": "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz",
|
||||
"integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.39.1",
|
||||
"@typescript-eslint/types": "^8.39.1",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.40.0",
|
||||
"@typescript-eslint/types": "^8.40.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3788,9 +3806,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.39.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz",
|
||||
"integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz",
|
||||
"integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3804,9 +3822,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.39.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz",
|
||||
"integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz",
|
||||
"integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3817,15 +3835,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.39.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz",
|
||||
"integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz",
|
||||
"integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.39.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.39.1",
|
||||
"@typescript-eslint/types": "8.39.1",
|
||||
"@typescript-eslint/visitor-keys": "8.39.1",
|
||||
"@typescript-eslint/project-service": "8.40.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.40.0",
|
||||
"@typescript-eslint/types": "8.40.0",
|
||||
"@typescript-eslint/visitor-keys": "8.40.0",
|
||||
"debug": "^4.3.4",
|
||||
"fast-glob": "^3.3.2",
|
||||
"is-glob": "^4.0.3",
|
||||
@@ -3845,12 +3863,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.39.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz",
|
||||
"integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz",
|
||||
"integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.39.1",
|
||||
"@typescript-eslint/types": "8.40.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4379,15 +4397,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-2.0.6.tgz",
|
||||
"integrity": "sha512-P3TxJSe31bUHBiblg59oU1PpaWPtmxF9GhJ/cB7OkgJ0qN/ifFSKUI25/v8ZhsT+lIG6ac8DpTOplXxORX6F3Q==",
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-2.0.7.tgz",
|
||||
"integrity": "sha512-wE7y3jmYeb0+h6mr5BOovuqhFv22O/MV9j5p0ndJsa7z1zJNPGQ4ph5pQk/kTTCWRC3xsA4SmtwmkzQO+7NCNg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz",
|
||||
"integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -4522,13 +4540,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ast-walker-scope": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.1.tgz",
|
||||
"integrity": "sha512-72XOdbzQCMKERvFrxAykatn2pu7osPNq/sNUzwcHdWzwPvOsNpPqkawfDXVvQbA2RT+ivtsMNjYdojTUZitt1A==",
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.2.tgz",
|
||||
"integrity": "sha512-3pYeLyDZ6nJew9QeBhS4Nly02269Dkdk32+zdbbKmL6n4ZuaGorwwA+xx12xgOciA8BF1w9x+dlH7oUkFTW91w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.27.2",
|
||||
"ast-kit": "^2.0.0"
|
||||
"@babel/parser": "^7.28.3",
|
||||
"ast-kit": "^2.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
@@ -4671,9 +4689,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.25.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz",
|
||||
"integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==",
|
||||
"version": "4.25.3",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz",
|
||||
"integrity": "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -4690,8 +4708,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001733",
|
||||
"electron-to-chromium": "^1.5.199",
|
||||
"caniuse-lite": "^1.0.30001735",
|
||||
"electron-to-chromium": "^1.5.204",
|
||||
"node-releases": "^2.0.19",
|
||||
"update-browserslist-db": "^1.1.3"
|
||||
},
|
||||
@@ -5946,9 +5964,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.202",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.202.tgz",
|
||||
"integrity": "sha512-NxbYjRmiHcHXV1Ws3fWUW+SLb62isauajk45LUJ/HgIOkUA7jLZu/X2Iif+X9FBNK8QkF9Zb4Q2mcwXCcY30mg==",
|
||||
"version": "1.5.206",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.206.tgz",
|
||||
"integrity": "sha512-/eucXSTaI8L78l42xPurxdBzPTjAkMVCQO7unZCWk9LnZiwKcSvQUhF4c99NWQLwMQXxjlfoQy0+8m9U2yEDQQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
@@ -6234,6 +6252,15 @@
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
|
||||
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
|
||||
@@ -6338,6 +6365,18 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/faye-websocket": {
|
||||
"version": "0.11.4",
|
||||
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
|
||||
"integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"websocket-driver": ">=0.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
@@ -6909,6 +6948,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/http-parser-js": {
|
||||
"version": "0.5.10",
|
||||
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
|
||||
"integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-shutdown": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/http-shutdown/-/http-shutdown-1.2.2.tgz",
|
||||
@@ -9574,9 +9619,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz",
|
||||
"integrity": "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==",
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -9589,6 +9634,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -9786,6 +9837,12 @@
|
||||
"integrity": "sha512-uuoJ1hU/k6M0779t3VMVIYpb2VMJk05cehCaABFhXaibcbvfgR8wKiozLjVFSzJPmQMRqIcO0HMyTFqfV09V6Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "2.0.0-next.5",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
||||
@@ -9829,9 +9886,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz",
|
||||
"integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.3.tgz",
|
||||
"integrity": "sha512-RZn2XTjXb8t5g13f5YclGoilU/kwT696DIkY3sywjdZidNSi3+vseaQov7D7BZXVJCPv3pDWUN69C78GGbXsKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
@@ -9844,26 +9901,26 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.46.2",
|
||||
"@rollup/rollup-android-arm64": "4.46.2",
|
||||
"@rollup/rollup-darwin-arm64": "4.46.2",
|
||||
"@rollup/rollup-darwin-x64": "4.46.2",
|
||||
"@rollup/rollup-freebsd-arm64": "4.46.2",
|
||||
"@rollup/rollup-freebsd-x64": "4.46.2",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.46.2",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.46.2",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.46.2",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.46.2",
|
||||
"@rollup/rollup-linux-loongarch64-gnu": "4.46.2",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.46.2",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.46.2",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.46.2",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.46.2",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.46.2",
|
||||
"@rollup/rollup-linux-x64-musl": "4.46.2",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.46.2",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.46.2",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.46.2",
|
||||
"@rollup/rollup-android-arm-eabi": "4.46.3",
|
||||
"@rollup/rollup-android-arm64": "4.46.3",
|
||||
"@rollup/rollup-darwin-arm64": "4.46.3",
|
||||
"@rollup/rollup-darwin-x64": "4.46.3",
|
||||
"@rollup/rollup-freebsd-arm64": "4.46.3",
|
||||
"@rollup/rollup-freebsd-x64": "4.46.3",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.46.3",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.46.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.46.3",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.46.3",
|
||||
"@rollup/rollup-linux-loongarch64-gnu": "4.46.3",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.46.3",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.46.3",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.46.3",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.46.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.46.3",
|
||||
"@rollup/rollup-linux-x64-musl": "4.46.3",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.46.3",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.46.3",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.46.3",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -10225,6 +10282,34 @@
|
||||
"integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sockjs-client": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz",
|
||||
"integrity": "sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^3.2.7",
|
||||
"eventsource": "^2.0.2",
|
||||
"faye-websocket": "^0.11.4",
|
||||
"inherits": "^2.0.4",
|
||||
"url-parse": "^1.5.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://tidelift.com/funding/github/npm/sockjs-client"
|
||||
}
|
||||
},
|
||||
"node_modules/sockjs-client/node_modules/debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.7.6",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
|
||||
@@ -10516,9 +10601,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.1.0.tgz",
|
||||
"integrity": "sha512-GBuewsPrhJPftT+fqDa9oI/zc5HNsG9nREqwzoSFDOIqf0NggOZbHQj2TE1P1CDJK8ZogFnlZY9hWoUiur7I/A==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.0.tgz",
|
||||
"integrity": "sha512-5eG9FQjEjDbAlI5+kdpdyPIBMRH4GfTVDGREVupaZHmVoppknhM29b/S9BkQz7cathp85BVgRi/As3Siln7e0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -10937,13 +11022,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.5.tgz",
|
||||
"integrity": "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==",
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.6.tgz",
|
||||
"integrity": "sha512-+/MdXl8bLTXI2lJF22gUBeCFqZruEpL/oM9f8wxCuKh9+Mw9qeul3gTqgbKpMeOFlusCzc0s7x2Kax2xKW+FQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.14.1",
|
||||
"picomatch": "^4.0.2",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"acorn": "^8.15.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -11134,59 +11220,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/unwasm": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/unwasm/-/unwasm-0.3.9.tgz",
|
||||
"integrity": "sha512-LDxTx/2DkFURUd+BU1vUsF/moj0JsoTvl+2tcg2AUOiEzVturhGGx17/IMgGvKUYdZwr33EJHtChCJuhu9Ouvg==",
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/unwasm/-/unwasm-0.3.11.tgz",
|
||||
"integrity": "sha512-Vhp5gb1tusSQw5of/g3Q697srYgMXvwMgXMjcG4ZNga02fDX9coxJ9fAb0Ci38hM2Hv/U1FXRPGgjP2BYqhNoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"knitwork": "^1.0.0",
|
||||
"magic-string": "^0.30.8",
|
||||
"mlly": "^1.6.1",
|
||||
"pathe": "^1.1.2",
|
||||
"pkg-types": "^1.0.3",
|
||||
"unplugin": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unwasm/node_modules/confbox": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unwasm/node_modules/pathe": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
|
||||
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unwasm/node_modules/pkg-types": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.1.8",
|
||||
"knitwork": "^1.2.0",
|
||||
"magic-string": "^0.30.17",
|
||||
"mlly": "^1.7.4",
|
||||
"pathe": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/unwasm/node_modules/pkg-types/node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unwasm/node_modules/unplugin": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz",
|
||||
"integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.14.0",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^2.2.0",
|
||||
"unplugin": "^2.3.6"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
@@ -11225,6 +11269,16 @@
|
||||
"integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/url-parse": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/urlpattern-polyfill": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz",
|
||||
@@ -11273,13 +11327,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz",
|
||||
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==",
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",
|
||||
"integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.6",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
"rollup": "^4.43.0",
|
||||
@@ -11737,6 +11791,29 @@
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/websocket-driver": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
|
||||
"integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"http-parser-js": ">=0.5.1",
|
||||
"safe-buffer": ">=5.1.0",
|
||||
"websocket-extensions": ">=0.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/websocket-extensions": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz",
|
||||
"integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,9 +98,7 @@ export default {
|
||||
}
|
||||
|
||||
.about-tabs {
|
||||
position: sticky;
|
||||
top: calc(var(--header-height) + 1px);
|
||||
z-index: 200;
|
||||
background-color: var(--background-color-blur);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="site-stats-page">
|
||||
<div v-if="isLoading" class="loading-message">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<VChart
|
||||
v-if="dauOption"
|
||||
@@ -51,8 +54,10 @@ const dauOption = ref(null)
|
||||
const newUserOption = ref(null)
|
||||
const postOption = ref(null)
|
||||
const commentOption = ref(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
const token = getToken()
|
||||
const headers = { Authorization: `Bearer ${token}` }
|
||||
|
||||
@@ -93,6 +98,7 @@ async function loadData() {
|
||||
const data = await commentRes.json()
|
||||
commentOption.value = toOption('每日回贴量', data)
|
||||
}
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
@@ -105,4 +111,11 @@ onMounted(loadData)
|
||||
background-color: var(--background-color);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 400px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
<div class="article-container">
|
||||
<template
|
||||
v-if="
|
||||
selectedTopic === '最新' || selectedTopic === '排行榜' || selectedTopic === '最新回复'
|
||||
selectedTopic === '最新' ||
|
||||
selectedTopic === '排行榜' ||
|
||||
selectedTopic === '最新回复' ||
|
||||
selectedTopic === '精选'
|
||||
"
|
||||
>
|
||||
<div class="article-header-container">
|
||||
@@ -152,17 +155,22 @@ const route = useRoute()
|
||||
const tagOptions = ref([])
|
||||
const categoryOptions = ref([])
|
||||
|
||||
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
||||
const topics = ref(['最新回复', '最新', '精选', '排行榜' /*, '热门', '类别'*/])
|
||||
const selectedTopicCookie = useCookie('homeTab')
|
||||
const selectedTopic = ref(
|
||||
selectedTopicCookie.value
|
||||
? selectedTopicCookie.value
|
||||
: route.query.view === 'ranking'
|
||||
? '排行榜'
|
||||
: route.query.view === 'latest'
|
||||
? '最新'
|
||||
: '最新回复',
|
||||
)
|
||||
|
||||
let defaultTopic = '最新回复'
|
||||
|
||||
if (selectedTopicCookie.value) {
|
||||
defaultTopic = selectedTopicCookie.value
|
||||
} else if (route.query.view === 'ranking') {
|
||||
defaultTopic = '排行榜'
|
||||
} else if (route.query.view === 'latest') {
|
||||
defaultTopic = '最新'
|
||||
} else if (route.query.view === 'featured') {
|
||||
defaultTopic = '精选'
|
||||
}
|
||||
const selectedTopic = ref(defaultTopic)
|
||||
|
||||
if (!selectedTopicCookie.value) selectedTopicCookie.value = selectedTopic.value
|
||||
const articles = ref([])
|
||||
const page = ref(0)
|
||||
@@ -236,6 +244,7 @@ const baseQuery = computed(() => ({
|
||||
const listApiPath = computed(() => {
|
||||
if (selectedTopic.value === '排行榜') return '/api/posts/ranking'
|
||||
if (selectedTopic.value === '最新回复') return '/api/posts/latest-reply'
|
||||
if (selectedTopic.value === '精选') return '/api/posts/featured'
|
||||
return '/api/posts'
|
||||
})
|
||||
const buildUrl = ({ pageNo }) => {
|
||||
@@ -338,7 +347,7 @@ watch([selectedCategory, selectedTags], () => {
|
||||
watch(selectedTopic, (val) => {
|
||||
loadOptions()
|
||||
selectedTopicCookie.value = val
|
||||
if (process.client) localStorage.setItem('homeTab', val)
|
||||
if (import.meta.client) localStorage.setItem('homeTab', val)
|
||||
})
|
||||
|
||||
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
|
||||
@@ -440,7 +449,7 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
}
|
||||
|
||||
.topic-item {
|
||||
padding: 2px 10px;
|
||||
padding: 6px 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
527
frontend_nuxt/pages/message-box/[id].vue
Normal file
527
frontend_nuxt/pages/message-box/[id].vue
Normal file
@@ -0,0 +1,527 @@
|
||||
<template>
|
||||
<div class="chat-container">
|
||||
<div v-if="!loading" class="chat-header">
|
||||
<NuxtLink to="/message-box" class="back-button">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</NuxtLink>
|
||||
<h2 class="participant-name">
|
||||
{{ isChannel ? conversationName : otherParticipant?.username }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="messages-list" ref="messagesListEl">
|
||||
<div v-if="loading" class="loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div v-else-if="error" class="error-container">{{ error }}</div>
|
||||
<template v-else>
|
||||
<div class="load-more-container" v-if="hasMoreMessages">
|
||||
<div @click="loadMoreMessages" :disabled="loadingMore" class="load-more-button">
|
||||
{{ loadingMore ? '加载中...' : '查看更多消息' }}
|
||||
</div>
|
||||
</div>
|
||||
<BaseTimeline :items="messages">
|
||||
<template #item="{ item }">
|
||||
<div class="message-header">
|
||||
<div class="user-name">
|
||||
{{ item.sender.username }}
|
||||
</div>
|
||||
<div class="message-timestamp">
|
||||
{{ TimeManager.format(item.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="info-content-text" v-html="renderMarkdown(item.content)"></div>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
<div class="empty-container">
|
||||
<BasePlaceholder
|
||||
v-if="messages.length === 0"
|
||||
text="暂无会话,发送消息试试 🎉"
|
||||
icon="fas fa-inbox"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="message-input-area">
|
||||
<MessageEditor :loading="sending" @submit="sendMessage" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
nextTick,
|
||||
computed,
|
||||
watch,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
} from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getToken, fetchCurrentUser } from '~/utils/auth'
|
||||
import { toast } from '~/main'
|
||||
import { renderMarkdown } from '~/utils/markdown'
|
||||
import MessageEditor from '~/components/MessageEditor.vue'
|
||||
import { useWebSocket } from '~/composables/useWebSocket'
|
||||
import { useUnreadCount } from '~/composables/useUnreadCount'
|
||||
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
|
||||
import TimeManager from '~/utils/time'
|
||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const { connect, disconnect, subscribe, isConnected } = useWebSocket()
|
||||
const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount()
|
||||
const { fetchChannelUnread: refreshChannelUnread } = useChannelsUnreadCount()
|
||||
let subscription = null
|
||||
|
||||
const messages = ref([])
|
||||
const participants = ref([])
|
||||
const loading = ref(true)
|
||||
const sending = ref(false)
|
||||
const error = ref(null)
|
||||
const conversationId = route.params.id
|
||||
const currentUser = ref(null)
|
||||
const messagesListEl = ref(null)
|
||||
let lastMessageEl = null
|
||||
const currentPage = ref(0)
|
||||
const totalPages = ref(0)
|
||||
const loadingMore = ref(false)
|
||||
let scrollInterval = null
|
||||
const conversationName = ref('')
|
||||
const isChannel = ref(false)
|
||||
|
||||
const hasMoreMessages = computed(() => currentPage.value < totalPages.value - 1)
|
||||
|
||||
const otherParticipant = computed(() => {
|
||||
if (isChannel.value || !currentUser.value || participants.value.length === 0) {
|
||||
return null
|
||||
}
|
||||
return participants.value.find((p) => p.id !== currentUser.value.id)
|
||||
})
|
||||
|
||||
function isSentByCurrentUser(message) {
|
||||
return message.sender.id === currentUser.value?.id
|
||||
}
|
||||
|
||||
function handleAvatarError(event) {
|
||||
event.target.src = '/default-avatar.svg'
|
||||
}
|
||||
|
||||
// No changes needed here, as renderMarkdown is now imported.
|
||||
// The old function is removed.
|
||||
|
||||
async function fetchMessages(page = 0) {
|
||||
if (page === 0) {
|
||||
loading.value = true
|
||||
messages.value = []
|
||||
} else {
|
||||
loadingMore.value = true
|
||||
}
|
||||
error.value = null
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/messages/conversations/${conversationId}?page=${page}&size=20`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
)
|
||||
if (!response.ok) throw new Error('无法加载消息')
|
||||
|
||||
const conversationData = await response.json()
|
||||
const pageData = conversationData.messages
|
||||
|
||||
if (page === 0) {
|
||||
participants.value = conversationData.participants
|
||||
conversationName.value = conversationData.name
|
||||
isChannel.value = conversationData.channel
|
||||
}
|
||||
|
||||
// Since the backend sorts by descending, we need to reverse for correct chat order
|
||||
const newMessages = pageData.content.reverse().map((item) => ({
|
||||
...item,
|
||||
src: item.sender.avatar,
|
||||
iconClick: () => {
|
||||
navigateTo(`/users/${item.sender.id}`, { replace: true })
|
||||
},
|
||||
}))
|
||||
|
||||
const list = messagesListEl.value
|
||||
const oldScrollHeight = list ? list.scrollHeight : 0
|
||||
|
||||
if (page === 0) {
|
||||
messages.value = newMessages
|
||||
} else {
|
||||
messages.value = [...newMessages, ...messages.value]
|
||||
}
|
||||
|
||||
currentPage.value = pageData.number
|
||||
totalPages.value = pageData.totalPages
|
||||
|
||||
// Scrolling is now fully handled by the watcher
|
||||
await nextTick()
|
||||
if (page > 0 && list) {
|
||||
const newScrollHeight = list.scrollHeight
|
||||
list.scrollTop = newScrollHeight - oldScrollHeight
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
toast.error(e.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreMessages() {
|
||||
if (hasMoreMessages.value && !loadingMore.value) {
|
||||
await fetchMessages(currentPage.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(content, clearInput) {
|
||||
if (!content.trim()) return
|
||||
sending.value = true
|
||||
const token = getToken()
|
||||
try {
|
||||
let response
|
||||
if (isChannel.value) {
|
||||
response = await fetch(
|
||||
`${API_BASE_URL}/api/messages/conversations/${conversationId}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
const recipient = otherParticipant.value
|
||||
if (!recipient) {
|
||||
toast.error('无法确定收信人')
|
||||
return
|
||||
}
|
||||
response = await fetch(`${API_BASE_URL}/api/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipientId: recipient.id,
|
||||
content: content,
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (!response.ok) throw new Error('发送失败')
|
||||
|
||||
const newMessage = await response.json()
|
||||
messages.value.push({
|
||||
...newMessage,
|
||||
src: newMessage.sender.avatar,
|
||||
iconClick: () => {
|
||||
navigateTo(`/users/${newMessage.sender.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
clearInput()
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 100)
|
||||
} catch (e) {
|
||||
toast.error(e.message)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markConversationAsRead() {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
try {
|
||||
await fetch(`${API_BASE_URL}/api/messages/conversations/${conversationId}/read`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
// After marking as read, refresh the global unread count
|
||||
refreshGlobalUnreadCount()
|
||||
refreshChannelUnread()
|
||||
} catch (e) {
|
||||
console.error('Failed to mark conversation as read', e)
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (messagesListEl.value) {
|
||||
const element = messagesListEl.value
|
||||
// 強制滾動到底部,使用 smooth 行為確保視覺效果
|
||||
element.scrollTop = element.scrollHeight
|
||||
|
||||
// 再次確認滾動位置
|
||||
setTimeout(() => {
|
||||
if (element.scrollTop < element.scrollHeight - element.clientHeight) {
|
||||
element.scrollTop = element.scrollHeight
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
messages,
|
||||
async (newMessages) => {
|
||||
if (newMessages.length === 0) return
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Simple, reliable scroll to bottom
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 100)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
currentUser.value = await fetchCurrentUser()
|
||||
if (currentUser.value) {
|
||||
await fetchMessages(0)
|
||||
await markConversationAsRead()
|
||||
const token = getToken()
|
||||
if (token && !isConnected.value) {
|
||||
connect(token)
|
||||
}
|
||||
} else {
|
||||
toast.error('请先登录')
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(isConnected, (newValue) => {
|
||||
if (newValue) {
|
||||
// 等待一小段时间确保连接稳定
|
||||
setTimeout(() => {
|
||||
subscription = subscribe(`/topic/conversation/${conversationId}`, (message) => {
|
||||
// 避免重复显示当前用户发送的消息
|
||||
if (message.sender.id !== currentUser.value.id) {
|
||||
messages.value.push({
|
||||
...message,
|
||||
src: message.sender.avatar,
|
||||
iconClick: () => {
|
||||
navigateTo(`/users/${message.sender.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
// 实时收到消息时自动标记为已读
|
||||
markConversationAsRead()
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
// This will be called every time the component is activated (navigated to)
|
||||
if (currentUser.value) {
|
||||
await fetchMessages(0)
|
||||
await markConversationAsRead()
|
||||
|
||||
// 確保滾動到底部 - 使用多重延遲策略
|
||||
await nextTick()
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 100)
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 300)
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 500)
|
||||
|
||||
if (!isConnected.value) {
|
||||
const token = getToken()
|
||||
if (token) connect(token)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe()
|
||||
subscription = null
|
||||
}
|
||||
disconnect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe()
|
||||
subscription = null
|
||||
}
|
||||
disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 auto;
|
||||
overflow: auto;
|
||||
height: calc(100vh - var(--header-height));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
background-color: var(--background-color-blur);
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
font-size: 18px;
|
||||
color: var(--text-color-primary);
|
||||
margin-right: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.messages-list {
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
padding-bottom: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.load-more-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.load-more-button {
|
||||
color: var(--primary-color);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.load-more-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message-timestamp {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.message-item.sent {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-item.sent .message-timestamp {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Received messages */
|
||||
.message-item.received {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.message-item.received .message-content {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-item.received .message-bubble {
|
||||
background-color: var(--bg-color-soft);
|
||||
border: 1px solid var(--border-color);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message-input-area {
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.messages-list {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-input-area {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
</style>
|
||||
476
frontend_nuxt/pages/message-box/index.vue
Normal file
476
frontend_nuxt/pages/message-box/index.vue
Normal file
@@ -0,0 +1,476 @@
|
||||
<template>
|
||||
<div class="messages-container">
|
||||
<div class="tabs">
|
||||
<div :class="['tab', { active: activeTab === 'messages' }]" @click="activeTab = 'messages'">
|
||||
站内信
|
||||
</div>
|
||||
<div :class="['tab', { active: activeTab === 'channels' }]" @click="switchToChannels">
|
||||
频道
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'messages'">
|
||||
<div v-if="loading" class="loading-message">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-container">
|
||||
<div class="error-text">{{ error }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading" class="search-container">
|
||||
<SearchPersonDropdown />
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && conversations.length === 0" class="empty-container">
|
||||
<BasePlaceholder v-if="conversations.length === 0" text="暂无会话" icon="fas fa-inbox" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!loading"
|
||||
v-for="convo in conversations"
|
||||
:key="convo.id"
|
||||
class="conversation-item"
|
||||
@click="goToConversation(convo.id)"
|
||||
>
|
||||
<div class="conversation-avatar">
|
||||
<img
|
||||
:src="getOtherParticipant(convo)?.avatar || '/default-avatar.svg'"
|
||||
:alt="getOtherParticipant(convo)?.username || '用户'"
|
||||
class="avatar-img"
|
||||
@error="handleAvatarError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="conversation-content">
|
||||
<div class="conversation-header">
|
||||
<div class="participant-name">
|
||||
{{ getOtherParticipant(convo)?.username || '未知用户' }}
|
||||
</div>
|
||||
<div class="message-time">
|
||||
{{ formatTime(convo.lastMessage?.createdAt || convo.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="last-message-row">
|
||||
<div class="last-message">
|
||||
{{
|
||||
convo.lastMessage ? stripMarkdownLength(convo.lastMessage.content, 100) : '暂无消息'
|
||||
}}
|
||||
</div>
|
||||
<div v-if="convo.unreadCount > 0" class="unread-count-badge">
|
||||
{{ convo.unreadCount }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="loadingChannels" class="loading-message">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="channels.length === 0" class="empty-container">
|
||||
<BasePlaceholder text="暂无频道" icon="fas fa-inbox" />
|
||||
</div>
|
||||
<div
|
||||
v-for="ch in channels"
|
||||
:key="ch.id"
|
||||
class="conversation-item"
|
||||
@click="goToChannel(ch.id)"
|
||||
>
|
||||
<div class="conversation-avatar">
|
||||
<img
|
||||
:src="ch.avatar || '/default-avatar.svg'"
|
||||
:alt="ch.name"
|
||||
class="avatar-img"
|
||||
@error="handleAvatarError"
|
||||
/>
|
||||
</div>
|
||||
<div class="conversation-content">
|
||||
<div class="conversation-header">
|
||||
<div class="participant-name">
|
||||
{{ ch.name }}
|
||||
<span v-if="ch.unreadCount > 0" class="unread-dot"></span>
|
||||
</div>
|
||||
<div class="message-time">
|
||||
{{ formatTime(ch.lastMessage?.createdAt || ch.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="last-message-row">
|
||||
<div class="last-message">
|
||||
{{
|
||||
ch.lastMessage ? stripMarkdownLength(ch.lastMessage.content, 100) : ch.description
|
||||
}}
|
||||
</div>
|
||||
<div class="member-count">成员 {{ ch.memberCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onUnmounted, watch, onActivated } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getToken, fetchCurrentUser } from '~/utils/auth'
|
||||
import { toast } from '~/main'
|
||||
import { useWebSocket } from '~/composables/useWebSocket'
|
||||
import { useUnreadCount } from '~/composables/useUnreadCount'
|
||||
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
|
||||
import TimeManager from '~/utils/time'
|
||||
import { stripMarkdownLength } from '~/utils/markdown'
|
||||
import SearchPersonDropdown from '~/components/SearchPersonDropdown.vue'
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const conversations = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const router = useRouter()
|
||||
const currentUser = ref(null)
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const { connect, disconnect, subscribe, isConnected } = useWebSocket()
|
||||
const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount()
|
||||
const { fetchChannelUnread: refreshChannelUnread, setFromList: setChannelUnreadFromList } =
|
||||
useChannelsUnreadCount()
|
||||
let subscription = null
|
||||
|
||||
const activeTab = ref('messages')
|
||||
const channels = ref([])
|
||||
const loadingChannels = ref(false)
|
||||
|
||||
async function fetchConversations() {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/messages/conversations`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
conversations.value = data
|
||||
} catch (e) {
|
||||
error.value = '无法加载会话列表。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取对话中的另一个参与者(非当前用户)
|
||||
function getOtherParticipant(conversation) {
|
||||
if (!currentUser.value || !conversation.participants) return null
|
||||
return conversation.participants.find((p) => p.id !== currentUser.value.id)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(timeString) {
|
||||
if (!timeString) return ''
|
||||
return TimeManager.format(timeString)
|
||||
}
|
||||
|
||||
// 头像加载失败处理
|
||||
function handleAvatarError(event) {
|
||||
event.target.src = '/default-avatar.svg'
|
||||
}
|
||||
|
||||
async function fetchChannels() {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
loadingChannels.value = true
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/channels`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!response.ok) throw new Error('无法加载频道')
|
||||
const data = await response.json()
|
||||
channels.value = data
|
||||
setChannelUnreadFromList(data)
|
||||
} catch (e) {
|
||||
toast.error(e.message)
|
||||
} finally {
|
||||
loadingChannels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function switchToChannels() {
|
||||
activeTab.value = 'channels'
|
||||
if (channels.value.length === 0) {
|
||||
fetchChannels()
|
||||
}
|
||||
}
|
||||
|
||||
async function goToChannel(id) {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fetch(`${API_BASE_URL}/api/channels/${id}/join`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
router.push(`/message-box/${id}`)
|
||||
} catch (e) {
|
||||
toast.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
onActivated(async () => {
|
||||
loading.value = true
|
||||
currentUser.value = await fetchCurrentUser()
|
||||
|
||||
if (currentUser.value) {
|
||||
await fetchConversations()
|
||||
refreshGlobalUnreadCount() // Refresh global count when entering the list
|
||||
refreshChannelUnread()
|
||||
const token = getToken()
|
||||
if (token && !isConnected.value) {
|
||||
connect(token)
|
||||
}
|
||||
} else {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(isConnected, (newValue) => {
|
||||
if (newValue && currentUser.value) {
|
||||
const destination = `/topic/user/${currentUser.value.id}/messages`
|
||||
|
||||
// 清理旧的订阅
|
||||
if (subscription) {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
|
||||
subscription = subscribe(destination, (message) => {
|
||||
fetchConversations()
|
||||
if (activeTab.value === 'channels') {
|
||||
fetchChannels()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
disconnect()
|
||||
})
|
||||
|
||||
function goToConversation(id) {
|
||||
router.push(`/message-box/${id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.messages-container {
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-bottom: 24px;
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.messages-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.messages-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.error-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.error-text,
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
.conversation-avatar {
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.conversation-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.member-count {
|
||||
font-size: 12px;
|
||||
color: gray;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: gray;
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.last-message-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.last-message {
|
||||
font-size: 14px;
|
||||
color: gray;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-grow: 1;
|
||||
padding-right: 10px; /* Add some space between message and badge */
|
||||
}
|
||||
|
||||
.unread-count-badge {
|
||||
background-color: #f56c6c;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
line-height: 1.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.unread-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #f56c6c;
|
||||
border-radius: 50%;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.conversation-item {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.messages-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.last-message {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -33,15 +33,13 @@
|
||||
<div v-if="selectedTab === 'control'">
|
||||
<div class="message-control-container">
|
||||
<div class="message-control-title">通知设置</div>
|
||||
<div class="message-control-push-item-container">
|
||||
<div
|
||||
v-for="pref in notificationPrefs"
|
||||
:key="pref.type"
|
||||
class="message-control-push-item"
|
||||
:class="{ select: pref.enabled }"
|
||||
@click="togglePref(pref)"
|
||||
>
|
||||
{{ formatType(pref.type) }}
|
||||
<div class="message-control-item-container">
|
||||
<div v-for="pref in notificationPrefs" :key="pref.type" class="message-control-item">
|
||||
<div class="message-control-item-label">{{ formatType(pref.type) }}</div>
|
||||
<BaseSwitch
|
||||
:model-value="pref.enabled"
|
||||
@update:modelValue="(val) => togglePref(pref, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -495,6 +493,37 @@
|
||||
已被管理员拒绝
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_FEATURED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您的文章
|
||||
<NuxtLink
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
被收录为精选
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_DELETED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
管理员
|
||||
<template v-if="item.fromUser">
|
||||
<NuxtLink
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.fromUser.id}`"
|
||||
>
|
||||
{{ item.fromUser.username }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
删除了您的帖子
|
||||
<span class="notif-content-text">
|
||||
{{ stripMarkdownLength(item.content, 100) }}
|
||||
</span>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
{{ formatType(item.type) }}
|
||||
@@ -524,7 +553,7 @@ import {
|
||||
fetchNotifications,
|
||||
fetchUnreadCount,
|
||||
isLoadingMessage,
|
||||
markRead,
|
||||
markNotificationRead,
|
||||
notifications,
|
||||
markAllRead,
|
||||
hasMore,
|
||||
@@ -532,6 +561,7 @@ import {
|
||||
updateNotificationPreference,
|
||||
} from '~/utils/notification'
|
||||
import TimeManager from '~/utils/time'
|
||||
import BaseSwitch from '~/components/BaseSwitch.vue'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
@@ -564,10 +594,10 @@ const fetchPrefs = async () => {
|
||||
notificationPrefs.value = await fetchNotificationPreferences()
|
||||
}
|
||||
|
||||
const togglePref = async (pref) => {
|
||||
const ok = await updateNotificationPreference(pref.type, !pref.enabled)
|
||||
const togglePref = async (pref, value) => {
|
||||
const ok = await updateNotificationPreference(pref.type, value)
|
||||
if (ok) {
|
||||
pref.enabled = !pref.enabled
|
||||
pref.enabled = value
|
||||
await fetchNotifications({
|
||||
page: page.value,
|
||||
size: pageSize,
|
||||
@@ -579,6 +609,14 @@ const togglePref = async (pref) => {
|
||||
}
|
||||
}
|
||||
|
||||
const markRead = async (id) => {
|
||||
markNotificationRead(id)
|
||||
if (selectedTab.value === 'unread') {
|
||||
const index = notifications.value.findIndex((n) => n.id === id)
|
||||
if (index !== -1) notifications.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const approve = async (id, nid) => {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
@@ -647,6 +685,10 @@ const formatType = (t) => {
|
||||
return '抽奖中奖了'
|
||||
case 'LOTTERY_DRAW':
|
||||
return '抽奖已开奖'
|
||||
case 'POST_DELETED':
|
||||
return '帖子被删除'
|
||||
case 'POST_FEATURED':
|
||||
return '文章被精选'
|
||||
default:
|
||||
return t
|
||||
}
|
||||
@@ -682,6 +724,7 @@ onActivated(async () => {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
backdrop-filter: var(--blur-10);
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.message-page-header-right {
|
||||
@@ -795,7 +838,6 @@ onActivated(async () => {
|
||||
.message-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.message-tab-item {
|
||||
@@ -818,26 +860,21 @@ onActivated(async () => {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.message-control-push-item-container {
|
||||
.message-control-item-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message-control-push-item {
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
.message-control-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.message-control-push-item.select {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
.message-control-item-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="post-clear" @click="clearPost"><i class="fa-solid fa-eraser"></i> 清空</div>
|
||||
<div class="ai-generate" @click="aiGenerate">
|
||||
<i class="fa-solid fa-robot"></i>
|
||||
md格式优化
|
||||
MD 格式优化
|
||||
</div>
|
||||
<div class="post-draft" @click="saveDraft">
|
||||
<i class="fa-solid fa-floppy-disk"></i>
|
||||
|
||||
@@ -1,62 +1,181 @@
|
||||
<template>
|
||||
<div class="point-mall-page">
|
||||
<section class="rules">
|
||||
<div class="section-title">🎉 积分规则</div>
|
||||
<div class="section-content">
|
||||
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
|
||||
<div class="point-tabs">
|
||||
<div
|
||||
:class="['point-tab-item', { selected: selectedTab === 'mall' }]"
|
||||
@click="selectedTab = 'mall'"
|
||||
>
|
||||
积分兑换
|
||||
</div>
|
||||
<div
|
||||
:class="['point-tab-item', { selected: selectedTab === 'history' }]"
|
||||
@click="selectedTab = 'history'"
|
||||
>
|
||||
积分历史
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="loading-points-container" v-if="isLoading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
|
||||
<div class="point-info">
|
||||
<p v-if="authState.loggedIn && point !== null">
|
||||
<span><i class="fas fa-coins coin-icon"></i></span>我的积分:<span class="point-value">{{
|
||||
point
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="selectedTab === 'mall'">
|
||||
<div class="point-mall-page-content">
|
||||
<section class="rules">
|
||||
<div class="section-title">🎉 积分规则</div>
|
||||
<div class="section-content">
|
||||
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="goods">
|
||||
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
|
||||
<img class="goods-item-image" :src="good.image" alt="good.name" />
|
||||
<div class="goods-item-name">{{ good.name }}</div>
|
||||
<div class="goods-item-cost">
|
||||
<i class="fas fa-coins"></i>
|
||||
{{ good.cost }} 积分
|
||||
<div class="loading-points-container" v-if="isLoading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div
|
||||
class="goods-item-button"
|
||||
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
|
||||
@click="openRedeem(good)"
|
||||
>
|
||||
兑换
|
||||
|
||||
<div class="point-info">
|
||||
<p v-if="authState.loggedIn && point !== null">
|
||||
<span><i class="fas fa-coins coin-icon"></i></span>我的积分:<span
|
||||
class="point-value"
|
||||
>{{ point }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="goods">
|
||||
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
|
||||
<img class="goods-item-image" :src="good.image" alt="good.name" />
|
||||
<div class="goods-item-name">{{ good.name }}</div>
|
||||
<div class="goods-item-cost">
|
||||
<i class="fas fa-coins"></i>
|
||||
{{ good.cost }} 积分
|
||||
</div>
|
||||
<div
|
||||
class="goods-item-button"
|
||||
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
|
||||
@click="openRedeem(good)"
|
||||
>
|
||||
兑换
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<RedeemPopup
|
||||
:visible="dialogVisible"
|
||||
v-model="contact"
|
||||
:loading="loading"
|
||||
@close="closeRedeem"
|
||||
@submit="submitRedeem"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<RedeemPopup
|
||||
:visible="dialogVisible"
|
||||
v-model="contact"
|
||||
:loading="loading"
|
||||
@close="closeRedeem"
|
||||
@submit="submitRedeem"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="loading-points-container" v-if="historyLoading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<BasePlaceholder v-else-if="histories.length === 0" text="暂无积分记录" icon="fas fa-inbox" />
|
||||
<div class="timeline-container" v-else>
|
||||
<BaseTimeline :items="histories">
|
||||
<template #item="{ item }">
|
||||
<div class="history-content">
|
||||
<template v-if="item.type === 'POST'">
|
||||
发送帖子
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
,获得{{ item.amount }}积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'COMMENT'">
|
||||
在文章
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
中
|
||||
<template v-if="!item.fromUserId">
|
||||
发送评论
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
||||
class="timeline-link"
|
||||
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
||||
>
|
||||
,获得{{ item.amount }}积分
|
||||
</template>
|
||||
<template v-else>
|
||||
被评论
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
||||
class="timeline-link"
|
||||
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
||||
>
|
||||
,获得{{ item.amount }}积分
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_LIKED' && item.fromUserId">
|
||||
帖子
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
被
|
||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||
item.fromUserName
|
||||
}}</NuxtLink>
|
||||
按赞,获得{{ item.amount }}积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'COMMENT_LIKED' && item.fromUserId">
|
||||
评论
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
||||
class="timeline-link"
|
||||
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
||||
>
|
||||
被
|
||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||
item.fromUserName
|
||||
}}</NuxtLink>
|
||||
按赞,获得{{ item.amount }}积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'INVITE' && item.fromUserId">
|
||||
邀请了好友
|
||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||
item.fromUserName
|
||||
}}</NuxtLink>
|
||||
加入社区 🎉,获得 {{ item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'FEATURE'">
|
||||
文章
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
被收录为精选,获得 {{ item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'REDEEM'">
|
||||
兑换商品,消耗 {{ -item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
|
||||
<i class="fas fa-coins"></i> 你目前的积分是 {{ item.balance }}
|
||||
</div>
|
||||
<div class="history-time">{{ TimeManager.format(item.createdAt) }}</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { authState, fetchCurrentUser, getToken } from '~/utils/auth'
|
||||
import { toast } from '~/main'
|
||||
import RedeemPopup from '~/components/RedeemPopup.vue'
|
||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
import { stripMarkdownLength } from '~/utils/markdown'
|
||||
import TimeManager from '~/utils/time'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const selectedTab = ref('mall')
|
||||
const point = ref(null)
|
||||
const isLoading = ref(false)
|
||||
const histories = ref([])
|
||||
const historyLoading = ref(false)
|
||||
const historyLoaded = ref(false)
|
||||
|
||||
const pointRules = [
|
||||
'发帖:每天前两次,每次 30 积分',
|
||||
@@ -64,6 +183,7 @@ const pointRules = [
|
||||
'帖子被点赞:每次 10 积分',
|
||||
'评论被点赞:每次 10 积分',
|
||||
'邀请好友加入可获得 500 积分/次,注意需要使用邀请链接注册',
|
||||
'文章被收录至精选:每次 500 积分',
|
||||
]
|
||||
|
||||
const goods = ref([])
|
||||
@@ -72,6 +192,17 @@ const contact = ref('')
|
||||
const loading = ref(false)
|
||||
const selectedGood = ref(null)
|
||||
|
||||
const iconMap = {
|
||||
POST: 'fas fa-file-alt',
|
||||
COMMENT: 'fas fa-comment',
|
||||
POST_LIKED: 'fas fa-thumbs-up',
|
||||
COMMENT_LIKED: 'fas fa-thumbs-up',
|
||||
INVITE: 'fas fa-user-plus',
|
||||
SYSTEM_ONLINE: 'fas fa-clock',
|
||||
REDEEM: 'fas fa-gift',
|
||||
FEATURE: 'fas fa-star',
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
isLoading.value = true
|
||||
if (authState.loggedIn) {
|
||||
@@ -82,6 +213,12 @@ onMounted(async () => {
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
watch(selectedTab, (val) => {
|
||||
if (val === 'history' && !historyLoaded.value) {
|
||||
loadHistory()
|
||||
}
|
||||
})
|
||||
|
||||
const loadGoods = async () => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/point-goods`)
|
||||
if (res.ok) {
|
||||
@@ -89,6 +226,26 @@ const loadGoods = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadHistory = async () => {
|
||||
if (!authState.loggedIn) {
|
||||
historyLoaded.value = true
|
||||
return
|
||||
}
|
||||
historyLoading.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/point-histories`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
histories.value = (await res.json()).map((item) => ({
|
||||
...item,
|
||||
icon: iconMap[item.type],
|
||||
}))
|
||||
}
|
||||
historyLoading.value = false
|
||||
historyLoaded.value = true
|
||||
}
|
||||
|
||||
const openRedeem = (good) => {
|
||||
if (!authState.loggedIn || point.value === null || point.value < good.cost) {
|
||||
toast.error('积分不足')
|
||||
@@ -129,12 +286,44 @@ const submitRedeem = async () => {
|
||||
|
||||
<style scoped>
|
||||
.point-mall-page {
|
||||
padding: 0 20px;
|
||||
max-width: var(--page-max-width);
|
||||
background-color: var(--background-color);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.point-mall-page-content {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.point-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.point-tab-item {
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.point-tab-item.selected {
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.timeline-link {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.timeline-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.loading-points-container {
|
||||
margin-top: 100px;
|
||||
display: flex;
|
||||
@@ -215,6 +404,17 @@ const submitRedeem = async () => {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.history-content {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.history-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="post-clear" @click="clearPost"><i class="fa-solid fa-eraser"></i> 清空</div>
|
||||
<div class="ai-generate" @click="aiGenerate">
|
||||
<i class="fa-solid fa-robot"></i>
|
||||
md格式优化
|
||||
MD 格式优化
|
||||
</div>
|
||||
<div class="post-cancel" @click="cancelEdit">取消</div>
|
||||
<div
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
|
||||
<div v-if="closed" class="article-closed-button">已关闭</div>
|
||||
<div
|
||||
v-if="loggedIn && !isAuthor && !subscribed"
|
||||
v-if="!closed && loggedIn && !isAuthor && !subscribed"
|
||||
class="article-subscribe-button"
|
||||
@click="subscribePost"
|
||||
>
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="loggedIn && !isAuthor && subscribed"
|
||||
v-if="!closed && loggedIn && !isAuthor && subscribed"
|
||||
class="article-unsubscribe-button"
|
||||
@click="unsubscribePost"
|
||||
>
|
||||
@@ -295,7 +295,7 @@ const commentSort = ref('NEWEST')
|
||||
const isFetchingComments = ref(false)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const headerHeight = process.client
|
||||
const headerHeight = import.meta.client
|
||||
? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0
|
||||
: 0
|
||||
|
||||
@@ -309,7 +309,7 @@ useHead(() => ({
|
||||
],
|
||||
}))
|
||||
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', updateCurrentIndex)
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
@@ -355,7 +355,7 @@ const updateCountdown = () => {
|
||||
countdown.value = `${h}:${m}:${s}`
|
||||
}
|
||||
const startCountdown = () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
updateCountdown()
|
||||
countdownTimer = setInterval(updateCountdown, 1000)
|
||||
@@ -515,7 +515,7 @@ watchEffect(() => {
|
||||
})
|
||||
|
||||
// 404 客户端跳转
|
||||
// if (postError.value?.statusCode === 404 && process.client) {
|
||||
// if (postError.value?.statusCode === 404 && import.meta.client) {
|
||||
// router.replace('/404')
|
||||
// }
|
||||
|
||||
@@ -876,12 +876,8 @@ const gotoProfile = () => {
|
||||
navigateTo(`/users/${author.value.id}`, { replace: true })
|
||||
}
|
||||
|
||||
onActivated(async () => {
|
||||
await refreshPost()
|
||||
await fetchComments()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const initPage = async () => {
|
||||
scrollTo(0, 0)
|
||||
await fetchComments()
|
||||
const hash = location.hash
|
||||
const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null
|
||||
@@ -889,6 +885,14 @@ onMounted(async () => {
|
||||
updateCurrentIndex()
|
||||
window.addEventListener('scroll', updateCurrentIndex)
|
||||
jumpToHashComment()
|
||||
}
|
||||
|
||||
onActivated(async () => {
|
||||
await initPage()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await initPage()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1067,6 +1071,7 @@ onMounted(async () => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-closed-button,
|
||||
.article-subscribe-button-text,
|
||||
.article-unsubscribe-button-text {
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -38,10 +38,7 @@
|
||||
</div>
|
||||
<div class="form-row switch-row">
|
||||
<div class="setting-title">毛玻璃效果</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="frosted" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<BaseSwitch v-model="frosted" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="role === 'ADMIN'" class="admin-section">
|
||||
@@ -76,6 +73,7 @@ import { ref, onMounted, watch } from 'vue'
|
||||
import AvatarCropper from '~/components/AvatarCropper.vue'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import BaseSwitch from '~/components/BaseSwitch.vue'
|
||||
import { toast } from '~/main'
|
||||
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
||||
import { frostedState, setFrosted } from '~/utils/frosted'
|
||||
@@ -318,51 +316,6 @@ const save = async () => {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.2s;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
transition: 0.2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
@@ -12,21 +12,27 @@
|
||||
<div class="profile-page-header-user-info">
|
||||
<div class="profile-page-header-user-info-name">{{ user.username }}</div>
|
||||
<div class="profile-page-header-user-info-description">{{ user.introduction }}</div>
|
||||
<div
|
||||
v-if="!isMine && !subscribed"
|
||||
class="profile-page-header-subscribe-button"
|
||||
@click="subscribeUser"
|
||||
>
|
||||
<i class="fas fa-user-plus"></i>
|
||||
关注
|
||||
</div>
|
||||
<div
|
||||
v-if="!isMine && subscribed"
|
||||
class="profile-page-header-unsubscribe-button"
|
||||
@click="unsubscribeUser"
|
||||
>
|
||||
<i class="fas fa-user-minus"></i>
|
||||
取消关注
|
||||
<div class="profile-page-header-user-info-buttons">
|
||||
<div
|
||||
v-if="!isMine && !subscribed"
|
||||
class="profile-page-header-subscribe-button"
|
||||
@click="subscribeUser"
|
||||
>
|
||||
<i class="fas fa-user-plus"></i>
|
||||
关注
|
||||
</div>
|
||||
<div
|
||||
v-if="!isMine && subscribed"
|
||||
class="profile-page-header-unsubscribe-button"
|
||||
@click="unsubscribeUser"
|
||||
>
|
||||
<i class="fas fa-user-minus"></i>
|
||||
取消关注
|
||||
</div>
|
||||
<div v-if="!isMine" class="profile-page-header-subscribe-button" @click="sendMessage">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
发私信
|
||||
</div>
|
||||
</div>
|
||||
<LevelProgress
|
||||
:exp="levelInfo.exp"
|
||||
@@ -537,6 +543,28 @@ const unsubscribeUser = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/messages/conversations`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
recipientId: user.value.id,
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const result = await response.json()
|
||||
router.push(`/message-box/${result.conversationId}`)
|
||||
} catch (e) {
|
||||
toast.error('无法发起私信')
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const gotoTag = (tag) => {
|
||||
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
||||
@@ -614,6 +642,12 @@ watch(selectedTab, async (val) => {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.profile-page-header-user-info-buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.profile-page-header-subscribe-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { clearToken } from '~/utils/auth'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
const originalFetch = window.fetch
|
||||
window.fetch = async (input, init) => {
|
||||
const response = await originalFetch(input, init)
|
||||
|
||||
@@ -4,7 +4,7 @@ import '~/assets/toast.css'
|
||||
|
||||
export default defineNuxtPlugin(async (nuxtApp) => {
|
||||
// 确保只在客户端环境中注册插件
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
try {
|
||||
// 使用动态导入来避免 CommonJS 模块问题
|
||||
const { default: Toast, POSITION } = await import('vue-toastification')
|
||||
|
||||
1
frontend_nuxt/public/default-avatar.svg
Normal file
1
frontend_nuxt/public/default-avatar.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg t="1755789348718" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13787" width="400" height="400"><path d="M152.773168 306.474932h45.030559c37.525466 0 56.288199 18.762733 56.288198 56.288198v45.024199c0 37.525466-18.762733 56.288199-56.288198 56.288199h-45.030559c-37.525466 0-56.281839-18.762733-56.281839-56.288199v-45.030559c0-37.525466 18.762733-56.288199 56.288199-56.288199z" fill="#4D4132" p-id="13788"></path><path d="M409.294708 763.229814h228.968944v146.285714c0 63.22723-51.263602 114.484472-114.484472 114.484472-63.23359 0-114.484472-51.257242-114.484472-114.484472v-146.285714z" fill="#C5AC95" p-id="13789"></path><path d="M73.97605 520.357366c0 55.957466 45.361292 101.318758 101.318757 101.318758 55.951106 0 101.312398-45.361292 101.312398-101.318758 0-55.951106-45.361292-101.312398-101.318758-101.312397-55.951106 0-101.312398 45.361292-101.312397 101.318758z" fill="#C9AB90" p-id="13790"></path><path d="M490.48964 2.531379c186.520646 0 337.710112 151.195826 337.710112 337.716472v382.740671c0 99.474286-80.63523 180.109516-180.109516 180.109515H287.858484c-74.599354 0-135.078957-60.485963-135.078956-135.085317V340.247851C152.773168 153.727205 303.968994 2.531379 490.48964 2.531379z" fill="#EBD3BD" p-id="13791"></path><path d="M400.434882 509.099727c124.342857 0 225.140075 93.241242 225.140075 208.259975 0 5.679702-0.25441 11.308522-0.731429 16.880099H176.019876a195.278708 195.278708 0 0 1-0.731429-16.880099c0-115.018733 100.797217-208.259975 225.146435-208.259975zM805.684472 306.474932h45.030559c37.525466 0 56.288199 18.762733 56.288199 56.288198v45.024199c0 37.525466-18.762733 56.288199-56.288199 56.288199h-45.030559c-37.525466 0-56.288199-18.762733-56.288199-56.288199v-45.030559c0-37.525466 18.762733-56.288199 56.288199-56.288199z" fill="#4D4132" p-id="13792"></path><path d="M749.402634 520.357366c0 55.957466 45.361292 101.318758 101.312397 101.318758s101.318758-45.361292 101.318758-101.318758c0-55.951106-45.367652-101.312398-101.318758-101.312397s-101.318758 45.361292-101.318758 101.318758z" fill="#EBD3BD" p-id="13793"></path><path d="M805.684472 509.099727a45.030559 45.030559 0 1 0 90.061118 0.01908 45.030559 45.030559 0 0 0-90.061118-0.01908z" fill="#E89E80" p-id="13794"></path><path d="M175.288447 374.01441a90.061118 90.061118 0 1 0 180.115876 0c0-49.737143-40.323975-90.054758-90.061118-90.054758s-90.054758 40.323975-90.054758 90.061118z" fill="#FFFFFF" p-id="13795"></path><path d="M220.319006 379.64323a39.401739 39.401739 0 1 0 78.803478 0 39.401739 39.401739 0 0 0-78.803478 0z" fill="#514141" p-id="13796"></path><path d="M490.48964 374.01441c0 49.737143 40.323975 90.061118 90.061118 90.061118s90.048398-40.323975 90.048397-90.061118-40.317615-90.054758-90.054757-90.054758-90.061118 40.323975-90.061118 90.061118z" fill="#FFFFFF" p-id="13797"></path><path d="M535.520199 379.64323a39.401739 39.401739 0 1 0 78.797118 0 39.401739 39.401739 0 0 0-78.803478 0z" fill="#514141" p-id="13798"></path><path d="M394.806062 362.75677a40.18405 40.18405 0 0 1 37.754435 26.458634l41.99036 115.47031A78.803478 78.803478 0 0 1 400.504845 610.412124h-17.789615a78.803478 78.803478 0 0 1-72.920249-108.633043l46.207205-112.970733a41.920398 41.920398 0 0 1 38.797516-26.051578z" fill="#E89E80" p-id="13799"></path><path d="M165.36646 190.807453m38.16149 0l101.763975 0q38.161491 0 38.161491 38.161491l0 0q0 38.161491-38.161491 38.161491l-101.763975 0q-38.161491 0-38.16149-38.161491l0 0q0-38.161491 38.16149-38.161491Z" fill="#4D4132" p-id="13800"></path><path d="M483.378882 190.807453m38.161491 0l127.204969 0q38.161491 0 38.16149 38.161491l0 0q0 38.161491-38.16149 38.161491l-127.204969 0q-38.161491 0-38.161491-38.161491l0 0q0-38.161491 38.161491-38.161491Z" fill="#4D4132" p-id="13801"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
1
frontend_nuxt/public/tencent2707107139169774686.txt
Normal file
1
frontend_nuxt/public/tencent2707107139169774686.txt
Normal file
@@ -0,0 +1 @@
|
||||
1839503219847005265
|
||||
@@ -12,7 +12,7 @@ export const authState = reactive({
|
||||
role: null,
|
||||
})
|
||||
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
authState.loggedIn =
|
||||
localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== ''
|
||||
authState.userId = localStorage.getItem(USER_ID_KEY)
|
||||
@@ -21,18 +21,18 @@ if (process.client) {
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return process.client ? localStorage.getItem(TOKEN_KEY) : null
|
||||
return import.meta.client ? localStorage.getItem(TOKEN_KEY) : null
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
authState.loggedIn = true
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
clearUserInfo()
|
||||
authState.loggedIn = false
|
||||
@@ -40,7 +40,7 @@ export function clearToken() {
|
||||
}
|
||||
|
||||
export function setUserInfo({ id, username }) {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
authState.userId = id
|
||||
authState.username = username
|
||||
if (arguments[0] && arguments[0].role) {
|
||||
@@ -53,7 +53,7 @@ export function setUserInfo({ id, username }) {
|
||||
}
|
||||
|
||||
export function clearUserInfo() {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
localStorage.removeItem(USER_ID_KEY)
|
||||
localStorage.removeItem(USERNAME_KEY)
|
||||
localStorage.removeItem(ROLE_KEY)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const medalTitles = {
|
||||
COMMENT: '评论达人',
|
||||
POST: '发帖达人',
|
||||
FEATURED: '精选作者',
|
||||
SEED: '种子用户',
|
||||
CONTRIBUTOR: '贡献者',
|
||||
PIONEER: '开山鼻祖',
|
||||
|
||||
@@ -26,6 +26,8 @@ const iconMap = {
|
||||
LOTTERY_WIN: 'fas fa-trophy',
|
||||
LOTTERY_DRAW: 'fas fa-bullhorn',
|
||||
MENTION: 'fas fa-at',
|
||||
POST_DELETED: 'fas fa-trash',
|
||||
POST_FEATURED: 'fas fa-star',
|
||||
}
|
||||
|
||||
export async function fetchUnreadCount() {
|
||||
@@ -158,7 +160,7 @@ function createFetchNotifications() {
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
@@ -168,7 +170,7 @@ function createFetchNotifications() {
|
||||
emoji: reactionEmojiMap[n.reactionType],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
@@ -180,7 +182,19 @@ function createFetchNotifications() {
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_DELETED') {
|
||||
arr.push({
|
||||
...n,
|
||||
src: n.fromUser ? n.fromUser.avatar : null,
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
@@ -191,7 +205,7 @@ function createFetchNotifications() {
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`)
|
||||
}
|
||||
},
|
||||
@@ -201,7 +215,7 @@ function createFetchNotifications() {
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
@@ -211,7 +225,7 @@ function createFetchNotifications() {
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
@@ -222,7 +236,7 @@ function createFetchNotifications() {
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
@@ -237,7 +251,7 @@ function createFetchNotifications() {
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
@@ -249,7 +263,18 @@ function createFetchNotifications() {
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_FEATURED') {
|
||||
arr.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
@@ -277,7 +302,7 @@ function createFetchNotifications() {
|
||||
}
|
||||
}
|
||||
|
||||
const markRead = async (id) => {
|
||||
const markNotificationRead = async (id) => {
|
||||
if (!id) return
|
||||
const n = notifications.value.find((n) => n.id === id)
|
||||
if (!n || n.read) return
|
||||
@@ -319,7 +344,7 @@ function createFetchNotifications() {
|
||||
}
|
||||
return {
|
||||
fetchNotifications,
|
||||
markRead,
|
||||
markNotificationRead,
|
||||
notifications,
|
||||
isLoadingMessage,
|
||||
markAllRead,
|
||||
@@ -329,7 +354,7 @@ function createFetchNotifications() {
|
||||
|
||||
export const {
|
||||
fetchNotifications,
|
||||
markRead,
|
||||
markNotificationRead,
|
||||
notifications,
|
||||
isLoadingMessage,
|
||||
markAllRead,
|
||||
|
||||
Reference in New Issue
Block a user