From 6ee1b67315be31876d23e3a8e82ac47227efbb3e Mon Sep 17 00:00:00 2001 From: DIKER0K Date: Sat, 19 Jul 2025 04:40:46 +0500 Subject: [PATCH] add: marketplace --- .gitignore | 2 + src/renderer/api.ts | 287 +++++++++++++- src/renderer/components/PlayerInventory.tsx | 355 ++++++++++++++++++ src/renderer/components/TopBar.tsx | 35 +- src/renderer/pages/Marketplace.tsx | 390 +++++++++++++++++++- src/renderer/utils/playerOnlineCheck.ts | 51 ++- 6 files changed, 1081 insertions(+), 39 deletions(-) create mode 100644 src/renderer/components/PlayerInventory.tsx diff --git a/.gitignore b/.gitignore index 55608b2..ad2fe2d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +public/ + # Logs logs *.log diff --git a/src/renderer/api.ts b/src/renderer/api.ts index c0fda42..bc588d9 100644 --- a/src/renderer/api.ts +++ b/src/renderer/api.ts @@ -60,15 +60,6 @@ export interface ActiveServersResponse { servers: Server[]; } -// Исправьте тип возвращаемого значения -export async function fetchActiveServers(): Promise { - const response = await fetch(`${API_BASE_URL}/api/pranks/servers`); - if (!response.ok) { - throw new Error('Не удалось получить активные сервера'); - } - return await response.json(); -} - export interface OnlinePlayersResponse { server: { id: string; @@ -83,6 +74,284 @@ export interface OnlinePlayersResponse { count: number; } +export interface MarketplaceResponse { + items: [ + { + _id: string; + id: string; + material: string; + amount: number; + price: number; + seller_name: string; + server_ip: string; + display_name: string | null; + lore: string | null; + enchants: string | null; + item_data: { + slot: number; + material: string; + amount: number; + }; + created_at: string; + }, + ]; + total: number; + page: number; + pages: number; +} + +export interface MarketplaceItemResponse { + _id: string; + id: string; + material: string; + amount: number; + price: number; + seller_name: string; + server_ip: string; + display_name: string | null; + lore: string | null; + enchants: string | null; + item_data: { + slot: number; + material: string; + amount: number; + }; + created_at: string; +} + +export interface SellItemResponse { + message: string; +} + +export interface BuyItemResponse { + message: string; +} + +export interface PlayerInventoryResponse { + status: string; + request_id: string; +} + +export interface PlayerInventory { + status: string; + result: { + player_name: string; + server_ip: string; + inventory_data: PlayerInventoryItem[]; + }; +} + +export interface PlayerInventoryItem { + slot: number; + material: string; + amount: number; + enchants: { + [key: string]: number; + }; +} + +export interface MarketplaceOperation { + id: string; + type: 'sell' | 'buy'; + player_name: string; + slot_index?: number; + amount?: number; + price: number; + server_ip: string; + status: 'pending' | 'completed' | 'failed'; + item_id?: string; + error?: string; + created_at: string; + item_data?: any; +} + +export interface OperationsResponse { + operations: MarketplaceOperation[]; +} + +export async function getPlayerInventory( + request_id: string, +): Promise { + const response = await fetch( + `${API_BASE_URL}/api/server/inventory/${request_id}`, + ); + if (!response.ok) { + throw new Error('Не удалось получить инвентарь игрока'); + } + return await response.json(); +} + +export async function RequestPlayerInventory( + server_ip: string, + player_name: string, +): Promise { + const response = await fetch(`${API_BASE_URL}/api/server/inventory`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + server_ip: server_ip, + player_name: player_name, + }), + }); + if (!response.ok) { + throw new Error('Не удалось получить инвентарь игрока'); + } + return await response.json(); +} + +export async function buyItem( + buyer_username: string, + item_id: string, +): Promise<{ status: string; operation_id: string; message: string }> { + const response = await fetch( + `${API_BASE_URL}/api/marketplace/items/buy/${item_id}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username: buyer_username, + }), + }, + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.message || errorData.detail || 'Не удалось купить предмет', + ); + } + + return await response.json(); +} + +export async function confirmMarketplaceOperation( + operation_id: string, + status: string = 'success', + error?: string, +): Promise<{ status: string }> { + const response = await fetch( + `${API_BASE_URL}/api/marketplace/operations/confirm`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + operation_id, + status, + error, + }), + }, + ); + + if (!response.ok) { + throw new Error('Не удалось подтвердить операцию'); + } + + return await response.json(); +} + +export async function submitItemDetails( + operation_id: string, + item_data: any, +): Promise<{ status: string }> { + const response = await fetch( + `${API_BASE_URL}/api/marketplace/items/details`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + operation_id, + item_data, + }), + }, + ); + + if (!response.ok) { + throw new Error('Не удалось отправить данные предмета'); + } + + return await response.json(); +} + +export async function sellItem( + username: string, + slot_index: number, + amount: number, + price: number, + server_ip: string, +): Promise<{ status: string; operation_id: string }> { + const response = await fetch(`${API_BASE_URL}/api/marketplace/items/sell`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + slot_index, + amount, + price, + server_ip, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.message || + errorData.detail || + 'Не удалось выставить предмет на продажу', + ); + } + + return await response.json(); +} + +export async function fetchMarketplaceItem( + item_id: string, +): Promise { + const response = await fetch( + `${API_BASE_URL}/api/marketplace/items/${item_id}`, + ); + if (!response.ok) { + throw new Error('Не удалось получить рынок'); + } + return await response.json(); +} + +export async function fetchMarketplace( + server_ip: string, + page: number, + limit: number, +): Promise { + // Создаем URL с параметрами запроса + const url = new URL(`${API_BASE_URL}/api/marketplace/items`); + url.searchParams.append('server_ip', server_ip); + url.searchParams.append('page', page.toString()); + url.searchParams.append('limit', limit.toString()); + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error('Не удалось получить предметы рынка'); + } + return await response.json(); +} + +// Исправьте тип возвращаемого значения +export async function fetchActiveServers(): Promise { + const response = await fetch(`${API_BASE_URL}/api/pranks/servers`); + if (!response.ok) { + throw new Error('Не удалось получить активные сервера'); + } + return await response.json(); +} + export async function fetchOnlinePlayers( server_id: string, ): Promise { diff --git a/src/renderer/components/PlayerInventory.tsx b/src/renderer/components/PlayerInventory.tsx new file mode 100644 index 0000000..dfafb73 --- /dev/null +++ b/src/renderer/components/PlayerInventory.tsx @@ -0,0 +1,355 @@ +// src/renderer/components/PlayerInventory.tsx +import { useEffect, useState } from 'react'; +import { + Box, + Typography, + Grid, + Card, + CardMedia, + CardContent, + Button, + CircularProgress, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Alert, +} from '@mui/material'; +import { + RequestPlayerInventory, + getPlayerInventory, + sellItem, + PlayerInventoryItem, +} from '../api'; + +interface PlayerInventoryProps { + username: string; + serverIp: string; + onSellSuccess?: () => void; // Callback для обновления маркетплейса после продажи +} + +export default function PlayerInventory({ + username, + serverIp, + onSellSuccess, +}: PlayerInventoryProps) { + const [loading, setLoading] = useState(false); + const [inventoryItems, setInventoryItems] = useState( + [], + ); + const [error, setError] = useState(null); + const [sellDialogOpen, setSellDialogOpen] = useState(false); + const [selectedItem, setSelectedItem] = useState( + null, + ); + const [price, setPrice] = useState(0); + const [amount, setAmount] = useState(1); + const [sellLoading, setSellLoading] = useState(false); + const [sellError, setSellError] = useState(null); + + // Функция для запроса инвентаря игрока + const fetchPlayerInventory = async () => { + try { + setLoading(true); + setError(null); + + // Сначала делаем запрос на получение идентификатора запроса инвентаря + const inventoryRequest = await RequestPlayerInventory(serverIp, username); + const requestId = inventoryRequest.request_id; + + // Затем начинаем опрашивать API для получения результата + let inventoryData = null; + let attempts = 0; + const maxAttempts = 10; // Максимальное количество попыток + + while (!inventoryData && attempts < maxAttempts) { + attempts++; + + try { + // Пауза перед следующим запросом + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Запрашиваем состояние инвентаря + const response = await getPlayerInventory(requestId); + + // Если инвентарь загружен, сохраняем его + if (response.status === 'completed') { + inventoryData = response.result.inventory_data; + break; + } + } catch (e) { + console.log('Ожидание завершения запроса инвентаря...'); + } + } + + if (inventoryData) { + setInventoryItems(inventoryData); + } else { + setError('Не удалось получить инвентарь. Попробуйте еще раз.'); + } + } catch (e) { + console.error('Ошибка при получении инвентаря:', e); + setError('Произошла ошибка при загрузке инвентаря.'); + } finally { + setLoading(false); + } + }; + + // Открываем диалог для продажи предмета + const handleOpenSellDialog = (item: PlayerInventoryItem) => { + setSelectedItem(item); + setAmount(1); + setPrice(0); + setSellError(null); + setSellDialogOpen(true); + }; + + // Закрываем диалог + const handleCloseSellDialog = () => { + setSellDialogOpen(false); + setSelectedItem(null); + }; + + // Выставляем предмет на продажу + const handleSellItem = async () => { + if (!selectedItem) return; + + try { + setSellLoading(true); + setSellError(null); + + // Проверяем валидность введенных данных + if (price <= 0) { + setSellError('Цена должна быть больше 0'); + return; + } + + if (amount <= 0 || amount > selectedItem.amount) { + setSellError(`Количество должно быть от 1 до ${selectedItem.amount}`); + return; + } + + // Отправляем запрос на продажу + const result = await sellItem( + username, + selectedItem.slot, + amount, + price, + serverIp, + ); + + // Проверяем статус операции + if (result.status === 'pending') { + // Закрываем диалог и обновляем инвентарь + handleCloseSellDialog(); + + // Показываем уведомление о том, что операция обрабатывается + // setNotification({ // Assuming setNotification is available in the context + // open: true, + // message: 'Предмет выставляется на продажу. Это может занять некоторое время.', + // type: 'info' + // }); + + // Через 5 секунд обновляем инвентарь + setTimeout(() => { + fetchPlayerInventory(); + + // Вызываем callback для обновления маркетплейса + if (onSellSuccess) { + onSellSuccess(); + } + }, 5000); + } + } catch (e) { + console.error('Ошибка при продаже предмета:', e); + setSellError('Произошла ошибка при продаже предмета.'); + } finally { + setSellLoading(false); + } + }; + + // Загружаем инвентарь при монтировании компонента + useEffect(() => { + fetchPlayerInventory(); + }, [username, serverIp]); + + // Получаем отображаемое имя предмета + const getItemDisplayName = (material: string) => { + return material + .replace(/_/g, ' ') + .toLowerCase() + .replace(/\b\w/g, (l) => l.toUpperCase()); + }; + + return ( + + + + Ваш инвентарь + + + + + {error && ( + + {error} + + )} + + {loading ? ( + + + + ) : ( + <> + {inventoryItems.length === 0 ? ( + + Ваш инвентарь пуст или не удалось загрузить предметы. + + ) : ( + + {inventoryItems.map((item) => + item.material !== 'AIR' && item.amount > 0 ? ( + + handleOpenSellDialog(item)} + > + + + + {getItemDisplayName(item.material)} + + + x{item.amount} + + {Object.keys(item.enchants || {}).length > 0 && ( + + Зачарования: {Object.keys(item.enchants).length} + + )} + + + + ) : null, + )} + + )} + + )} + + {/* Диалог для продажи предмета */} + + Продать предмет + + {selectedItem && ( + + + + + {getItemDisplayName(selectedItem.material)} + + + + + Всего доступно: {selectedItem.amount} + + + + setAmount( + Math.min( + parseInt(e.target.value) || 0, + selectedItem.amount, + ), + ) + } + inputProps={{ min: 1, max: selectedItem.amount }} + /> + + setPrice(parseInt(e.target.value) || 0)} + inputProps={{ min: 1 }} + /> + + {sellError && ( + + {sellError} + + )} + + )} + + + + + + + + ); +} diff --git a/src/renderer/components/TopBar.tsx b/src/renderer/components/TopBar.tsx index 8ba32ec..855d403 100644 --- a/src/renderer/components/TopBar.tsx +++ b/src/renderer/components/TopBar.tsx @@ -5,7 +5,6 @@ import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import { useEffect, useState } from 'react'; import { Tooltip } from '@mui/material'; import { fetchCoins } from '../api'; -import { isPlayerOnline } from '../utils/playerOnlineCheck'; declare global { interface Window { @@ -34,7 +33,6 @@ export default function TopBar({ onRegister, username }: TopBarProps) { const navigate = useNavigate(); const [coins, setCoins] = useState(0); const [value, setValue] = useState(0); - const [isOnline, setIsOnline] = useState(false); const handleChange = (event: React.SyntheticEvent, newValue: number) => { setValue(newValue); @@ -78,30 +76,15 @@ export default function TopBar({ onRegister, username }: TopBarProps) { } }; - const checkPlayerOnlineStatus = async () => { - if (!username) return; - - try { - const online = await isPlayerOnline(username); - setIsOnline(online); - } catch (error) { - console.error('Ошибка при проверке онлайн-статуса:', error); - setIsOnline(false); - } - }; - useEffect(() => { if (username) { fetchCoinsData(); - checkPlayerOnlineStatus(); // Проверяем сразу // Создаем интервалы для периодического обновления данных const coinsInterval = setInterval(fetchCoinsData, 60000); - const onlineStatusInterval = setInterval(checkPlayerOnlineStatus, 30000); // Каждые 30 секунд return () => { clearInterval(coinsInterval); - clearInterval(onlineStatusInterval); }; } }, [username]); @@ -184,16 +167,14 @@ export default function TopBar({ onRegister, username }: TopBarProps) { fontSize: '0.7em', }} /> - {isOnline && ( - - )} + )} diff --git a/src/renderer/pages/Marketplace.tsx b/src/renderer/pages/Marketplace.tsx index 21b537b..e3e6496 100644 --- a/src/renderer/pages/Marketplace.tsx +++ b/src/renderer/pages/Marketplace.tsx @@ -1,3 +1,389 @@ -export default function Marketplace() { - return
Marketplace
; +// src/renderer/pages/Marketplace.tsx +import { useEffect, useState } from 'react'; +import { + Box, + Typography, + CircularProgress, + Button, + Grid, + Card, + CardContent, + CardMedia, + Pagination, + Tabs, + Tab, + Alert, + Snackbar, +} from '@mui/material'; +import { isPlayerOnline, getPlayerServer } from '../utils/playerOnlineCheck'; +import { buyItem, fetchMarketplace, MarketplaceResponse, Server } from '../api'; +import PlayerInventory from '../components/PlayerInventory'; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); +} + +export default function Marketplace() { + const [loading, setLoading] = useState(true); + const [marketLoading, setMarketLoading] = useState(false); + const [isOnline, setIsOnline] = useState(false); + const [username, setUsername] = useState(''); + const [playerServer, setPlayerServer] = useState(null); + const [marketItems, setMarketItems] = useState( + null, + ); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [tabValue, setTabValue] = useState(0); + const [notification, setNotification] = useState<{ + open: boolean; + message: string; + type: 'success' | 'error'; + }>({ + open: false, + message: '', + type: 'success', + }); + + // Функция для проверки онлайн-статуса игрока и определения сервера + const checkPlayerStatus = async () => { + if (!username) return; + + try { + setLoading(true); + // Проверяем, онлайн ли игрок и получаем сервер, где он находится + const { online, server } = await getPlayerServer(username); + setIsOnline(online); + setPlayerServer(server); + + // Если игрок онлайн и на каком-то сервере, загружаем предметы рынка + if (online && server) { + await loadMarketItems(server.ip, 1); + } + } catch (error) { + console.error('Ошибка при проверке онлайн-статуса:', error); + setIsOnline(false); + setPlayerServer(null); + } finally { + setLoading(false); + } + }; + + // Функция для загрузки предметов маркетплейса + const loadMarketItems = async (serverIp: string, pageNumber: number) => { + try { + setMarketLoading(true); + const marketData = await fetchMarketplace(serverIp, pageNumber, 10); // 10 предметов на страницу + setMarketItems(marketData); + setPage(marketData.page); + setTotalPages(marketData.pages); + } catch (error) { + console.error('Ошибка при загрузке предметов рынка:', error); + setMarketItems(null); + } finally { + setMarketLoading(false); + } + }; + + // Обработчик смены страницы + const handlePageChange = ( + _event: React.ChangeEvent, + newPage: number, + ) => { + if (playerServer) { + loadMarketItems(playerServer.ip, newPage); + } + }; + + // Обработчик смены вкладок + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { + setTabValue(newValue); + }; + + // Обновляем функцию handleBuyItem в Marketplace.tsx + const handleBuyItem = async (itemId: string) => { + try { + if (username) { + const result = await buyItem(username, itemId); + + setNotification({ + open: true, + message: + result.message || + 'Предмет успешно куплен! Он будет добавлен в ваш инвентарь.', + type: 'success', + }); + + // Обновляем список предметов + if (playerServer) { + loadMarketItems(playerServer.ip, page); + } + } + } catch (error) { + console.error('Ошибка при покупке предмета:', error); + setNotification({ + open: true, + message: + error instanceof Error + ? error.message + : 'Ошибка при покупке предмета', + type: 'error', + }); + } + }; + + // Закрытие уведомления + const handleCloseNotification = () => { + setNotification({ ...notification, open: false }); + }; + + // Получаем имя пользователя из localStorage при монтировании компонента + useEffect(() => { + const savedConfig = localStorage.getItem('launcher_config'); + if (savedConfig) { + const config = JSON.parse(savedConfig); + if (config.username) { + setUsername(config.username); + } + } + }, []); + + // Проверяем статус при изменении username + useEffect(() => { + if (username) { + checkPlayerStatus(); + } + }, [username]); + + // Показываем loader во время проверки + if (loading) { + return ( + + + + Проверяем, находитесь ли вы на сервере... + + + ); + } + + // Если игрок не онлайн + if (!isOnline) { + return ( + + + Доступ к рынку ограничен + + + Для доступа к рынку вам необходимо находиться на одном из серверов + игры. + + + Зайдите на любой сервер и обновите страницу. + + + + ); + } + + return ( + + + Рынок сервера {playerServer?.name || ''} + + + {/* Вкладки */} + + + + + + + + {/* Содержимое вкладки "Товары" */} + + {marketLoading ? ( + + + + ) : !marketItems || marketItems.items.length === 0 ? ( + + + На данный момент на рынке нет предметов. + + + + ) : ( + <> + + {marketItems.items.map((item) => ( + + + + + + {item.display_name || + item.material + .replace(/_/g, ' ') + .toLowerCase() + .replace(/\b\w/g, (l) => l.toUpperCase())} + + + Количество: {item.amount} + + + Цена: {item.price} монет + + + Продавец: {item.seller_name} + + + + + + ))} + + + {totalPages > 1 && ( + + + + )} + + )} + + + {/* Содержимое вкладки "Мой инвентарь" */} + + {playerServer && username ? ( + { + // После успешной продажи, обновляем список товаров + if (playerServer) { + loadMarketItems(playerServer.ip, 1); + } + + // Показываем уведомление + setNotification({ + open: true, + message: 'Предмет успешно выставлен на продажу!', + type: 'success', + }); + }} + /> + ) : ( + + Не удалось загрузить инвентарь. + + )} + + + {/* Уведомления */} + + + {notification.message} + + + + ); } diff --git a/src/renderer/utils/playerOnlineCheck.ts b/src/renderer/utils/playerOnlineCheck.ts index b330e74..f0b5861 100644 --- a/src/renderer/utils/playerOnlineCheck.ts +++ b/src/renderer/utils/playerOnlineCheck.ts @@ -1,4 +1,4 @@ -import { fetchActiveServers, fetchOnlinePlayers } from '../api'; +import { fetchActiveServers, fetchOnlinePlayers, Server } from '../api'; /** * Проверяет, находится ли указанный игрок онлайн на любом из серверов @@ -77,3 +77,52 @@ export async function isPlayerOnlineOnServer( return false; } } + +/** + * Проверяет, на каком сервере находится игрок + * @param username Имя игрока для проверки + * @returns Объект с информацией: онлайн ли игрок и на каком сервере + */ +export async function getPlayerServer( + username: string, +): Promise<{ online: boolean; server: Server | null }> { + try { + // Получаем список активных серверов + const servers = await fetchActiveServers(); + + // Фильтруем серверы с игроками + const serversWithPlayers = servers.filter( + (server) => server.online_players > 0, + ); + + // Если нет серверов с игроками, игрок точно не онлайн + if (serversWithPlayers.length === 0) { + return { online: false, server: null }; + } + + // Проверяем каждый сервер на наличие игрока + for (const server of serversWithPlayers) { + try { + const onlinePlayers = await fetchOnlinePlayers(server.id); + + if ( + Array.isArray(onlinePlayers.online_players) && + onlinePlayers.online_players.some( + (player) => player.username === username, + ) + ) { + // Игрок найден на этом сервере + return { online: true, server }; + } + } catch (error) { + console.error(`Ошибка при проверке сервера ${server.id}:`, error); + } + } + + // Игрок не найден ни на одном сервере + return { online: false, server: null }; + } catch (error) { + console.error('Ошибка при проверке сервера игрока:', error); + return { online: false, server: null }; + } +}