From 9a07d006d7ec43c79ea9a36cb0a14b19c56c351b Mon Sep 17 00:00:00 2001 From: neizbejnoezlo <137374284+neizbejnoezlo@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:59:26 +0700 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20Apollo,=20=D0=BF=D0=BE=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mdm-front/.env | 1 + mdm-front/src/app/layouts/AppLayout.scss | 1 + .../src/entities/device/api/device.graphql.ts | 38 ++ mdm-front/src/entities/device/model/types.ts | 33 ++ .../src/features/auth/api/auth.graphql.ts | 28 ++ mdm-front/src/features/auth/ui/AuthGate.tsx | 59 +++ mdm-front/src/index.scss | 2 +- mdm-front/src/main.tsx | 9 +- mdm-front/src/pages/DevicePage/DevicePage.tsx | 156 +++++++- mdm-front/src/pages/DevicePage/types.ts | 1 + .../src/pages/DevicesPage/DevicesPage.scss | 55 ++- .../src/pages/DevicesPage/DevicesPage.tsx | 345 +++++++++++------- mdm-front/src/pages/LoginPage/LoginPage.scss | 93 +++++ mdm-front/src/pages/LoginPage/LoginPage.tsx | 75 ++++ mdm-front/src/shared/api/apolloClient.ts | 124 +++++++ mdm-front/src/widgets/Sidebar/Sidebar.scss | 4 +- mdm-front/vite.config.ts | 9 + 17 files changed, 882 insertions(+), 151 deletions(-) create mode 100644 mdm-front/.env create mode 100644 mdm-front/src/entities/device/api/device.graphql.ts create mode 100644 mdm-front/src/entities/device/model/types.ts create mode 100644 mdm-front/src/features/auth/api/auth.graphql.ts create mode 100644 mdm-front/src/features/auth/ui/AuthGate.tsx create mode 100644 mdm-front/src/pages/LoginPage/LoginPage.scss create mode 100644 mdm-front/src/pages/LoginPage/LoginPage.tsx create mode 100644 mdm-front/src/shared/api/apolloClient.ts diff --git a/mdm-front/.env b/mdm-front/.env new file mode 100644 index 0000000..8209e8b --- /dev/null +++ b/mdm-front/.env @@ -0,0 +1 @@ +VITE_GRAPHQL_API_URL=/graphql \ No newline at end of file diff --git a/mdm-front/src/app/layouts/AppLayout.scss b/mdm-front/src/app/layouts/AppLayout.scss index bb00f11..16ae059 100644 --- a/mdm-front/src/app/layouts/AppLayout.scss +++ b/mdm-front/src/app/layouts/AppLayout.scss @@ -9,4 +9,5 @@ flex-direction: column; flex: 1; padding: 20px 36px 36px 0; + max-height: calc(100vh - 56px); } \ No newline at end of file diff --git a/mdm-front/src/entities/device/api/device.graphql.ts b/mdm-front/src/entities/device/api/device.graphql.ts new file mode 100644 index 0000000..0e2bde5 --- /dev/null +++ b/mdm-front/src/entities/device/api/device.graphql.ts @@ -0,0 +1,38 @@ +import { gql } from '@apollo/client' + +export const GET_PHONES_PAGE_QUERY = gql` + query GetPhonesPage($key: String) { + getPhonesPage(key: $key) { + page { + id + imei + imei2 + serial + lastLocation { + alt + date + lat + lng + } + } + nextKey + } + } +` + +export const GET_PHONE_QUERY = gql` + query GetPhone($id: Int!) { + getPhone(id: $id) { + id + imei + imei2 + serial + lastLocation { + alt + date + lat + lng + } + } + } +` \ No newline at end of file diff --git a/mdm-front/src/entities/device/model/types.ts b/mdm-front/src/entities/device/model/types.ts new file mode 100644 index 0000000..9f715cb --- /dev/null +++ b/mdm-front/src/entities/device/model/types.ts @@ -0,0 +1,33 @@ +export type DeviceLocation = { + alt: number + date: number + lat: number + lng: number +} + +export type Device = { + id: number + imei: string + imei2: string + serial: string + lastLocation: DeviceLocation | null +} + +export type GetPhonesPageData = { + getPhonesPage: { + page: Device[] + nextKey: string | null + } +} + +export type GetPhonesPageVariables = { + key?: string +} + +export type GetPhoneData = { + getPhone: Device | null +} + +export type GetPhoneVariables = { + id: number +} \ No newline at end of file diff --git a/mdm-front/src/features/auth/api/auth.graphql.ts b/mdm-front/src/features/auth/api/auth.graphql.ts new file mode 100644 index 0000000..1a7f1ca --- /dev/null +++ b/mdm-front/src/features/auth/api/auth.graphql.ts @@ -0,0 +1,28 @@ +import { gql } from '@apollo/client' + +export const SIGN_IN_MUTATION = gql` + mutation SignIn($username: String!, $password: String!) { + signIn(username: $username, password: $password) { + id + role + } + } +` + +export const REFRESH_SESSION_MUTATION = gql` + mutation RefreshSession { + refreshSession { + id + role + } + } +` + +export const CURRENT_USER_QUERY = gql` + query CurrentUser { + currentUser { + id + role + } + } +` \ No newline at end of file diff --git a/mdm-front/src/features/auth/ui/AuthGate.tsx b/mdm-front/src/features/auth/ui/AuthGate.tsx new file mode 100644 index 0000000..0136299 --- /dev/null +++ b/mdm-front/src/features/auth/ui/AuthGate.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { useQuery } from '@apollo/client/react' + +import { CURRENT_USER_QUERY } from '../api/auth.graphql' +import { LoginPage } from '../../../pages/LoginPage/LoginPage' + +type CurrentUser = { + id: string + role: string +} + +type CurrentUserQueryData = { + currentUser: CurrentUser | null +} + +type AuthGateProps = { + children: ReactNode +} + +export function AuthGate({ children }: AuthGateProps) { + const [authVersion, setAuthVersion] = useState(0) + + const { data, loading, error, refetch } = useQuery( + CURRENT_USER_QUERY, + { + fetchPolicy: 'network-only', + }, + ) + + useEffect(() => { + function handleLogout() { + setAuthVersion((prev) => prev + 1) + } + + window.addEventListener('auth:logout', handleLogout) + + return () => { + window.removeEventListener('auth:logout', handleLogout) + } + }, []) + + if (loading) { + return
Проверка авторизации...
+ } + + if (error || !data?.currentUser) { + return ( + { + refetch() + }} + /> + ) + } + + return children +} \ No newline at end of file diff --git a/mdm-front/src/index.scss b/mdm-front/src/index.scss index 00f60cb..9144a57 100644 --- a/mdm-front/src/index.scss +++ b/mdm-front/src/index.scss @@ -29,7 +29,7 @@ letter-spacing: 0.18px; color-scheme: light dark; color: var(--text); - background: var(--bg); + background: $color-bg; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; diff --git a/mdm-front/src/main.tsx b/mdm-front/src/main.tsx index f896905..7db3476 100644 --- a/mdm-front/src/main.tsx +++ b/mdm-front/src/main.tsx @@ -2,12 +2,19 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.scss' import { RouterProvider } from 'react-router-dom' +import { ApolloProvider } from '@apollo/client/react' +import { apolloClient } from './shared/api/apolloClient' import { router } from './app/router/router.tsx' import 'react-day-picker/style.css' import 'leaflet/dist/leaflet.css' +import { AuthGate } from './features/auth/ui/AuthGate' createRoot(document.getElementById('root')!).render( - + + + + + , ) diff --git a/mdm-front/src/pages/DevicePage/DevicePage.tsx b/mdm-front/src/pages/DevicePage/DevicePage.tsx index c44abee..0635b29 100644 --- a/mdm-front/src/pages/DevicePage/DevicePage.tsx +++ b/mdm-front/src/pages/DevicePage/DevicePage.tsx @@ -1,9 +1,15 @@ import { useMemo } from 'react' -import { useNavigate, useParams } from 'react-router-dom' -import { Link } from 'react-router-dom' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { useQuery } from '@apollo/client/react' -import devices from '../DevicesPage/devices.mock.json' -import type { Device } from './types' +import type { Device as PageDevice } from './types' + +import { GET_PHONE_QUERY } from '../../entities/device/api/device.graphql' +import type { + GetPhoneData, + GetPhoneVariables, + Device as ApiDevice, +} from '../../entities/device/model/types' import { DeviceMainCard } from './components/DeviceMainCard/DeviceMainCard' import { DeviceMapCard } from './components/DeviceMapCard/DeviceMapCard' @@ -13,17 +19,137 @@ import { DeviceStatsCards } from './components/DeviceStatsCards/DeviceStatsCards import './DevicePage.scss' -const typedDevices = devices as Device[] +function formatLocationDate(timestamp?: number) { + if (!timestamp) return 'Нет данных' + + return new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(timestamp * 1000)) +} + +function mapApiDeviceToPageDevice(device: ApiDevice): PageDevice { + const location = device.lastLocation + ? { + lat: device.lastLocation.lat, + lng: device.lastLocation.lng, + } + : undefined + + return { + id: device.id, + + model: 'АРМАФОН S3.3+', + factoryNumber: device.serial || 'Заводской номер не указан', + imei: device.imei || 'IMEI не указан', + imei2: device.imei2 || 'IMEI 2 не указан', + serialNumber: device.serial || undefined, + + employee: null, + + condition: 'ok', + connection: device.lastLocation ? 'online' : 'offline', + connectionText: device.lastLocation ? 'В сети' : 'Нет геопозиции', + + workTime: null, + registeredAt: undefined, + + image: undefined, + + location, + lastLocationAt: device.lastLocation + ? formatLocationDate(device.lastLocation.date) + : 'Нет данных', + + route: device.lastLocation + ? [ + { + lat: device.lastLocation.lat, + lng: device.lastLocation.lng, + time: formatLocationDate(device.lastLocation.date), + }, + ] + : [], + + permissions: { + wifi: true, + bluetooth: true, + gps: Boolean(device.lastLocation), + camera: true, + sim: true, + speaker: true, + }, + + statusIcons: { + gps: Boolean(device.lastLocation), + wifi: true, + bluetooth: true, + lock: false, + camera: true, + sim: true, + sound: true, + kiosk: false, + }, + + battery: undefined, + batteryMaxCapacity: undefined, + chargeCycles: undefined, + totalWorkTime: undefined, + mediumImpacts: undefined, +} +} export function DevicePage() { const { deviceId } = useParams() const navigate = useNavigate() - const device = useMemo(() => { - return typedDevices.find((item) => String(item.id) === deviceId) - }, [deviceId]) + const phoneId = Number(deviceId) - if (!device) { + const { data, loading, error } = useQuery( + GET_PHONE_QUERY, + { + variables: { + id: phoneId, + }, + skip: !phoneId, + fetchPolicy: 'cache-and-network', + }, + ) + + const device = useMemo(() => { + if (!data?.getPhone) return null + + return mapApiDeviceToPageDevice(data.getPhone) + }, [data]) + + if (!phoneId) { + return ( +
+
+

Некорректный ID устройства

+ + +
+
+ ) + } + + if (loading && !device) { + return ( +
+
+

Загрузка устройства...

+
+
+ ) + } + + if (error || !device) { return (
@@ -40,13 +166,13 @@ export function DevicePage() { return (
- - Все устройства - + + Все устройства + - / - {device.factoryNumber} -
+ / + {device.factoryNumber} +
diff --git a/mdm-front/src/pages/DevicePage/types.ts b/mdm-front/src/pages/DevicePage/types.ts index eef96e8..0e0c30b 100644 --- a/mdm-front/src/pages/DevicePage/types.ts +++ b/mdm-front/src/pages/DevicePage/types.ts @@ -6,6 +6,7 @@ export type Device = { factoryNumber: string model?: string imei: string + imei2: string serialNumber?: string workTime: string | null employee: string | null diff --git a/mdm-front/src/pages/DevicesPage/DevicesPage.scss b/mdm-front/src/pages/DevicesPage/DevicesPage.scss index 67ff3da..5aeb31a 100644 --- a/mdm-front/src/pages/DevicesPage/DevicesPage.scss +++ b/mdm-front/src/pages/DevicesPage/DevicesPage.scss @@ -5,6 +5,9 @@ flex-direction: column; gap: 10px; flex: 1; + min-height: 0; + height: 100%; + //overflow: hidden; } .devices-table-container { @@ -17,6 +20,10 @@ .devices-table-filter-container { display: flex; flex: 1; + min-height: 0; + min-width: 0; + gap: 12px; + overflow: hidden; } .devices-table-card { @@ -24,6 +31,26 @@ overflow: auto; border-radius: 20px; background: #ffffff; + scrollbar-width: thin; + scrollbar-color: $gray50 transparent; + + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: $gray30; + border-radius: 999px; + } + + &::-webkit-scrollbar-thumb:hover { + background: $gray50; + } } .devices-table { @@ -31,12 +58,14 @@ border-collapse: collapse; table-layout: fixed; - &__row{ + &__row { transition: .2s ease; cursor: pointer; - &:hover{ + + &:hover { background-color: $gray20; - .devices-map-btn{ + + .devices-map-btn { background-color: white; } } @@ -135,10 +164,24 @@ display: inline-flex; align-items: center; gap: 8px; - + text-transform: uppercase; color: #111827; - font-size: 18px; - font-weight: 400; + font-size: 14px; + padding: 0px 8px; + border-radius: 12px; + font-weight: 550; + &--green{ + background-color: hsla(128, 56%, 45%, 0.15); + color: $green; + } + &--gray{ + background-color: $color-bg; + color: $gray50; + } + &--red{ + background-color: hsla(0, 100%, 43%, 0.15); + color: $red; + } } .devices-dot { diff --git a/mdm-front/src/pages/DevicesPage/DevicesPage.tsx b/mdm-front/src/pages/DevicesPage/DevicesPage.tsx index db30ca9..37905e4 100644 --- a/mdm-front/src/pages/DevicesPage/DevicesPage.tsx +++ b/mdm-front/src/pages/DevicesPage/DevicesPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' +import { useApolloClient, useQuery } from '@apollo/client/react' import { Bluetooth, Camera, @@ -12,170 +13,260 @@ import { Store, } from 'lucide-react' -import devices from './devices.mock.json' import './DevicesPage.scss' + import { DevicesTabs } from './components/DevicesTabs/DevicesTabs' import { DevicesToolbar } from './components/DevicesToolbar/DevicesToolbar' import { DevicesFiltersPanel } from './components/DevicesFiltersPanel/DevicesFiltersPanel' -type DeviceCondition = 'ok' | 'inspection' -type DeviceConnection = 'online' | 'offline' | 'offlineDanger' +import { GET_PHONES_PAGE_QUERY } from '../../entities/device/api/device.graphql' +import type { Device, GetPhonesPageData, GetPhonesPageVariables } from '../../entities/device/model/types' -type Device = { - id: number - factoryNumber: string - imei: string - workTime: string | null - employee: string | null - condition: DeviceCondition - connection: DeviceConnection - connectionText: string - statusIcons: { - gps: boolean - wifi: boolean - bluetooth: boolean - lock: boolean - camera: boolean - sim: boolean - sound: boolean - kiosk: boolean - } -} +function formatLocationDate(timestamp: number) { + if (!timestamp) return 'Нет данных' -const typedDevices = devices as Device[] - -const conditionText: Record = { - ok: 'Исправно', - inspection: 'Требует осмотра', -} - -function getDotClass(status: DeviceCondition | DeviceConnection) { - if (status === 'ok' || status === 'online') return 'devices-dot devices-dot--green' - if (status === 'inspection' || status === 'offlineDanger') return 'devices-dot devices-dot--red' - - return 'devices-dot devices-dot--gray' + return new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(timestamp * 1000)) } export function DevicesPage() { const [isFiltersOpen, setIsFiltersOpen] = useState(true) - const navigate = useNavigate() + const client = useApolloClient() + + const [currentPage, setCurrentPage] = useState(1) + const [loadedPages, setLoadedPages] = useState([]) + const [loadedNextKeys, setLoadedNextKeys] = useState>([]) + const [isPageLoading, setIsPageLoading] = useState(false) + + const { + data: firstPageData, + loading: isFirstPageLoading, + error, + } = useQuery( + GET_PHONES_PAGE_QUERY, + { + variables: {}, + fetchPolicy: 'network-only', + }, + ) + + const firstPageDevices = firstPageData?.getPhonesPage.page ?? [] + const firstPageNextKey = firstPageData?.getPhonesPage.nextKey ?? null + + const pages = [firstPageDevices, ...loadedPages] + + const devices = pages[currentPage - 1] ?? [] + + const nextKey = + currentPage === 1 + ? firstPageNextKey + : loadedNextKeys[currentPage - 2] ?? null + + const loading = isFirstPageLoading || isPageLoading + + async function handleNextPage() { + const alreadyLoadedNextPage = pages[currentPage] + + if (alreadyLoadedNextPage) { + setCurrentPage((prev) => prev + 1) + return + } + + if (!nextKey) return + + setIsPageLoading(true) + + try { + const result = await client.query({ + query: GET_PHONES_PAGE_QUERY, + variables: { + key: nextKey, + }, + fetchPolicy: 'network-only', + }) + + const nextPageData = result.data?.getPhonesPage + + if (!nextPageData) { + return + } + + setLoadedPages((prev) => [ + ...prev, + nextPageData.page, + ]) + + setLoadedNextKeys((prev) => [ + ...prev, + nextPageData.nextKey, + ]) + + setCurrentPage((prev) => prev + 1) + } finally { + setIsPageLoading(false) + } + } + + function handlePrevPage() { + setCurrentPage((prev) => Math.max(1, prev - 1)) + } + return (
- + setIsFiltersOpen((prev) => !prev)} /> -
-
+ +
+
- - - - - - - - - - - - {typedDevices.map((device) => ( - navigate(`/devices/${device.id}`)}> - + {!loading && !error && devices.length === 0 && ( +
Устройства не найдены
+ )} - - - - - - - - - + {!loading && !error && ( +
IDИнформацияСостояниеСвязьСтатусы -
{device.id} -
-
{device.factoryNumber}
-
{device.imei}
- - {device.workTime && ( -
- - В работе: {device.workTime} -
- )} - - {device.employee && ( -
{device.employee}
- )} -
-
-
- - {conditionText[device.condition]} -
-
-
- - {device.connectionText} -
-
-
- - - - - - - - - -
-
- -
+ + + + + + + + - ))} - -
IDИнформацияСостояниеСвязьСтатусы
+ + + + {devices.map((device) => ( + navigate(`/devices/${device.id}`)} + > + {device.id} + + +
+
+ {device.serial || '—'} +
+ +
+ IMEI: {device.imei || '—'} +
+ +
+ IMEI 2: {device.imei2 || '—'} +
+ + {device.lastLocation && ( +
+ Последняя геопозиция:{' '} + {formatLocationDate(device.lastLocation.date)} +
+ )} +
+ + + +
+ + + {device.lastLocation? 'Исправно' : 'Требует ТО'} +
+ + + +
+ + {device.lastLocation ? 'В сети' : 'Нет данных'} +
+ + + +
+ + + + + + + + + + +
+ + + + + + + ))} + + + )}
- 1 из 10 + Страница {currentPage}
- + + + - -
+
diff --git a/mdm-front/src/pages/LoginPage/LoginPage.scss b/mdm-front/src/pages/LoginPage/LoginPage.scss new file mode 100644 index 0000000..37830eb --- /dev/null +++ b/mdm-front/src/pages/LoginPage/LoginPage.scss @@ -0,0 +1,93 @@ +@use '../../shared/styles/variables' as *; + +.login-page { + min-height: 100vh; + background: $color-bg; + + display: flex; + align-items: center; + justify-content: center; + + padding: 24px; +} + +.login-card { + width: 100%; + max-width: 420px; + + border-radius: 24px; + background: #ffffff; + padding: 32px; + + display: flex; + flex-direction: column; + gap: 18px; +} + +.login-card__header { + margin-bottom: 8px; + + p { + margin: 8px 0 0; + color: $gray50; + font-size: 16px; + font-weight: 450; + line-height: 1.4; + } +} + +.login-field { + display: flex; + flex-direction: column; + gap: 8px; + + span { + color: #30394b; + font-size: 14px; + font-weight: 500; + } + + input { + height: 44px; + border: 1px solid #dfe5ef; + border-radius: 14px; + padding: 0 14px; + + background: #f8fafc; + color: #111827; + font-size: 16px; + outline: none; + + &:focus { + border-color: $blue; + background: #ffffff; + } + } +} + +.login-error { + border-radius: 12px; + background: #ffecec; + color: $red; + padding: 10px 12px; + font-weight: 450; + font-size: 14px; +} + +.login-button { + height: 44px; + border: none; + border-radius: 14px; + background: $blue; + + color: #ffffff; + font-size: 15px; + font-weight: 600; + + cursor: pointer; + + &:disabled { + opacity: 0.65; + cursor: default; + } +} \ No newline at end of file diff --git a/mdm-front/src/pages/LoginPage/LoginPage.tsx b/mdm-front/src/pages/LoginPage/LoginPage.tsx new file mode 100644 index 0000000..e56d33b --- /dev/null +++ b/mdm-front/src/pages/LoginPage/LoginPage.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react' +import type { SubmitEvent } from 'react' +import { useMutation } from '@apollo/client/react' +import { SIGN_IN_MUTATION, CURRENT_USER_QUERY } from '../../features/auth/api/auth.graphql' + +import './LoginPage.scss' + +type LoginPageProps = { + onSuccess: () => void +} + +export function LoginPage({ onSuccess }: LoginPageProps) { + const [username, setUsername] = useState('User1') + const [password, setPassword] = useState('123456') + + const [signIn, { loading, error }] = useMutation(SIGN_IN_MUTATION, { + refetchQueries: [CURRENT_USER_QUERY], + onCompleted: () => { + onSuccess() + }, + }) + + const handleSubmit = (event: SubmitEvent) => { + event.preventDefault() + + signIn({ + variables: { + username, + password, + }, + }) + } + + return ( +
+
+
+ logo +

Авторизуйтесь для доступа к панели управления устройствами

+
+ + + + + + {error && ( +
+ Не удалось войти. Проверьте логин и пароль. +
+ )} + + +
+
+ ) +} \ No newline at end of file diff --git a/mdm-front/src/shared/api/apolloClient.ts b/mdm-front/src/shared/api/apolloClient.ts new file mode 100644 index 0000000..b11f0ea --- /dev/null +++ b/mdm-front/src/shared/api/apolloClient.ts @@ -0,0 +1,124 @@ +import { + ApolloClient, + from, + HttpLink, + InMemoryCache, + Observable, +} from '@apollo/client' +import { ErrorLink } from '@apollo/client/link/error' +import { CombinedGraphQLErrors } from '@apollo/client/errors' + +const httpLink = new HttpLink({ + uri: import.meta.env.VITE_GRAPHQL_API_URL, + credentials: 'include', +}) + +let isRefreshing = false +let pendingRequests: Array<() => void> = [] + +function resolvePendingRequests() { + pendingRequests.forEach((callback) => callback()) + pendingRequests = [] +} + +function isUnauthorizedError(error: unknown) { + if (!CombinedGraphQLErrors.is(error)) { + return false + } + + return error.errors.some((graphQLError) => { + return graphQLError.extensions?.code === 'Unauthorized' + }) +} + +async function refreshSession() { + const response = await fetch(import.meta.env.VITE_GRAPHQL_API_URL, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: ` + mutation RefreshSession { + refreshSession { + id + role + } + } + `, + }), + }) + + const result = await response.json() + + if (result.errors?.length || !result.data?.refreshSession) { + throw new Error('Refresh session failed') + } + + return result.data.refreshSession +} + +const errorLink = new ErrorLink(({ error, operation, forward }) => { + const isUnauthorized = isUnauthorizedError(error) + + if (!isUnauthorized) { + return + } + + /** + * Важно: если Unauthorized прилетел уже на refreshSession, + * повторять refresh нельзя, иначе можно уйти в цикл. + */ + if (operation.operationName === 'RefreshSession') { + window.dispatchEvent(new Event('auth:logout')) + return + } + + return new Observable((observer) => { + const retryRequest = () => { + forward(operation).subscribe({ + next: observer.next.bind(observer), + error: observer.error.bind(observer), + complete: observer.complete.bind(observer), + }) + } + + if (isRefreshing) { + pendingRequests.push(retryRequest) + return + } + + isRefreshing = true + + refreshSession() + .then(() => { + isRefreshing = false + resolvePendingRequests() + retryRequest() + }) + .catch((refreshError) => { + isRefreshing = false + pendingRequests = [] + + window.dispatchEvent(new Event('auth:logout')) + + observer.error(refreshError) + }) + }) +}) + +export const apolloClient = new ApolloClient({ + link: from([errorLink, httpLink]), + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + getPhonesPage: { + keyArgs: ['key'], + }, + }, + }, + }, + }), +}) \ No newline at end of file diff --git a/mdm-front/src/widgets/Sidebar/Sidebar.scss b/mdm-front/src/widgets/Sidebar/Sidebar.scss index c3a4005..89b2c8b 100644 --- a/mdm-front/src/widgets/Sidebar/Sidebar.scss +++ b/mdm-front/src/widgets/Sidebar/Sidebar.scss @@ -73,7 +73,7 @@ .sidebar__nav { display: flex; flex-direction: column; - gap: 4px; + //gap: 4px; } .sidebar__link { @@ -119,6 +119,7 @@ .sidebar__label { white-space: nowrap; overflow: hidden; + line-height: 1.1; transition: width 0.2s ease, opacity 0.2s ease; @@ -140,6 +141,7 @@ .wrap-btn__text { white-space: nowrap; overflow: hidden; + line-height: 1.1; transition: width 0.2s ease, opacity 0.2s ease; diff --git a/mdm-front/vite.config.ts b/mdm-front/vite.config.ts index 8b0f57b..9fef35b 100644 --- a/mdm-front/vite.config.ts +++ b/mdm-front/vite.config.ts @@ -4,4 +4,13 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + proxy: { + '/graphql': { + target: 'http://192.168.1.179:8080', + changeOrigin: true, + secure: false, + }, + }, + }, })