2801-есть десктоп-версия. реализован ws для авторизации через тг-бота

This commit is contained in:
2026-01-28 08:12:41 +03:00
parent a536b3ff3c
commit b99e328d35
26 changed files with 2258 additions and 82 deletions

View File

@@ -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;
}
}

View File

@@ -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",

View File

@@ -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

View File

@@ -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>
);

View 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>
);
};

View 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';
}, []);
};

View 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 };
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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;
},
};

View 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,
}),
}
)
);

View 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 });
},
}));