mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
2801-есть десктоп-версия. реализован ws для авторизации через тг-бота
This commit is contained in:
@@ -19,4 +19,12 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /socket.io/ {
|
||||
proxy_pass http://app:8080; # Или как называется твой контейнер бэка
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
94
rmser-view/package-lock.json
generated
94
rmser-view/package-lock.json
generated
@@ -15,8 +15,10 @@
|
||||
"axios": "^1.13.2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
@@ -2744,6 +2746,15 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/attr-accept": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
|
||||
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
@@ -3392,6 +3403,18 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
||||
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
@@ -3718,7 +3741,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -3837,6 +3859,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -3939,6 +3973,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -4082,6 +4125,23 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -4098,6 +4158,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/raf-schd": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||
@@ -4126,6 +4195,23 @@
|
||||
"react": "^19.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dropzone": {
|
||||
"version": "14.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
|
||||
"integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"attr-accept": "^2.2.4",
|
||||
"file-selector": "^2.1.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
@@ -4409,6 +4495,12 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
"axios": "^1.13.2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
function App() {
|
||||
// Protected Route для десктопной версии
|
||||
const ProtectedDesktopRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const location = useLocation();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/web" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
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 <NotInTelegramScreen />;
|
||||
}
|
||||
|
||||
// Если это десктопный роут и платформа - мобильный браузер
|
||||
if (isDesktopRoute && platform === "MobileBrowser") {
|
||||
return <MobileBrowserStub />;
|
||||
}
|
||||
|
||||
// Если бэкенд вернул 401
|
||||
if (isUnauthorized) {
|
||||
return (
|
||||
@@ -90,20 +125,41 @@ function App() {
|
||||
return <MaintenancePage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* Мобильные роуты (существующие) */}
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route index element={<Navigate to="/invoices" replace />} />
|
||||
<Route path="ocr" element={<OcrLearning />} />
|
||||
<Route path="invoices" element={<DraftsList />} />
|
||||
<Route path="invoice/draft/:id" element={<InvoiceDraftPage />} />
|
||||
<Route path="invoice/view/:id" element={<InvoiceViewPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
{/* Десктопные роуты */}
|
||||
<Route path="/web" element={<DesktopAuthScreen />} />
|
||||
<Route path="/web" element={<DesktopLayout />}>
|
||||
<Route
|
||||
path="dashboard"
|
||||
element={
|
||||
<ProtectedDesktopRoute>
|
||||
<InvoicesDashboard />
|
||||
</ProtectedDesktopRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
// Главный компонент-обертка
|
||||
function App() {
|
||||
return (
|
||||
<Providers>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route index element={<Navigate to="/invoices" replace />} />
|
||||
<Route path="ocr" element={<OcrLearning />} />
|
||||
<Route path="invoices" element={<DraftsList />} />
|
||||
<Route path="invoice/draft/:id" element={<InvoiceDraftPage />} />
|
||||
<Route path="invoice/view/:id" element={<InvoiceViewPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<AppContent />
|
||||
</BrowserRouter>
|
||||
</Providers>
|
||||
);
|
||||
|
||||
96
rmser-view/src/components/DragDropZone.tsx
Normal file
96
rmser-view/src/components/DragDropZone.tsx
Normal file
@@ -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<string, string[]>;
|
||||
maxSize?: number;
|
||||
maxFiles?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент зоны перетаскивания файлов
|
||||
* Обертка над react-dropzone с Ant Design стилизацией
|
||||
*/
|
||||
export const DragDropZone: React.FC<DragDropZoneProps> = ({
|
||||
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 (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={className}
|
||||
style={{
|
||||
border: `2px dashed ${getBorderColor()}`,
|
||||
borderRadius: '8px',
|
||||
padding: '40px 20px',
|
||||
textAlign: 'center',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
backgroundColor: getBackgroundColor(),
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{children || (
|
||||
<div>
|
||||
<InboxOutlined
|
||||
style={{
|
||||
fontSize: '48px',
|
||||
color: isDragActive ? '#1890ff' : '#bfbfbf',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
{isDragActive ? (
|
||||
<Text type="secondary">Отпустите файлы здесь</Text>
|
||||
) : (
|
||||
<div>
|
||||
<Text>Перетащите файлы сюда или нажмите для выбора</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
Поддерживаются: .xlsx, .xls, изображения (макс. {maxSize / 1024 / 1024}MB)
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
33
rmser-view/src/hooks/usePlatform.ts
Normal file
33
rmser-view/src/hooks/usePlatform.ts
Normal file
@@ -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';
|
||||
}, []);
|
||||
};
|
||||
76
rmser-view/src/hooks/useWebSocket.ts
Normal file
76
rmser-view/src/hooks/useWebSocket.ts
Normal file
@@ -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<string | null>(null);
|
||||
const [lastMessage, setLastMessage] = useState<WsEvent | null>(null);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(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 };
|
||||
};
|
||||
86
rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx
Normal file
86
rmser-view/src/layouts/DesktopLayout/DesktopHeader.tsx
Normal file
@@ -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: <LogoutOutlined />,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||
padding: '0 24px',
|
||||
height: '64px',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
|
||||
{/* Логотип */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1890ff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span>RMSer</span>
|
||||
</div>
|
||||
|
||||
{/* Заглушка выбора сервера */}
|
||||
<Button
|
||||
type="default"
|
||||
ghost
|
||||
style={{
|
||||
color: '#8c8c8c',
|
||||
borderColor: '#d9d9d9',
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
Сервер не выбран
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Аватар пользователя */}
|
||||
<Space>
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Avatar size="default" icon={<UserOutlined />} />
|
||||
<span style={{ color: '#262626' }}>{user?.username || 'Пользователь'}</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
);
|
||||
};
|
||||
30
rmser-view/src/layouts/DesktopLayout/DesktopLayout.tsx
Normal file
30
rmser-view/src/layouts/DesktopLayout/DesktopLayout.tsx
Normal file
@@ -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 (
|
||||
<Layout style={{ minHeight: '100vh', backgroundColor: '#f0f2f5' }}>
|
||||
<DesktopHeader />
|
||||
<Content style={{ padding: '24px' }}>
|
||||
<div
|
||||
style={{
|
||||
minHeight: 'calc(100vh - 64px - 48px)',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
178
rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx
Normal file
178
rmser-view/src/pages/desktop/auth/DesktopAuthScreen.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [qrLink, setQrLink] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
backgroundColor: "#f0f2f5",
|
||||
}}
|
||||
>
|
||||
<Spin size="large" tip="Загрузка..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || lastError) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
backgroundColor: "#f0f2f5",
|
||||
padding: "24px",
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
message="Ошибка"
|
||||
description={error || lastError || "Произошла ошибка при подключении"}
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ maxWidth: "400px" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
backgroundColor: "#f0f2f5",
|
||||
padding: "24px",
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "400px",
|
||||
textAlign: "center",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)",
|
||||
}}
|
||||
>
|
||||
<Title level={3}>Авторизация</Title>
|
||||
<Paragraph type="secondary">
|
||||
Отсканируйте QR код для авторизации через Телеграмм
|
||||
</Paragraph>
|
||||
|
||||
{qrLink && (
|
||||
<div
|
||||
style={{
|
||||
margin: "24px 0",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG value={qrLink} size={200} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qrLink && (
|
||||
<Button
|
||||
type="primary"
|
||||
href={qrLink}
|
||||
target="_blank"
|
||||
icon={<SendOutlined />}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
Открыть в Telegram Desktop
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
fontSize: 12,
|
||||
color: "#888",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
Status:{" "}
|
||||
{isConnected ? (
|
||||
<span style={{ color: "green" }}>Connected</span>
|
||||
) : (
|
||||
<span style={{ color: "red" }}>Disconnected</span>
|
||||
)}
|
||||
</p>
|
||||
<p>Session: {sessionId}</p>
|
||||
{lastError && <p style={{ color: "red" }}>Error: {lastError}</p>}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
37
rmser-view/src/pages/desktop/auth/MobileBrowserStub.tsx
Normal file
37
rmser-view/src/pages/desktop/auth/MobileBrowserStub.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#f0f2f5',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<Result
|
||||
icon={<MobileOutlined style={{ fontSize: '72px', color: '#1890ff' }} />}
|
||||
title="Десктопная версия недоступна"
|
||||
subTitle="Пожалуйста, используйте мобильное приложение или откройте сайт на десктопном устройстве"
|
||||
extra={[
|
||||
<Button type="primary" key="mobile" onClick={handleRedirect}>
|
||||
Перейти к мобильной версии
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
66
rmser-view/src/pages/desktop/dashboard/InvoicesDashboard.tsx
Normal file
66
rmser-view/src/pages/desktop/dashboard/InvoicesDashboard.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<Title level={2}>Черновики</Title>
|
||||
|
||||
{/* Зона для загрузки файлов */}
|
||||
<Card style={{ marginBottom: "24px" }}>
|
||||
<DragDropZone onDrop={handleDrop} />
|
||||
</Card>
|
||||
|
||||
{/* Список черновиков (заглушка) */}
|
||||
<Card title="Последние черновики">
|
||||
<List
|
||||
dataSource={mockDrafts}
|
||||
renderItem={(draft) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={draft.title}
|
||||
description={`${draft.date} • ${draft.status}`}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
description="Нет черновиков"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<void> => {
|
||||
await apiClient.post(`/photos/${id}/regenerate`);
|
||||
},
|
||||
|
||||
// --- Десктопная авторизация ---
|
||||
|
||||
initDesktopAuth: async (): Promise<InitDesktopAuthResponse> => {
|
||||
const { data } = await apiClient.post<InitDesktopAuthResponse>('/auth/init-desktop');
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
52
rmser-view/src/stores/authStore.ts
Normal file
52
rmser-view/src/stores/authStore.ts
Normal file
@@ -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<AuthState>()(
|
||||
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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
30
rmser-view/src/stores/uiStore.ts
Normal file
30
rmser-view/src/stores/uiStore.ts
Normal file
@@ -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<UIState>((set) => ({
|
||||
selectedServer: null,
|
||||
sidebarCollapsed: false,
|
||||
|
||||
setSelectedServer: (server: string | null) => {
|
||||
set({ selectedServer: server });
|
||||
},
|
||||
|
||||
toggleSidebar: () => {
|
||||
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }));
|
||||
},
|
||||
|
||||
setSidebarCollapsed: (collapsed: boolean) => {
|
||||
set({ sidebarCollapsed: collapsed });
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user