feat(src/views/matches/MatchesView.vue): добавляет поддержку чатов с использованием сокетов

 feat(src/views/profile/MyProfileView.vue): добавляет раздел истории чатов в профиль пользователя

 feat(src/composables/useSocket.ts): создает хук для работы с сокетами

📝 chore(src/queries/useChatsQuery.ts): обновляет логику закрытия чата с учетом новых параметров

📝 chore(src/components/profile/MediaGallery.vue): исправляет путь к медиафайлам в галерее

🔧 fix(src/components/layout/SideNav.vue): комментирует неиспользуемый элемент навигации "Встречи"
This commit is contained in:
Oscar
2026-06-09 15:39:19 +03:00
parent 2ac9bbf220
commit a1261ce0db
11 changed files with 304 additions and 36 deletions

View File

@@ -169,9 +169,17 @@ export interface MatchDto {
createdAt: string;
}
export interface ChatDto {
id: string;
profile1Id: string;
profile2Id: string;
status: "active" | "closed";
}
export interface CreateLikeResponseDto {
like: LikeDto;
match?: MatchDto | null;
chat?: ChatDto | null;
}
export interface CreateChatDto {
@@ -181,13 +189,6 @@ export interface CreateChatDto {
matchId: string;
}
export interface ChatDto {
id: string;
profile1Id: string;
profile2Id: string;
status: "active" | "closed";
}
export interface MessageDto {
id: string;
chatId: string;
@@ -204,6 +205,17 @@ export interface SendMessageDto {
mediaType?: "photo" | "voice" | "video";
}
export interface CloseChatDto {
/** cancel — отменить матч, report — пожаловаться */
type: "cancel" | "report";
/** Сообщение собеседнику при отмене матча */
cancelMessage?: string;
/** Причина жалобы (preset) */
reportReason?: string;
/** Дополнительное описание жалобы */
reportDescription?: string;
}
export interface CreateDateDto {
/** Your profile ID */
profileId: string;
@@ -978,16 +990,22 @@ export class Api<SecurityDataType extends unknown> {
*
* @tags chat
* @name ChatControllerCloseChat
* @summary Close a chat
* @summary Close a chat (cancel match or report)
* @request DELETE:/api/v1/chats/{chatId}
* @secure
*/
chatControllerCloseChat: ({ chatId, ...query }: ChatControllerCloseChatParams, params: RequestParams = {}) =>
chatControllerCloseChat: (
{ chatId, ...query }: ChatControllerCloseChatParams,
data: CloseChatDto,
params: RequestParams = {},
) =>
this.http.request<MessageResponseDto, any>({
path: `/api/v1/chats/${chatId}`,
method: "DELETE",
query: query,
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),

View File

@@ -45,12 +45,12 @@
</template>
<script setup lang="ts">
import type { IconName } from '@/assets/icons/icon-names'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppIcon from '@/components/common/AppIcon.vue'
import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
import AppIcon from '@/components/common/AppIcon.vue'
import type { IconName } from '@/assets/icons/icon-names'
const route = useRoute()
const authStore = useAuth()
@@ -68,7 +68,7 @@ const navItems: NavItem[] = [
{ name: 'feed', path: '/feed', label: 'Лента', icon: 'grid' },
{ name: 'matches', path: '/matches', label: 'Совпадения', icon: 'heart' },
{ name: 'chats', path: '/chats', label: 'Чаты', icon: 'chat' },
{ name: 'dates', path: '/dates', label: 'Встречи', icon: 'calendar' },
// { name: 'dates', path: '/dates', label: 'Встречи', icon: 'calendar' },
{ name: 'profile', path: '/profile/me', label: 'Профиль', icon: 'person' },
]

View File

@@ -22,11 +22,11 @@
class="gallery__item"
>
<img
:src="item.url"
:src="item.path"
class="gallery__img"
alt="Медиа"
loading="lazy"
@click="lightboxUrl = item.url"
@click="lightboxUrl = item.path"
>
<button
v-if="editable"
@@ -56,12 +56,12 @@
<script setup lang="ts">
import { ref } from 'vue'
import { apiClient } from '@/api/client'
import { apiClient, axiosInstance, BASE_URL } from '@/api/client'
import { useUi } from '@/composables/useUi'
interface MediaItem {
id: string
url: string
path: string
type: string
}
@@ -114,11 +114,28 @@ async function onFileChange(e: Event) {
fileInput.value.value = ''
}
async function doUpload(_file: File | null, _tauriPath?: string) {
async function doUpload(file: File | null, tauriPath?: string) {
uploading.value = true
try {
// API expects multipart/form-data with the file; we pass query type=photo
await apiClient.api.mediaControllerUpload({ profileId: props.profileId, type: 'photo' })
const formData = new FormData()
if (file) {
formData.append('file', file)
}
else if (tauriPath) {
const { convertFileSrc } = await import('@tauri-apps/api/core')
const assetUrl = convertFileSrc(tauriPath)
const res = await fetch(assetUrl)
const blob = await res.blob()
const name = tauriPath.split(/[\\/]/).pop() ?? 'photo.jpg'
formData.append('file', blob, name)
}
else {
throw new Error('No file provided')
}
await axiosInstance.post(
`${BASE_URL}/api/v1/profiles/${props.profileId}/media/upload?type=photo`,
formData,
)
uiStore.addToast('Фото загружено', 'success')
await loadMedia()
emit('updated')

View File

@@ -0,0 +1,65 @@
import { io, Socket } from 'socket.io-client'
import { ref } from 'vue'
import { BASE_URL, _getAccessToken } from '@/api/client'
let socket: Socket | null = null
const connected = ref(false)
export function useSocket() {
function connect(profileId: string) {
if (socket?.connected) return
if (socket) {
socket.disconnect()
socket = null
}
const token = _getAccessToken()
if (!token) return
socket = io(`${BASE_URL}/chat`, {
path: '/socket.io',
transports: ['websocket'],
auth: { token, profileId },
})
socket.on('connect', () => {
connected.value = true
})
socket.on('disconnect', () => {
connected.value = false
})
}
function disconnect() {
socket?.disconnect()
socket = null
connected.value = false
}
function on<T = any>(event: string, handler: (data: T) => void) {
socket?.on(event, handler)
return () => socket?.off(event, handler)
}
function off(event: string, handler: (...args: any[]) => void) {
socket?.off(event, handler)
}
function emit(event: string, data: any) {
socket?.emit(event, data)
}
function joinChat(chatId: string) {
socket?.emit('join_chat', { chatId })
}
function leaveChat(chatId: string) {
socket?.emit('leave_chat', { chatId })
}
function sendTyping(chatId: string, isTyping: boolean) {
socket?.emit('typing', { chatId, isTyping })
}
return { connect, disconnect, on, off, emit, joinChat, leaveChat, sendTyping, connected }
}

View File

@@ -1,8 +1,9 @@
import type { ChatDto, MessageDto } from '@/api/api'
import type { MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import type { ChatDto, MessageDto } from '@/api/api'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { apiClient } from '@/api/client'
import { computed, toValue } from 'vue'
import { apiClient, axiosInstance } from '@/api/client'
import { useAuth } from '@/composables/useAuth'
import { queryKeys } from './keys'
export interface ChatPartner {
@@ -11,15 +12,23 @@ export interface ChatPartner {
avatarUrl?: string
}
// The API returns richer chat objects than ChatDto — these extra fields aren't in the generated types
export type Chat = ChatDto & {
partner?: ChatPartner
lastMessage?: MessageDto
unreadCount?: number
}
export interface CloseChatPayload {
chatId: string
type: 'cancel' | 'report'
cancelMessage?: string
reportReason?: string
reportDescription?: string
}
export function useChatsQuery(profileId: MaybeRefOrGetter<string | undefined>) {
const qc = useQueryClient()
const authStore = useAuth()
const pid = () => toValue(profileId) ?? ''
const query = useQuery({
@@ -35,9 +44,15 @@ export function useChatsQuery(profileId: MaybeRefOrGetter<string | undefined>) {
})
const closeChat = useMutation({
mutationFn: ({ chatId }: { chatId: string }) =>
apiClient.api.chatControllerCloseChat({ chatId, profileId: pid() }),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.chats(pid()) }),
mutationFn: ({ chatId, type, cancelMessage, reportReason, reportDescription }: CloseChatPayload) =>
axiosInstance.delete(`/api/v1/chats/${chatId}`, {
params: { profileId: pid() },
data: { type, cancelMessage, reportReason, reportDescription },
}),
onSuccess: async () => {
await authStore.fetchMe()
qc.invalidateQueries({ queryKey: queryKeys.chats(pid()) })
},
})
return { query, openChat, closeChat }

View File

@@ -61,6 +61,7 @@
</template>
<script setup lang="ts">
import type { EnrichedMatch } from '@/queries/useMatchesQuery'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import AppButton from '@/components/common/AppButton.vue'
@@ -70,7 +71,6 @@ import { useAuth } from '@/composables/useAuth'
import { useUi } from '@/composables/useUi'
import { useChatsQuery } from '@/queries/useChatsQuery'
import { useMatchesQuery } from '@/queries/useMatchesQuery'
import type { EnrichedMatch } from '@/queries/useMatchesQuery'
const authStore = useAuth()
const uiStore = useUi()
@@ -79,7 +79,7 @@ const router = useRouter()
const profileId = computed(() => authStore.activeProfile?.id)
const { data: matches, isLoading: loading } = useMatchesQuery(profileId)
const { openChat: openChatMutation } = useChatsQuery(profileId)
const { query: { data: chats }, openChat: openChatMutation } = useChatsQuery(profileId)
function calcAge(birthDate: string): number | undefined {
const birth = new Date(birthDate)
@@ -93,13 +93,23 @@ function calcAge(birthDate: string): number | undefined {
}
async function openChat(match: EnrichedMatch) {
// const existing = (chats.value ?? []).find(
// c => c.status === 'active'
// && (c.profile1Id === match.partnerProfile.id || c.profile2Id === match.partnerProfile.id),
// )
// if (existing) {
// router.push(`/chats/${existing.id}`)
// return
// }
try {
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

@@ -82,6 +82,27 @@
<MediaGallery :profile-id="profile.id" :editable="true" />
</div>
<!-- Chat history -->
<div class="my-profile__section">
<h3 class="my-profile__section-title">
Общение
</h3>
<RouterLink to="/chats" class="my-profile__chat-history-link">
<span class="my-profile__chat-history-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="18" height="18">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</span>
<span class="my-profile__chat-history-text">
<span class="my-profile__chat-history-label">История чатов</span>
<span class="meta my-profile__chat-history-meta">Активные диалоги и прошлые переписки</span>
</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16" class="my-profile__chat-history-arrow">
<path d="M9 18l6-6-6-6" />
</svg>
</RouterLink>
</div>
<!-- Danger zone -->
<div class="my-profile__section my-profile__danger">
<AppButton variant="danger" size="sm" @click="confirmDelete = true">
@@ -328,6 +349,52 @@ function calcAge(birthDate: string) {
letter-spacing: 0.03em;
}
&__chat-history-link {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
background: rgba(240, 235, 224, 0.04);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
text-decoration: none;
color: var(--color-cream);
transition: background var(--transition-fast);
&:hover {
background: rgba(240, 235, 224, 0.07);
}
}
&__chat-history-icon {
color: var(--color-signal);
flex-shrink: 0;
display: flex;
}
&__chat-history-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
&__chat-history-label {
font-family: var(--font-mono);
font-size: 0.9375rem;
font-weight: 500;
}
&__chat-history-meta {
color: var(--color-muted);
}
&__chat-history-arrow {
color: var(--color-muted);
flex-shrink: 0;
}
&__danger {
padding-top: 16px;
border-top: 1px solid rgba(196, 92, 58, 0.2);

View File

@@ -54,9 +54,9 @@
<!-- Actions (non-own) -->
<div v-if="!isOwnProfile" class="profile-detail__actions">
<AppButton size="sm" variant="primary" @click="dateOpen = true">
Встреча
</AppButton>
<!-- <AppButton size="sm" variant="primary" @click="dateOpen = true"> -->
<!-- Встреча -->
<!-- </AppButton> -->
<AppButton size="sm" variant="ghost" @click="reportOpen = true">
Пожаловаться
</AppButton>