(null);
+
+ const { isConnected, lastError, lastMessage } = useWebSocket(sessionId);
+
+ // Инициализация сессии авторизации при маунте
+ useEffect(() => {
+ const initAuth = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const data = await api.initDesktopAuth();
+ setSessionId(data.session_id);
+ setQrLink(data.qr_url);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Неизвестная ошибка");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ initAuth();
+ }, []);
+
+ // Обработка события успешной авторизации через WebSocket
+ useEffect(() => {
+ if (lastMessage && lastMessage.event === "auth_success") {
+ const data = lastMessage.data as AuthSuccessData;
+ const { token, user } = data;
+ console.log("🎉 Auth Success:", user);
+ setToken(token);
+ setUser(user);
+ message.success("Вход выполнен!");
+ navigate("/web/dashboard");
+ }
+ }, [lastMessage, setToken, setUser, navigate]);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error || lastError) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ Авторизация
+
+ Отсканируйте QR код в мобильном приложении для входа
+
+
+ {qrLink && (
+
+
+
+ )}
+
+
+
+ Status:{" "}
+ {isConnected ? (
+ Connected
+ ) : (
+ Disconnected
+ )}
+
+
Session: {sessionId}
+ {lastError &&
Error: {lastError}
}
+
+
+
+ );
+};
+
+```
+
+# ===================================================================
+# Файл: src/pages/desktop/auth/MobileBrowserStub.tsx
+# ===================================================================
+
+```
+import React from 'react';
+import { Result, Button } from 'antd';
+import { MobileOutlined } from '@ant-design/icons';
+
+/**
+ * Заглушка для мобильных браузеров
+ * Отображается когда пользователь пытается открыть десктопную версию на мобильном устройстве
+ */
+export const MobileBrowserStub: React.FC = () => {
+ const handleRedirect = () => {
+ window.location.href = '/';
+ };
+
+ return (
+
+ }
+ title="Десктопная версия недоступна"
+ subTitle="Пожалуйста, используйте мобильное приложение или откройте сайт на десктопном устройстве"
+ extra={[
+ ,
+ ]}
+ />
+
+ );
+};
+
+```
+
+# ===================================================================
+# Файл: src/pages/desktop/dashboard/InvoicesDashboard.tsx
+# ===================================================================
+
+```
+import React from "react";
+import { Typography, Card, List, Empty } from "antd";
+import { DragDropZone } from "../../../components/DragDropZone";
+
+const { Title } = Typography;
+
+/**
+ * Дашборд черновиков для десктопной версии
+ * Содержит зону для загрузки файлов и список черновиков
+ */
+export const InvoicesDashboard: React.FC = () => {
+ const handleDrop = (files: File[]) => {
+ console.log("Файлы загружены:", files);
+ // TODO: Добавить логику обработки файлов
+ };
+
+ // Заглушка списка черновиков
+ const mockDrafts = [
+ {
+ id: "1",
+ title: "Черновик #1",
+ date: "2024-01-15",
+ status: "В работе",
+ },
+ {
+ id: "2",
+ title: "Черновик #2",
+ date: "2024-01-14",
+ status: "Черновик",
+ },
+ ];
+
+ return (
+
+
Черновики
+
+ {/* Зона для загрузки файлов */}
+
+
+
+
+ {/* Список черновиков (заглушка) */}
+
+ (
+
+
+
+ )}
+ locale={{
+ emptyText: (
+
+ ),
+ }}
+ />
+
+
+ );
+};
+
+```
+
# ===================================================================
# Файл: src/services/api.ts
# ===================================================================
@@ -9008,6 +9811,7 @@ export const SettingsPage: React.FC = () => {
```
import axios from 'axios';
import { notification } from 'antd';
+import { useAuthStore } from '../stores/authStore';
import type {
CatalogItem,
CreateInvoiceRequest,
@@ -9038,6 +9842,12 @@ import type {
GetPhotosResponse
} from './types';
+// Интерфейс для ответа метода инициализации десктопной авторизации
+export interface InitDesktopAuthResponse {
+ session_id: string;
+ qr_url: string;
+}
+
// Базовый URL
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
@@ -9065,20 +9875,30 @@ const apiClient = axios.create({
},
});
-// --- Request Interceptor (Авторизация через initData) ---
+// --- Request Interceptor (Авторизация через JWT или initData) ---
apiClient.interceptors.request.use((config) => {
- const initData = tg?.initData;
-
- // Если initData пустая — мы не в Telegram. Блокируем запрос.
- if (!initData) {
- console.error('Запрос заблокирован: приложение запущено вне Telegram.');
- return Promise.reject(new Error('MISSING_TELEGRAM_DATA'));
+ // Шаг 1: Whitelist - пропускаем запросы к инициализации десктопной авторизации
+ if (config.url?.endsWith('/auth/init-desktop')) {
+ return config;
}
- // Устанавливаем заголовок согласно новым требованиям
- config.headers['Authorization'] = `Bearer ${initData}`;
-
- return config;
+ // Шаг 2: Desktop Auth - проверяем JWT токен из authStore
+ const jwtToken = useAuthStore.getState().token;
+ if (jwtToken) {
+ config.headers['Authorization'] = `Bearer ${jwtToken}`;
+ return config;
+ }
+
+ // Шаг 3: Mobile Auth - проверяем Telegram initData
+ const initData = tg?.initData;
+ if (initData) {
+ config.headers['Authorization'] = `Bearer ${initData}`;
+ return config;
+ }
+
+ // Шаг 4: Block - если нет ни JWT, ни initData, отклоняем запрос
+ console.error('Запрос заблокирован: отсутствуют данные авторизации.');
+ return Promise.reject(new Error('MISSING_AUTH'));
});
// --- Response Interceptor (Обработка ошибок и уведомления) ---
@@ -9101,8 +9921,8 @@ apiClient.interceptors.response.use(
window.dispatchEvent(new Event(MAINTENANCE_EVENT));
}
- // Если запрос был отменен нами (нет initData), не выводим стандартную ошибку API
- if (error.message === 'MISSING_TELEGRAM_DATA') {
+ // Если запрос был отменен нами (нет авторизации), не выводим стандартную ошибку API
+ if (error.message === 'MISSING_AUTH') {
return Promise.reject(error);
}
@@ -9298,6 +10118,13 @@ export const api = {
regenerateDraftFromPhoto: async (id: string): Promise => {
await apiClient.post(`/photos/${id}/regenerate`);
},
+
+ // --- Десктопная авторизация ---
+
+ initDesktopAuth: async (): Promise => {
+ const { data } = await apiClient.post('/auth/init-desktop');
+ return data;
+ },
};
@@ -9619,6 +10446,104 @@ export interface GetPhotosResponse {
}
```
+# ===================================================================
+# Файл: src/stores/authStore.ts
+# ===================================================================
+
+```
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+interface User {
+ id: string;
+ username: string;
+ email?: string;
+ role?: string;
+}
+
+interface AuthState {
+ token: string | null;
+ isAuthenticated: boolean;
+ user: User | null;
+ setToken: (token: string) => void;
+ setUser: (user: User) => void;
+ logout: () => void;
+}
+
+/**
+ * Хранилище состояния авторизации
+ * Сохраняет токен и данные пользователя в localStorage
+ */
+export const useAuthStore = create()(
+ persist(
+ (set) => ({
+ token: null,
+ isAuthenticated: false,
+ user: null,
+
+ setToken: (token: string) => {
+ set({ token, isAuthenticated: true });
+ },
+
+ setUser: (user: User) => {
+ set({ user });
+ },
+
+ logout: () => {
+ set({ token: null, isAuthenticated: false, user: null });
+ },
+ }),
+ {
+ name: 'auth-storage',
+ partialize: (state) => ({
+ token: state.token,
+ isAuthenticated: state.isAuthenticated,
+ user: state.user,
+ }),
+ }
+ )
+);
+
+```
+
+# ===================================================================
+# Файл: src/stores/uiStore.ts
+# ===================================================================
+
+```
+import { create } from 'zustand';
+
+interface UIState {
+ // Выбранный сервер (заглушка для будущего функционала)
+ selectedServer: string | null;
+ sidebarCollapsed: boolean;
+ setSelectedServer: (server: string | null) => void;
+ toggleSidebar: () => void;
+ setSidebarCollapsed: (collapsed: boolean) => void;
+}
+
+/**
+ * Хранилище UI состояния десктопной версии
+ */
+export const useUIStore = create((set) => ({
+ selectedServer: null,
+ sidebarCollapsed: false,
+
+ setSelectedServer: (server: string | null) => {
+ set({ selectedServer: server });
+ },
+
+ toggleSidebar: () => {
+ set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }));
+ },
+
+ setSidebarCollapsed: (collapsed: boolean) => {
+ set({ sidebarCollapsed: collapsed });
+ },
+}));
+
+```
+
# ===================================================================
# Файл: src/vite-env.d.ts
# ===================================================================
diff --git a/rmser-view/src/App.tsx b/rmser-view/src/App.tsx
index e780c29..a07a3ea 100644
--- a/rmser-view/src/App.tsx
+++ b/rmser-view/src/App.tsx
@@ -1,5 +1,11 @@
import { useEffect, useState } from "react";
-import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
+import {
+ BrowserRouter,
+ Routes,
+ Route,
+ Navigate,
+ useLocation,
+} from "react-router-dom";
import { Result, Button } from "antd";
import { Providers } from "./components/layout/Providers";
import { AppLayout } from "./components/layout/AppLayout";
@@ -10,6 +16,12 @@ import { DraftsList } from "./pages/DraftsList";
import { SettingsPage } from "./pages/SettingsPage";
import { UNAUTHORIZED_EVENT, MAINTENANCE_EVENT } from "./services/api";
import MaintenancePage from "./pages/MaintenancePage";
+import { usePlatform } from "./hooks/usePlatform";
+import { useAuthStore } from "./stores/authStore";
+import { DesktopAuthScreen } from "./pages/desktop/auth/DesktopAuthScreen";
+import { MobileBrowserStub } from "./pages/desktop/auth/MobileBrowserStub";
+import { DesktopLayout } from "./layouts/DesktopLayout/DesktopLayout";
+import { InvoicesDashboard } from "./pages/desktop/dashboard/InvoicesDashboard";
// Компонент-заглушка для внешних браузеров
const NotInTelegramScreen = () => (
@@ -36,14 +48,32 @@ const NotInTelegramScreen = () => (
);
-function App() {
+// Protected Route для десктопной версии
+const ProtectedDesktopRoute = ({ children }: { children: React.ReactNode }) => {
+ const { isAuthenticated } = useAuthStore();
+ const location = useLocation();
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return <>{children}>;
+};
+
+// Внутренний компонент с логикой, которая требует контекста роутера
+const AppContent = () => {
const [isUnauthorized, setIsUnauthorized] = useState(false);
const [isMaintenance, setIsMaintenance] = useState(false);
const tg = window.Telegram?.WebApp;
+ const platform = usePlatform();
+ const location = useLocation(); // Теперь это безопасно, т.к. мы внутри BrowserRouter
// Проверяем, есть ли данные от Telegram
const isInTelegram = !!tg?.initData;
+ // Проверяем, находимся ли мы на десктопном роуте
+ const isDesktopRoute = location.pathname.startsWith("/web");
+
useEffect(() => {
const handleUnauthorized = () => setIsUnauthorized(true);
const handleMaintenance = () => setIsMaintenance(true);
@@ -51,7 +81,7 @@ function App() {
window.addEventListener(MAINTENANCE_EVENT, handleMaintenance);
if (tg) {
- tg.expand(); // Расширяем приложение на все окно
+ tg.expand();
}
return () => {
@@ -60,11 +90,16 @@ function App() {
};
}, [tg]);
- // Если открыто не в Telegram — блокируем всё
- if (!isInTelegram) {
+ // Если открыто не в Telegram и это не десктопный роут — блокируем всё
+ if (!isInTelegram && !isDesktopRoute) {
return ;
}
+ // Если это десктопный роут и платформа - мобильный браузер
+ if (isDesktopRoute && platform === "MobileBrowser") {
+ return ;
+ }
+
// Если бэкенд вернул 401
if (isUnauthorized) {
return (
@@ -90,20 +125,41 @@ function App() {
return ;
}
+ return (
+
+ {/* Мобильные роуты (существующие) */}
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* Десктопные роуты */}
+ } />
+ }>
+
+
+
+ }
+ />
+
+
+ );
+};
+
+// Главный компонент-обертка
+function App() {
return (
-
- }>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
+
);
diff --git a/rmser-view/src/components/DragDropZone.tsx b/rmser-view/src/components/DragDropZone.tsx
new file mode 100644
index 0000000..28d1224
--- /dev/null
+++ b/rmser-view/src/components/DragDropZone.tsx
@@ -0,0 +1,96 @@
+import React from 'react';
+import { useDropzone } from 'react-dropzone';
+import { InboxOutlined } from '@ant-design/icons';
+import { Typography } from 'antd';
+
+const { Text } = Typography;
+
+interface DragDropZoneProps {
+ onDrop: (files: File[]) => void;
+ accept?: Record;
+ maxSize?: number;
+ maxFiles?: number;
+ disabled?: boolean;
+ className?: string;
+ children?: React.ReactNode;
+}
+
+/**
+ * Компонент зоны перетаскивания файлов
+ * Обертка над react-dropzone с Ant Design стилизацией
+ */
+export const DragDropZone: React.FC = ({
+ onDrop,
+ accept = {
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
+ 'application/vnd.ms-excel': ['.xls'],
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp'],
+ },
+ maxSize = 10 * 1024 * 1024, // 10MB по умолчанию
+ maxFiles = 10,
+ disabled = false,
+ className = '',
+ children,
+}) => {
+ const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
+ onDrop,
+ accept,
+ maxSize,
+ maxFiles,
+ disabled,
+ });
+
+ const getBorderColor = () => {
+ if (isDragReject) return '#ff4d4f';
+ if (isDragActive) return '#1890ff';
+ return '#d9d9d9';
+ };
+
+ const getBackgroundColor = () => {
+ if (isDragActive) return '#e6f7ff';
+ if (disabled) return '#f5f5f5';
+ return '#fafafa';
+ };
+
+ return (
+
+
+ {children || (
+
+
+
+ {isDragActive ? (
+
Отпустите файлы здесь
+ ) : (
+
+ Перетащите файлы сюда или нажмите для выбора
+
+
+ Поддерживаются: .xlsx, .xls, изображения (макс. {maxSize / 1024 / 1024}MB)
+
+
+ )}
+
+
+ )}
+
+ );
+};
diff --git a/rmser-view/src/hooks/usePlatform.ts b/rmser-view/src/hooks/usePlatform.ts
new file mode 100644
index 0000000..e5deba5
--- /dev/null
+++ b/rmser-view/src/hooks/usePlatform.ts
@@ -0,0 +1,33 @@
+import { useMemo } from 'react';
+
+export type Platform = 'MobileApp' | 'Desktop' | 'MobileBrowser';
+
+/**
+ * Хук для определения текущей платформы
+ * MobileApp - если есть специфические признаки мобильного приложения
+ * Desktop - если это десктопный браузер
+ * MobileBrowser - если это мобильный браузер
+ */
+export const usePlatform = (): Platform => {
+ return useMemo(() => {
+ const userAgent = navigator.userAgent;
+
+ // Проверка на мобильное приложение (специфические признаки)
+ // Можно добавить дополнительные проверки для конкретных приложений
+ const isMobileApp = /rmser-app|mobile-app|cordova|phonegap/i.test(userAgent);
+
+ if (isMobileApp) {
+ return 'MobileApp';
+ }
+
+ // Проверка на мобильный браузер
+ const isMobileBrowser = /android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
+
+ if (isMobileBrowser) {
+ return 'MobileBrowser';
+ }
+
+ // По умолчанию - десктоп
+ return 'Desktop';
+ }, []);
+};
diff --git a/rmser-view/src/hooks/useWebSocket.ts b/rmser-view/src/hooks/useWebSocket.ts
new file mode 100644
index 0000000..ec785b4
--- /dev/null
+++ b/rmser-view/src/hooks/useWebSocket.ts
@@ -0,0 +1,76 @@
+import { useEffect, useState, useRef } from 'react';
+
+const apiUrl = import.meta.env.VITE_API_URL || '';
+
+// Определяем базовый URL для WS (меняем http->ws, https->wss)
+const getWsUrl = () => {
+ let baseUrl = apiUrl;
+ if (baseUrl.startsWith('/')) {
+ baseUrl = window.location.origin;
+ } else if (!baseUrl) {
+ baseUrl = 'http://localhost:8080';
+ }
+
+ // Заменяем протокол
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const host = baseUrl.replace(/^http(s)?:\/\//, '');
+ // Важно: путь /socket.io/ оставлен для совместимости с Nginx конфигом
+ return `${protocol}//${host}/socket.io/`;
+};
+
+interface WsEvent {
+ event: string;
+ data: unknown;
+}
+
+export const useWebSocket = (sessionId: string | null) => {
+ const [isConnected, setIsConnected] = useState(false);
+ const [lastError, setLastError] = useState(null);
+ const [lastMessage, setLastMessage] = useState(null);
+
+ const wsRef = useRef(null);
+
+ useEffect(() => {
+ if (!sessionId) return;
+
+ const url = `${getWsUrl()}?session_id=${sessionId}`;
+ console.log('🔌 Connecting Native WS:', url);
+
+ const ws = new WebSocket(url);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ console.log('✅ WS Connected');
+ setIsConnected(true);
+ setLastError(null);
+ };
+
+ ws.onclose = (event) => {
+ console.log('⚠️ WS Closed', event.code, event.reason);
+ setIsConnected(false);
+ };
+
+ ws.onerror = (error) => {
+ console.error('❌ WS Error', error);
+ setLastError('Connection error');
+ setIsConnected(false);
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const parsed = JSON.parse(event.data);
+ console.log('📨 WS Message:', parsed);
+ setLastMessage(parsed);
+ } catch {
+ console.error('Failed to parse WS message', event.data);
+ }
+ };
+
+ return () => {
+ console.log('🧹 WS Cleanup');
+ ws.close();
+ };
+ }, [sessionId]);
+
+ return { isConnected, lastError, lastMessage };
+};
diff --git a/rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx b/rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx
new file mode 100644
index 0000000..36ca401
--- /dev/null
+++ b/rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import { Layout, Space, Avatar, Dropdown, Button } from 'antd';
+import { UserOutlined, LogoutOutlined } from '@ant-design/icons';
+import { useAuthStore } from '../../stores/authStore';
+
+const { Header } = Layout;
+
+/**
+ * Header для десктопной версии
+ * Содержит логотип, заглушку выбора сервера и аватар пользователя
+ */
+export const DesktopHeader: React.FC = () => {
+ const { user, logout } = useAuthStore();
+
+ const handleLogout = () => {
+ logout();
+ window.location.href = '/web';
+ };
+
+ const userMenuItems = [
+ {
+ key: 'logout',
+ label: 'Выйти',
+ icon: ,
+ onClick: handleLogout,
+ },
+ ];
+
+ return (
+
+ );
+};
diff --git a/rmser-view/src/layouts/DesktopLayout/DesktopLayout.tsx b/rmser-view/src/layouts/DesktopLayout/DesktopLayout.tsx
new file mode 100644
index 0000000..08eaa01
--- /dev/null
+++ b/rmser-view/src/layouts/DesktopLayout/DesktopLayout.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import { Layout } from 'antd';
+import { Outlet } from 'react-router-dom';
+import { DesktopHeader } from './DesktopHeader.tsx';
+
+const { Content } = Layout;
+
+/**
+ * Основной layout для десктопной версии
+ * Использует Ant Design Layout с фиксированным Header
+ */
+export const DesktopLayout: React.FC = () => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx b/rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx
new file mode 100644
index 0000000..aa58a2f
--- /dev/null
+++ b/rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx
@@ -0,0 +1,178 @@
+import React, { useEffect, useState } from "react";
+import { Card, Typography, Spin, Alert, message, Button } from "antd";
+import { QRCodeSVG } from "qrcode.react";
+import { SendOutlined } from "@ant-design/icons";
+import { useNavigate } from "react-router-dom";
+import { useWebSocket } from "../../../hooks/useWebSocket";
+import { useAuthStore } from "../../../stores/authStore";
+import { api } from "../../../services/api";
+
+const { Title, Paragraph } = Typography;
+
+interface AuthSuccessData {
+ token: string;
+ user: {
+ id: string;
+ username: string;
+ email?: string;
+ role?: string;
+ };
+}
+
+/**
+ * Экран авторизации для десктопной версии
+ * Отображает QR код для авторизации через мобильное приложение
+ */
+export const DesktopAuthScreen: React.FC = () => {
+ const navigate = useNavigate();
+ const { setToken, setUser } = useAuthStore();
+ const [sessionId, setSessionId] = useState(null);
+ const [qrLink, setQrLink] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const { isConnected, lastError, lastMessage } = useWebSocket(sessionId);
+
+ // Инициализация сессии авторизации при маунте
+ useEffect(() => {
+ const initAuth = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const data = await api.initDesktopAuth();
+ setSessionId(data.session_id);
+ setQrLink(data.qr_url);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Неизвестная ошибка");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ initAuth();
+ }, []);
+
+ // Обработка события успешной авторизации через WebSocket
+ useEffect(() => {
+ if (lastMessage && lastMessage.event === "auth_success") {
+ const data = lastMessage.data as AuthSuccessData;
+ const { token, user } = data;
+ console.log("🎉 Auth Success:", user);
+ setToken(token);
+ setUser(user);
+ message.success("Вход выполнен!");
+ navigate("/web/dashboard");
+ }
+ }, [lastMessage, setToken, setUser, navigate]);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error || lastError) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ Авторизация
+
+ Отсканируйте QR код для авторизации через Телеграмм
+
+
+ {qrLink && (
+
+
+
+ )}
+
+ {qrLink && (
+ }
+ style={{ marginTop: 16 }}
+ >
+ Открыть в Telegram Desktop
+
+ )}
+
+
+
+ Status:{" "}
+ {isConnected ? (
+ Connected
+ ) : (
+ Disconnected
+ )}
+
+
Session: {sessionId}
+ {lastError &&
Error: {lastError}
}
+
+
+
+ );
+};
diff --git a/rmser-view/src/pages/desktop/auth/MobileBrowserStub.tsx b/rmser-view/src/pages/desktop/auth/MobileBrowserStub.tsx
new file mode 100644
index 0000000..303c0b1
--- /dev/null
+++ b/rmser-view/src/pages/desktop/auth/MobileBrowserStub.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import { Result, Button } from 'antd';
+import { MobileOutlined } from '@ant-design/icons';
+
+/**
+ * Заглушка для мобильных браузеров
+ * Отображается когда пользователь пытается открыть десктопную версию на мобильном устройстве
+ */
+export const MobileBrowserStub: React.FC = () => {
+ const handleRedirect = () => {
+ window.location.href = '/';
+ };
+
+ return (
+
+ }
+ title="Десктопная версия недоступна"
+ subTitle="Пожалуйста, используйте мобильное приложение или откройте сайт на десктопном устройстве"
+ extra={[
+ ,
+ ]}
+ />
+
+ );
+};
diff --git a/rmser-view/src/pages/desktop/dashboard/InvoicesDashboard.tsx b/rmser-view/src/pages/desktop/dashboard/InvoicesDashboard.tsx
new file mode 100644
index 0000000..0cbf0b9
--- /dev/null
+++ b/rmser-view/src/pages/desktop/dashboard/InvoicesDashboard.tsx
@@ -0,0 +1,66 @@
+import React from "react";
+import { Typography, Card, List, Empty } from "antd";
+import { DragDropZone } from "../../../components/DragDropZone";
+
+const { Title } = Typography;
+
+/**
+ * Дашборд черновиков для десктопной версии
+ * Содержит зону для загрузки файлов и список черновиков
+ */
+export const InvoicesDashboard: React.FC = () => {
+ const handleDrop = (files: File[]) => {
+ console.log("Файлы загружены:", files);
+ // TODO: Добавить логику обработки файлов
+ };
+
+ // Заглушка списка черновиков
+ const mockDrafts = [
+ {
+ id: "1",
+ title: "Черновик #1",
+ date: "2024-01-15",
+ status: "В работе",
+ },
+ {
+ id: "2",
+ title: "Черновик #2",
+ date: "2024-01-14",
+ status: "Черновик",
+ },
+ ];
+
+ return (
+
+
Черновики
+
+ {/* Зона для загрузки файлов */}
+
+
+
+
+ {/* Список черновиков (заглушка) */}
+
+ (
+
+
+
+ )}
+ locale={{
+ emptyText: (
+
+ ),
+ }}
+ />
+
+
+ );
+};
diff --git a/rmser-view/src/services/api.ts b/rmser-view/src/services/api.ts
index 1364119..c95c2a4 100644
--- a/rmser-view/src/services/api.ts
+++ b/rmser-view/src/services/api.ts
@@ -1,5 +1,6 @@
import axios from 'axios';
import { notification } from 'antd';
+import { useAuthStore } from '../stores/authStore';
import type {
CatalogItem,
CreateInvoiceRequest,
@@ -30,6 +31,12 @@ import type {
GetPhotosResponse
} from './types';
+// Интерфейс для ответа метода инициализации десктопной авторизации
+export interface InitDesktopAuthResponse {
+ session_id: string;
+ qr_url: string;
+}
+
// Базовый URL
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
@@ -57,20 +64,30 @@ const apiClient = axios.create({
},
});
-// --- Request Interceptor (Авторизация через initData) ---
+// --- Request Interceptor (Авторизация через JWT или initData) ---
apiClient.interceptors.request.use((config) => {
- const initData = tg?.initData;
-
- // Если initData пустая — мы не в Telegram. Блокируем запрос.
- if (!initData) {
- console.error('Запрос заблокирован: приложение запущено вне Telegram.');
- return Promise.reject(new Error('MISSING_TELEGRAM_DATA'));
+ // Шаг 1: Whitelist - пропускаем запросы к инициализации десктопной авторизации
+ if (config.url?.endsWith('/auth/init-desktop')) {
+ return config;
}
- // Устанавливаем заголовок согласно новым требованиям
- config.headers['Authorization'] = `Bearer ${initData}`;
-
- return config;
+ // Шаг 2: Desktop Auth - проверяем JWT токен из authStore
+ const jwtToken = useAuthStore.getState().token;
+ if (jwtToken) {
+ config.headers['Authorization'] = `Bearer ${jwtToken}`;
+ return config;
+ }
+
+ // Шаг 3: Mobile Auth - проверяем Telegram initData
+ const initData = tg?.initData;
+ if (initData) {
+ config.headers['Authorization'] = `Bearer ${initData}`;
+ return config;
+ }
+
+ // Шаг 4: Block - если нет ни JWT, ни initData, отклоняем запрос
+ console.error('Запрос заблокирован: отсутствуют данные авторизации.');
+ return Promise.reject(new Error('MISSING_AUTH'));
});
// --- Response Interceptor (Обработка ошибок и уведомления) ---
@@ -93,8 +110,8 @@ apiClient.interceptors.response.use(
window.dispatchEvent(new Event(MAINTENANCE_EVENT));
}
- // Если запрос был отменен нами (нет initData), не выводим стандартную ошибку API
- if (error.message === 'MISSING_TELEGRAM_DATA') {
+ // Если запрос был отменен нами (нет авторизации), не выводим стандартную ошибку API
+ if (error.message === 'MISSING_AUTH') {
return Promise.reject(error);
}
@@ -290,5 +307,12 @@ export const api = {
regenerateDraftFromPhoto: async (id: string): Promise => {
await apiClient.post(`/photos/${id}/regenerate`);
},
+
+ // --- Десктопная авторизация ---
+
+ initDesktopAuth: async (): Promise => {
+ const { data } = await apiClient.post('/auth/init-desktop');
+ return data;
+ },
};
diff --git a/rmser-view/src/stores/authStore.ts b/rmser-view/src/stores/authStore.ts
new file mode 100644
index 0000000..21fb167
--- /dev/null
+++ b/rmser-view/src/stores/authStore.ts
@@ -0,0 +1,52 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+interface User {
+ id: string;
+ username: string;
+ email?: string;
+ role?: string;
+}
+
+interface AuthState {
+ token: string | null;
+ isAuthenticated: boolean;
+ user: User | null;
+ setToken: (token: string) => void;
+ setUser: (user: User) => void;
+ logout: () => void;
+}
+
+/**
+ * Хранилище состояния авторизации
+ * Сохраняет токен и данные пользователя в localStorage
+ */
+export const useAuthStore = create()(
+ persist(
+ (set) => ({
+ token: null,
+ isAuthenticated: false,
+ user: null,
+
+ setToken: (token: string) => {
+ set({ token, isAuthenticated: true });
+ },
+
+ setUser: (user: User) => {
+ set({ user });
+ },
+
+ logout: () => {
+ set({ token: null, isAuthenticated: false, user: null });
+ },
+ }),
+ {
+ name: 'auth-storage',
+ partialize: (state) => ({
+ token: state.token,
+ isAuthenticated: state.isAuthenticated,
+ user: state.user,
+ }),
+ }
+ )
+);
diff --git a/rmser-view/src/stores/uiStore.ts b/rmser-view/src/stores/uiStore.ts
new file mode 100644
index 0000000..196ecd0
--- /dev/null
+++ b/rmser-view/src/stores/uiStore.ts
@@ -0,0 +1,30 @@
+import { create } from 'zustand';
+
+interface UIState {
+ // Выбранный сервер (заглушка для будущего функционала)
+ selectedServer: string | null;
+ sidebarCollapsed: boolean;
+ setSelectedServer: (server: string | null) => void;
+ toggleSidebar: () => void;
+ setSidebarCollapsed: (collapsed: boolean) => void;
+}
+
+/**
+ * Хранилище UI состояния десктопной версии
+ */
+export const useUIStore = create((set) => ({
+ selectedServer: null,
+ sidebarCollapsed: false,
+
+ setSelectedServer: (server: string | null) => {
+ set({ selectedServer: server });
+ },
+
+ toggleSidebar: () => {
+ set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }));
+ },
+
+ setSidebarCollapsed: (collapsed: boolean) => {
+ set({ sidebarCollapsed: collapsed });
+ },
+}));