feat(src/composables/useFeed.ts): добавляет поддержку бесконечной прокрутки для ленты новостей

 feat(package.json): добавляет скрипт для генерации спрайта и обновляет зависимости
🔧 refactor(src/composables/useChat.ts): упрощает логику управления чатом, убирает устаревшие функции
🔧 refactor(src/views/chat/ChatRoomView.vue): обновляет логику загрузки сообщений с использованием новых хуков
🔧 refactor(src/views/matches/MatchesView.vue): улучшает обработку совпадений с использованием новых хуков
 feat(src/App.vue): добавляет загрузку справочных данных с использованием Vue Query
 feat(src/main.ts): интегрирует Vue Query в приложение для управления состоянием запросов
 feat(src/views/profile/MyProfileView.vue): добавляет мутацию для удаления профиля с использованием Vue Query
🔧 refactor(src/components/layout/BottomNav.vue): заменяет компонент иконки на более универсальный компонент AppIcon
This commit is contained in:
Oscar
2026-06-09 10:29:52 +03:00
parent 56c7fb22e9
commit 78fe3b5a98
46 changed files with 658 additions and 416 deletions

View File

@@ -70,12 +70,12 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { apiClient } from '@/api/client'
import { computed } from 'vue'
import AppButton from '@/components/common/AppButton.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useUi } from '@/composables/useUi'
import { useReportsQuery } from '@/queries/useReportsQuery'
interface Report {
id: string
@@ -89,26 +89,14 @@ interface Report {
}
const uiStore = useUi()
const reports = ref<Report[]>([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
const res = await apiClient.api.reportsControllerGetAll() as unknown as Report[]
reports.value = res
}
catch {
uiStore.addToast('Не удалось загрузить жалобы', 'error')
}
finally {
loading.value = false
}
})
const { query: { data: reportsData, isLoading: loading }, banUser: banUserMutation } = useReportsQuery()
const reports = computed(() => (reportsData.value as unknown as Report[]) ?? [])
async function banUser(userId: string) {
try {
await apiClient.api.usersControllerBan(userId)
await banUserMutation.mutateAsync(userId)
uiStore.addToast('Пользователь заблокирован', 'success')
}
catch {

View File

@@ -38,11 +38,11 @@
</div>
<!-- Messages -->
<div v-if="chatStore.loading" class="chat-room__loading">
<div v-if="messagesQuery.isLoading.value" class="chat-room__loading">
<LoadingSpinner size="md" />
</div>
<div v-else class="chat-room__messages" :class="{ 'chat-room__messages--blurred': isLocked }">
<div v-else-if="!messagesQuery.isLoading.value" class="chat-room__messages" :class="{ 'chat-room__messages--blurred': isLocked }">
<div
v-for="group in groupedMessages"
:key="group.date"
@@ -87,8 +87,8 @@
</template>
<script setup lang="ts">
import type { ChatMessage } from '@/composables/useChat'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import type { MessageDto } from '@/api/api'
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import ChatBubble from '@/components/chat/ChatBubble.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
@@ -96,27 +96,29 @@ import AppButton from '@/components/common/AppButton.vue'
import AppModal from '@/components/common/AppModal.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useChat } from '@/composables/useChat'
import { useUi } from '@/composables/useUi'
import { useChatsQuery } from '@/queries/useChatsQuery'
import { useMessagesQuery } from '@/queries/useMessagesQuery'
const route = useRoute()
const authStore = useAuth()
const chatStore = useChat()
const uiStore = useUi()
const chatId = route.params.chatId as string
const profileId = computed(() => authStore.activeProfile?.id ?? '')
const profileId = computed(() => authStore.activeProfile?.id)
const messagesEnd = ref<HTMLElement | null>(null)
const confirmClose = ref(false)
const chat = computed(() => chatStore.chats.find(c => c.id === chatId))
const { query: chatsQuery, closeChat: closeChatMutation } = useChatsQuery(profileId)
const { query: messagesQuery, sendMessage: sendMessageMutation } = useMessagesQuery(chatId, profileId)
const chat = computed(() => chatsQuery.data.value?.find(c => c.id === chatId))
const isLocked = computed(() => chat.value?.status === 'closed')
// Group messages by date
const groupedMessages = computed(() => {
const groups: Array<{ date: string, messages: ChatMessage[] }> = []
const groups: Array<{ date: string, messages: MessageDto[] }> = []
let lastDate = ''
for (const msg of chatStore.messages) {
for (const msg of (messagesQuery.data.value ?? [])) {
const d = new Date(msg.createdAt).toLocaleDateString('ru', { day: 'numeric', month: 'long', year: 'numeric' })
if (d !== lastDate) {
groups.push({ date: d, messages: [] })
@@ -132,23 +134,11 @@ async function scrollToBottom() {
messagesEnd.value?.scrollIntoView({ behavior: 'smooth' })
}
onMounted(async () => {
if (!profileId.value)
return
await chatStore.fetchMessages(chatId, profileId.value)
await scrollToBottom()
chatStore.startPolling(chatId, profileId.value)
})
onUnmounted(() => {
chatStore.stopPolling()
})
watch(() => messagesQuery.data.value?.length, scrollToBottom)
async function send(text: string, mediaUrl?: string, mediaType?: 'photo' | 'voice' | 'video') {
if (!profileId.value)
return
try {
await chatStore.sendMessage(chatId, profileId.value, { text, mediaUrl, mediaType })
await sendMessageMutation.mutateAsync({ text, mediaUrl, mediaType })
await scrollToBottom()
}
catch {
@@ -161,10 +151,8 @@ function goBack() {
}
async function doCloseChat() {
if (!profileId.value)
return
try {
await chatStore.closeChat(chatId, profileId.value)
await closeChatMutation.mutateAsync({ chatId })
confirmClose.value = false
goBack()
}

View File

@@ -11,14 +11,14 @@
</div>
<EmptyState
v-else-if="chatStore.chats.length === 0"
v-else-if="(chats ?? []).length === 0"
title="Нет активных чатов"
description="Найдите совпадения и начните общение"
icon="chat"
/>
<ul v-else class="chats-list__list" role="list">
<li v-for="chat in chatStore.chats" :key="chat.id">
<li v-for="chat in (chats ?? [])" :key="chat.id">
<RouterLink
:to="`/chats/${chat.id}`"
class="chat-item"
@@ -70,33 +70,16 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed } from 'vue'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useChat } from '@/composables/useChat'
import { useUi } from '@/composables/useUi'
import { useChatsQuery } from '@/queries/useChatsQuery'
const authStore = useAuth()
const chatStore = useChat()
const uiStore = useUi()
const loading = ref(false)
const profileId = computed(() => authStore.activeProfile?.id)
onMounted(async () => {
const profileId = authStore.activeProfile?.id
if (!profileId)
return
loading.value = true
try {
await chatStore.fetchChats(profileId)
}
catch {
uiStore.addToast('Не удалось загрузить чаты', 'error')
}
finally {
loading.value = false
}
})
const { query: { data: chats, isLoading: loading } } = useChatsQuery(profileId)
function formatTime(dateStr: string) {
const d = new Date(dateStr)

View File

@@ -11,7 +11,7 @@
</div>
<EmptyState
v-else-if="dates.length === 0"
v-else-if="dates.length === 0 && !loading"
title="Нет предстоящих встреч"
description="Предложите встречу из профиля совпадения"
icon="calendar"
@@ -77,13 +77,13 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { apiClient } from '@/api/client'
import { computed, ref } from 'vue'
import AppButton from '@/components/common/AppButton.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
import { useDatesQuery } from '@/queries/useDatesQuery'
interface DateStatus { id: string, name: string }
interface DateItem {
@@ -102,31 +102,17 @@ interface DateItem {
const authStore = useAuth()
const uiStore = useUi()
const dates = ref<DateItem[]>([])
const statuses = ref<DateStatus[]>([])
const loading = ref(false)
const profileId = computed(() => authStore.activeProfile?.id)
const actionLoading = ref<string | null>(null)
onMounted(async () => {
const profileId = authStore.activeProfile?.id
if (!profileId)
return
loading.value = true
try {
const [datesRes, statusesRes] = await Promise.all([
apiClient.api.datesControllerGetDates({ profileId }) as unknown as DateItem[],
apiClient.api.datesControllerGetStatuses() as unknown as DateStatus[],
])
dates.value = datesRes
statuses.value = statusesRes
}
catch {
uiStore.addToast('Не удалось загрузить встречи', 'error')
}
finally {
loading.value = false
}
})
const {
datesQuery: { data: datesData, isLoading: loading },
statusesQuery: { data: statusesData },
updateStatus: updateStatusMutation,
} = useDatesQuery(profileId)
const dates = computed(() => (datesData.value as unknown as DateItem[]) ?? [])
const statuses = computed(() => (statusesData.value as unknown as DateStatus[]) ?? [])
function statusLabel(statusId: string) {
return statuses.value.find(s => s.id === statusId)?.name ?? statusId
@@ -146,15 +132,9 @@ function statusColor(statusId: string) {
}
async function updateStatus(dateId: string, statusId: string) {
const profileId = authStore.activeProfile?.id
if (!profileId)
return
actionLoading.value = `${dateId}-${statusId}`
try {
await apiClient.api.datesControllerUpdateStatus({ id: dateId, profileId }, { statusId })
const idx = dates.value.findIndex(d => d.id === dateId)
if (idx !== -1)
dates.value[idx].statusId = statusId
await updateStatusMutation.mutateAsync({ dateId, statusId })
uiStore.addToast('Статус обновлён', 'success')
}
catch {
@@ -175,7 +155,6 @@ function formatDateTime(iso: string) {
})
}
// Pending = first two status ids roughly; accept/decline/complete = 3+
const _pendingStatusId = computed(() => statuses.value[0]?.id ?? '')
const acceptedStatusId = computed(() => statuses.value[1]?.id ?? '')
const declinedStatusId = computed(() => statuses.value[2]?.id ?? '')

View File

@@ -86,25 +86,16 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ref } from 'vue'
import AppButton from '@/components/common/AppButton.vue'
import FeedCardStack from '@/components/feed/FeedCardStack.vue'
import FeedFilters from '@/components/feed/FeedFilters.vue'
import { useAuth } from '@/composables/useAuth'
import { useFeed } from '@/composables/useFeed'
const feedStore = useFeed()
const authStore = useAuth()
const filtersOpen = ref(false)
const viewMode = ref<'stack' | 'scroll'>('stack')
onMounted(() => {
const profileId = authStore.activeProfile?.id
if (profileId && feedStore.cards.length === 0) {
feedStore.fetchNextPage(profileId)
}
})
</script>
<style scoped lang="scss">

View File

@@ -4,7 +4,7 @@
<h1 class="matches-view__title">
Совпадения
</h1>
<span class="meta">{{ matches.length }} {{ matches.length === 1 ? 'человек' : 'людей' }}</span>
<span class="meta">{{ (matches ?? []).length }} {{ (matches ?? []).length === 1 ? 'человек' : 'людей' }}</span>
</header>
<div v-if="loading" class="matches-view__loading">
@@ -12,7 +12,7 @@
</div>
<EmptyState
v-else-if="matches.length === 0"
v-else-if="(matches ?? []).length === 0"
title="Пока нет совпадений"
description="Ставьте лайки, чтобы находить тех, кто ответит взаимностью"
icon="heart"
@@ -20,7 +20,7 @@
<div v-else class="matches-list">
<article
v-for="match in matches"
v-for="match in (matches ?? [])"
:key="match.id"
class="match-card"
>
@@ -61,26 +61,25 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import type { MatchDto, ProfileResponseDto } from '@/api/api'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import { useAuth } from '@/composables/useAuth'
import { useChat } from '@/composables/useChat'
import { useUi } from '@/composables/useUi'
type EnrichedMatch = MatchDto & { partnerProfile: ProfileResponseDto }
import { useChatsQuery } from '@/queries/useChatsQuery'
import { useMatchesQuery } from '@/queries/useMatchesQuery'
import type { EnrichedMatch } from '@/queries/useMatchesQuery'
const authStore = useAuth()
const chatStore = useChat()
const uiStore = useUi()
const router = useRouter()
const matches = ref<EnrichedMatch[]>([])
const loading = ref(false)
const profileId = computed(() => authStore.activeProfile?.id)
const { data: matches, isLoading: loading } = useMatchesQuery(profileId)
const { openChat: openChatMutation } = useChatsQuery(profileId)
function calcAge(birthDate: string): number | undefined {
const birth = new Date(birthDate)
@@ -93,42 +92,14 @@ function calcAge(birthDate: string): number | undefined {
return age
}
onMounted(async () => {
const profileId = authStore.activeProfile?.id
if (!profileId)
return
loading.value = true
try {
const matchDtos = await apiClient.api.likesControllerGetMyMatches({ profileId })
matches.value = await Promise.all(
matchDtos.map(async (m) => {
const partnerProfileId = m.profile1Id === profileId ? m.profile2Id : m.profile1Id
const partnerProfile = await apiClient.api.profilesControllerFindOne(partnerProfileId)
return { ...m, partnerProfile }
}),
)
}
catch {
uiStore.addToast('Не удалось загрузить совпадения', 'error')
}
finally {
loading.value = false
}
})
async function openChat(match: EnrichedMatch) {
const profileId = authStore.activeProfile?.id
if (!profileId)
return
try {
const chat = await chatStore.openChat(profileId, match.id)
const chat = await openChatMutation.mutateAsync({ matchId: match.id })
router.push(`/chats/${chat.id}`)
}
catch (err: unknown) {
const msg = (err as { response?: { data?: { message?: string } } })
?.response
?.data
?.message ?? 'Не удалось открыть чат'
?.response?.data?.message ?? 'Не удалось открыть чат'
uiStore.addToast(msg, 'error')
}
}

View File

@@ -137,22 +137,23 @@
<script setup lang="ts">
import type { UserProfile } from '@/composables/useAuth'
import { computed, ref } from 'vue'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import AppModal from '@/components/common/AppModal.vue'
import MediaGallery from '@/components/profile/MediaGallery.vue'
import ProfileEditor from '@/components/profile/ProfileEditor.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
import { useDeleteProfileMutation } from '@/queries/useProfileQuery'
const authStore = useAuth()
const uiStore = useUi()
const deleteProfileMutation = useDeleteProfileMutation()
const editing = ref(false)
const confirmDelete = ref(false)
const deleting = ref(false)
const profile = computed(() => authStore.activeProfile)
const deleting = computed(() => deleteProfileMutation.isPending.value)
function onSaved(_updated: UserProfile) {
editing.value = false
@@ -161,9 +162,8 @@ function onSaved(_updated: UserProfile) {
async function doDelete() {
if (!profile.value)
return
deleting.value = true
try {
await apiClient.api.profilesControllerDelete(profile.value.id)
await deleteProfileMutation.mutateAsync(profile.value.id)
authStore.removeProfile(profile.value.id)
confirmDelete.value = false
uiStore.addToast('Профиль удалён', 'success')
@@ -171,9 +171,6 @@ async function doDelete() {
catch {
uiStore.addToast('Не удалось удалить профиль', 'error')
}
finally {
deleting.value = false
}
}
function logout() {

View File

@@ -113,43 +113,26 @@
</template>
<script setup lang="ts">
import type { UserProfile } from '@/composables/useAuth'
import { computed, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { apiClient } from '@/api/client'
import AppButton from '@/components/common/AppButton.vue'
import AppModal from '@/components/common/AppModal.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import DateProposalForm from '@/components/dates/DateProposalForm.vue'
import ReportModal from '@/components/reports/ReportModal.vue'
import { useAuth } from '@/composables/useAuth'
import { useChat } from '@/composables/useChat'
import { useUi } from '@/composables/useUi'
import { useProfileQuery } from '@/queries/useProfileQuery'
const route = useRoute()
const authStore = useAuth()
const uiStore = useUi()
const _chatStore = useChat()
const profileId = route.params.profileId as string
const profile = ref<UserProfile | null>(null)
const loading = ref(false)
const reportOpen = ref(false)
const dateOpen = ref(false)
onMounted(async () => {
loading.value = true
try {
const res = await apiClient.api.profilesControllerFindOne(profileId)
profile.value = res
}
catch {
uiStore.addToast('Не удалось загрузить профиль', 'error')
}
finally {
loading.value = false
}
})
const { data: profile, isLoading: loading } = useProfileQuery(profileId)
const age = computed(() => {
if (!profile.value?.birthDate)