feat: 新增移动客户端(iOS + Android)
- 新增 mobile/ 项目:React Native + Expo - 5个核心页面:连接服务器、房间列表、创建房间、加入房间、设置 - Tab 导航 + Minecraft 风格深色 UI - 房间搜索/筛选(名称、房间号、房主、版本类型) - 15秒自动刷新房间列表 - 设置持久化(AsyncStorage) - EAS Build 配置(云端构建 iOS/Android) - 完整 README 含构建指南 - 更新顶层 README 为三项目全平台架构
This commit is contained in:
81
README.md
81
README.md
@@ -2,14 +2,26 @@
|
|||||||
|
|
||||||
一个支持多节点中继的 Minecraft 联机平台,让玩家无需公网IP即可轻松联机。
|
一个支持多节点中继的 Minecraft 联机平台,让玩家无需公网IP即可轻松联机。
|
||||||
|
|
||||||
本仓库包含 **两个独立项目**,可分别独立开发、部署和运行。
|
本仓库包含 **三个独立项目**,覆盖全平台客户端和服务端。
|
||||||
|
|
||||||
|
## 支持平台
|
||||||
|
|
||||||
|
| 平台 | 类型 | 项目 |
|
||||||
|
|------|------|------|
|
||||||
|
| **Windows** | 桌面客户端 (NSIS 安装包) | `client/` |
|
||||||
|
| **macOS** | 桌面客户端 (DMG, x64/arm64) | `client/` |
|
||||||
|
| **Linux** | 桌面客户端 (AppImage/deb) | `client/` |
|
||||||
|
| **iOS** | 移动客户端 | `mobile/` |
|
||||||
|
| **Android** | 移动客户端 (APK/AAB) | `mobile/` |
|
||||||
|
| **Ubuntu** | 中继服务器 + Web 管理面板 | `server/` |
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
FunConnect/
|
FunConnect/
|
||||||
├── server/ # 服务端(中继服务器 + Web 管理面板 + 部署脚本)
|
├── server/ # 服务端(中继服务器 + Web 管理面板 + 部署脚本)
|
||||||
└── client/ # 客户端(Electron 桌面应用)
|
├── client/ # 桌面客户端(Electron: Windows / macOS / Linux)
|
||||||
|
└── mobile/ # 移动客户端(React Native + Expo: iOS / Android)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 服务端 (`server/`)
|
## 服务端 (`server/`)
|
||||||
@@ -25,46 +37,63 @@ FunConnect/
|
|||||||
- **一键部署** - Ubuntu 自动安装脚本 + systemd 服务
|
- **一键部署** - Ubuntu 自动安装脚本 + systemd 服务
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
cd server && npm install && cp .env.example .env && npm run dev
|
||||||
npm install
|
|
||||||
cp .env.example .env
|
|
||||||
npm run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
详细文档见 [server/README.md](server/README.md)
|
📖 [server/README.md](server/README.md) · 📦 [部署教程 DEPLOY.md](server/DEPLOY.md)
|
||||||
|
|
||||||
## 客户端 (`client/`)
|
## 桌面客户端 (`client/`)
|
||||||
|
|
||||||
Electron 跨平台桌面客户端,支持 Windows / macOS / Linux。
|
Electron 跨平台桌面客户端,支持 Windows / macOS / Linux。
|
||||||
|
|
||||||
- **连接服务器** - 输入中继地址一键连接
|
- **本地代理** - 自动建立 TCP 代理,MC 添加 `127.0.0.1:25566` 即可联机
|
||||||
- **房间管理** - 浏览/创建/加入联机房间
|
|
||||||
- **本地代理** - 自动建立本地代理,MC 添加 `127.0.0.1:25566` 即可联机
|
|
||||||
- **设置持久化** - 记住服务器地址、玩家名等偏好
|
- **设置持久化** - 记住服务器地址、玩家名等偏好
|
||||||
- **系统托盘** - 最小化到托盘后台运行
|
- **系统托盘** - 最小化到托盘后台运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
cd client && npm install && npm run dev # 开发
|
||||||
npm install
|
npm run dist:win # 打包 Windows
|
||||||
npm run dev
|
npm run dist:mac # 打包 macOS
|
||||||
|
npm run dist:linux # 打包 Linux
|
||||||
```
|
```
|
||||||
|
|
||||||
详细文档见 [client/README.md](client/README.md)
|
📖 [client/README.md](client/README.md)
|
||||||
|
|
||||||
|
## 移动客户端 (`mobile/`)
|
||||||
|
|
||||||
|
React Native + Expo 移动客户端,支持 iOS / Android。
|
||||||
|
|
||||||
|
- **房间管理** - 浏览/搜索/创建/加入联机房间
|
||||||
|
- **设置持久化** - 记住服务器地址和玩家名
|
||||||
|
- **深色 UI** - Minecraft 风格暗色主题
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mobile && npm install && npm start # 开发(Expo)
|
||||||
|
eas build --platform android --profile preview # 构建 Android APK
|
||||||
|
eas build --platform ios --profile production # 构建 iOS
|
||||||
|
```
|
||||||
|
|
||||||
|
📖 [mobile/README.md](mobile/README.md)
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
```
|
```
|
||||||
┌───────────────┐ ┌──────────────────────────┐
|
┌──────────────────┐
|
||||||
│ FunConnect │ TCP │ 中继服务器 (Ubuntu) │
|
│ 桌面客户端 │ Windows / macOS / Linux
|
||||||
│ 桌面客户端 │◄─────►│ server/ 项目独立部署 │
|
│ Electron │ TCP 本地代理
|
||||||
│ client/ │ │ ┌─────────┐ ┌──────────┐ │
|
│ client/ │─────────┐
|
||||||
└───────────────┘ │ │ TCP中继 │ │ REST API │ │
|
└──────────────────┘ │
|
||||||
│ └─────────┘ └──────────┘ │
|
▼
|
||||||
┌───────────────┐ │ ┌──────────────────────┐ │
|
┌──────────────────┐ ┌──────────────────────────┐
|
||||||
│ Minecraft │ TCP │ │ Web 管理面板 │ │
|
│ 移动客户端 │ │ 中继服务器 (Ubuntu) │
|
||||||
│ 游戏客户端 │◄─────►│ │ (React + Vite) │ │
|
│ React Native │──►│ TCP 中继 + REST API │
|
||||||
└───────────────┘ │ └──────────────────────┘ │
|
│ mobile/ │ │ Web 管理面板 (React) │
|
||||||
└──────────────────────────┘
|
└──────────────────┘ └──────────────────────────┘
|
||||||
|
▲
|
||||||
|
┌──────────────────┐ │
|
||||||
|
│ Minecraft │─────────┘
|
||||||
|
│ 游戏客户端 │ TCP 直连中继
|
||||||
|
└──────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
12
mobile/.gitignore
vendored
Normal file
12
mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
*.orig.*
|
||||||
|
web-build/
|
||||||
|
ios/
|
||||||
|
android/
|
||||||
70
mobile/App.tsx
Normal file
70
mobile/App.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
||||||
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import ConnectScreen from './src/screens/ConnectScreen';
|
||||||
|
import RoomsScreen from './src/screens/RoomsScreen';
|
||||||
|
import CreateScreen from './src/screens/CreateScreen';
|
||||||
|
import JoinScreen from './src/screens/JoinScreen';
|
||||||
|
import SettingsScreen from './src/screens/SettingsScreen';
|
||||||
|
|
||||||
|
const Tab = createBottomTabNavigator();
|
||||||
|
|
||||||
|
const DarkTheme = {
|
||||||
|
...DefaultTheme,
|
||||||
|
colors: {
|
||||||
|
...DefaultTheme.colors,
|
||||||
|
primary: '#4CAF50',
|
||||||
|
background: '#1a1a2e',
|
||||||
|
card: '#16213e',
|
||||||
|
text: '#e0e0e0',
|
||||||
|
border: 'rgba(15, 52, 96, 0.6)',
|
||||||
|
notification: '#e94560',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
Connect: '🔗',
|
||||||
|
Rooms: '🎮',
|
||||||
|
Create: '🏠',
|
||||||
|
Join: '🚀',
|
||||||
|
Settings: '⚙️',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<NavigationContainer theme={DarkTheme}>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={({ route }) => ({
|
||||||
|
headerStyle: { backgroundColor: '#16213e', elevation: 0, shadowOpacity: 0 },
|
||||||
|
headerTintColor: '#e0e0e0',
|
||||||
|
headerTitleStyle: { fontWeight: '700' },
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: '#16213e',
|
||||||
|
borderTopColor: 'rgba(15, 52, 96, 0.6)',
|
||||||
|
height: 60,
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
tabBarActiveTintColor: '#4CAF50',
|
||||||
|
tabBarInactiveTintColor: '#8a8a9a',
|
||||||
|
tabBarIcon: ({ color }) => (
|
||||||
|
<Text style={{ fontSize: 20 }}>{icons[route.name] || '📱'}</Text>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Tab.Screen name="Connect" component={ConnectScreen}
|
||||||
|
options={{ title: '连接', headerTitle: 'FunConnect' }} />
|
||||||
|
<Tab.Screen name="Rooms" component={RoomsScreen}
|
||||||
|
options={{ title: '房间', headerTitle: '房间列表' }} />
|
||||||
|
<Tab.Screen name="Create" component={CreateScreen}
|
||||||
|
options={{ title: '创建', headerTitle: '创建房间' }} />
|
||||||
|
<Tab.Screen name="Join" component={JoinScreen}
|
||||||
|
options={{ title: '加入', headerTitle: '加入房间' }} />
|
||||||
|
<Tab.Screen name="Settings" component={SettingsScreen}
|
||||||
|
options={{ title: '设置', headerTitle: '设置' }} />
|
||||||
|
</Tab.Navigator>
|
||||||
|
</NavigationContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
mobile/README.md
Normal file
131
mobile/README.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# FunConnect Mobile
|
||||||
|
|
||||||
|
Minecraft 联机移动客户端,基于 **React Native + Expo** 构建,支持 **iOS** 和 **Android**。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- **连接服务器** - 输入中继服务器地址连接
|
||||||
|
- **浏览房间** - 实时查看在线联机房间(自动刷新)
|
||||||
|
- **搜索筛选** - 按名称、房间号、房主搜索,按版本类型筛选
|
||||||
|
- **创建房间** - 创建联机房间并分享房间号
|
||||||
|
- **加入房间** - 输入房间号加入,支持密码验证
|
||||||
|
- **设置持久化** - 记住服务器地址、玩家名
|
||||||
|
- **深色主题** - Minecraft 风格暗色 UI
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- Expo CLI: `npm install -g expo-cli`
|
||||||
|
- iOS: Xcode 15+(仅 macOS)
|
||||||
|
- Android: Android Studio + SDK
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mobile
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动 Expo 开发服务器
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# 在 iOS 模拟器中运行
|
||||||
|
npm run ios
|
||||||
|
|
||||||
|
# 在 Android 模拟器中运行
|
||||||
|
npm run android
|
||||||
|
```
|
||||||
|
|
||||||
|
> 也可以用 **Expo Go** 手机 App 扫描二维码直接在真机上运行。
|
||||||
|
|
||||||
|
## 编译发布
|
||||||
|
|
||||||
|
### 方式一:EAS Build(推荐,云端构建)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 EAS CLI
|
||||||
|
npm install -g eas-cli
|
||||||
|
|
||||||
|
# 登录 Expo 账号
|
||||||
|
eas login
|
||||||
|
|
||||||
|
# 构建 Android APK(预览版)
|
||||||
|
eas build --platform android --profile preview
|
||||||
|
|
||||||
|
# 构建 Android AAB(生产版,用于上架 Google Play)
|
||||||
|
eas build --platform android --profile production
|
||||||
|
|
||||||
|
# 构建 iOS(需要 Apple Developer 账号)
|
||||||
|
eas build --platform ios --profile production
|
||||||
|
|
||||||
|
# 同时构建两个平台
|
||||||
|
eas build --platform all --profile production
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二:本地构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成原生项目
|
||||||
|
npx expo prebuild
|
||||||
|
|
||||||
|
# Android
|
||||||
|
cd android && ./gradlew assembleRelease
|
||||||
|
# 输出: android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
|
||||||
|
# iOS(需要 macOS + Xcode)
|
||||||
|
cd ios && xcodebuild -workspace FunConnect.xcworkspace -scheme FunConnect archive
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
mobile/
|
||||||
|
├── App.tsx # 入口 + Tab 导航
|
||||||
|
├── index.js # 注册根组件
|
||||||
|
├── src/
|
||||||
|
│ ├── screens/
|
||||||
|
│ │ ├── ConnectScreen.tsx # 连接服务器
|
||||||
|
│ │ ├── RoomsScreen.tsx # 房间列表
|
||||||
|
│ │ ├── CreateScreen.tsx # 创建房间
|
||||||
|
│ │ ├── JoinScreen.tsx # 加入房间
|
||||||
|
│ │ └── SettingsScreen.tsx # 设置
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── api.ts # API 客户端 + 存储
|
||||||
|
│ │ └── theme.ts # 主题色彩
|
||||||
|
│ └── components/ # 共享组件
|
||||||
|
├── app.json # Expo 配置
|
||||||
|
├── eas.json # EAS Build 配置
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 联机流程
|
||||||
|
|
||||||
|
### 房主
|
||||||
|
|
||||||
|
1. 在「连接」页连接中继服务器
|
||||||
|
2. 在「创建」页创建房间,复制房间号
|
||||||
|
3. 将房间号分享给好友
|
||||||
|
4. 启动本地 Minecraft 服务器
|
||||||
|
|
||||||
|
### 玩家
|
||||||
|
|
||||||
|
1. 在「连接」页连接同一个中继服务器
|
||||||
|
2. 在「加入」页输入房间号
|
||||||
|
3. 验证通过后获取服务器地址
|
||||||
|
4. 在 Minecraft 中添加该服务器地址
|
||||||
|
5. 开始联机!
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **React Native 0.73** + **Expo 50**
|
||||||
|
- **TypeScript**
|
||||||
|
- **React Navigation** - Tab 导航
|
||||||
|
- **AsyncStorage** - 本地持久化
|
||||||
|
- **Axios** - HTTP 请求
|
||||||
|
- **EAS Build** - 云端编译发布
|
||||||
26
mobile/app.json
Normal file
26
mobile/app.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "FunConnect",
|
||||||
|
"slug": "funconnect",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "dark",
|
||||||
|
"splash": {
|
||||||
|
"backgroundColor": "#1a1a2e"
|
||||||
|
},
|
||||||
|
"assetBundlePatterns": ["**/*"],
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "cn.funmc.connect",
|
||||||
|
"buildNumber": "1"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#1a1a2e"
|
||||||
|
},
|
||||||
|
"package": "cn.funmc.connect",
|
||||||
|
"versionCode": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
mobile/babel.config.js
Normal file
6
mobile/babel.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
};
|
||||||
|
};
|
||||||
37
mobile/eas.json
Normal file
37
mobile/eas.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 5.0.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"android": {
|
||||||
|
"buildType": "app-bundle"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {
|
||||||
|
"android": {
|
||||||
|
"serviceAccountKeyPath": "./google-services.json",
|
||||||
|
"track": "internal"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"appleId": "your-apple-id@example.com",
|
||||||
|
"ascAppId": "your-asc-app-id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
mobile/index.js
Normal file
4
mobile/index.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { registerRootComponent } from 'expo';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
registerRootComponent(App);
|
||||||
34
mobile/package.json
Normal file
34
mobile/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "funconnect-mobile",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"description": "FunConnect - Minecraft 联机客户端 (iOS & Android)",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"build:android": "eas build --platform android",
|
||||||
|
"build:ios": "eas build --platform ios",
|
||||||
|
"build:all": "eas build --platform all"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"expo": "~50.0.0",
|
||||||
|
"expo-status-bar": "~1.11.1",
|
||||||
|
"expo-clipboard": "~5.0.1",
|
||||||
|
"expo-constants": "~15.4.5",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-native": "0.73.4",
|
||||||
|
"react-native-safe-area-context": "4.8.2",
|
||||||
|
"react-native-screens": "~3.29.0",
|
||||||
|
"@react-navigation/native": "^6.1.9",
|
||||||
|
"@react-navigation/bottom-tabs": "^6.5.11",
|
||||||
|
"@react-native-async-storage/async-storage": "1.21.0",
|
||||||
|
"axios": "^1.6.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.20.0",
|
||||||
|
"@types/react": "~18.2.45",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
114
mobile/src/lib/api.ts
Normal file
114
mobile/src/lib/api.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
export interface RoomInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hostName: string;
|
||||||
|
hostPort: number;
|
||||||
|
gameVersion: string;
|
||||||
|
gameEdition: 'java' | 'bedrock';
|
||||||
|
maxPlayers: number;
|
||||||
|
currentPlayers: number;
|
||||||
|
nodeId: string;
|
||||||
|
createdAt: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerHealth {
|
||||||
|
status: string;
|
||||||
|
nodeId: string;
|
||||||
|
nodeName: string;
|
||||||
|
isMaster: boolean;
|
||||||
|
uptime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerStats {
|
||||||
|
node: { id: string; name: string; rooms: number; players: number; maxRooms: number };
|
||||||
|
cluster: { totalNodes: number; onlineNodes: number; totalRooms: number; totalPlayers: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private http: AxiosInstance | null = null;
|
||||||
|
private baseUrl: string = '';
|
||||||
|
|
||||||
|
configure(url: string) {
|
||||||
|
this.baseUrl = url.replace(/\/+$/, '');
|
||||||
|
this.http = axios.create({ baseURL: `${this.baseUrl}/api`, timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConfigured() { return !!this.http; }
|
||||||
|
get serverUrl() { return this.baseUrl; }
|
||||||
|
|
||||||
|
async getHealth(): Promise<ServerHealth> {
|
||||||
|
const res = await this.http!.get('/health');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStats(): Promise<ServerStats> {
|
||||||
|
const res = await this.http!.get('/stats');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRooms(): Promise<{ rooms: RoomInfo[]; total: number }> {
|
||||||
|
const res = await this.http!.get('/rooms');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRoom(data: {
|
||||||
|
name: string; hostName: string; hostPort: number;
|
||||||
|
gameVersion: string; gameEdition: string; maxPlayers: number; password?: string;
|
||||||
|
}) {
|
||||||
|
const res = await this.http!.post('/rooms', data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinRoom(roomId: string, password?: string) {
|
||||||
|
const res = await this.http!.post(`/rooms/${roomId}/join`, { password });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRoom(roomId: string) {
|
||||||
|
const res = await this.http!.delete(`/rooms/${roomId}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTraffic() {
|
||||||
|
const res = await this.http!.get('/traffic');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = new ApiClient();
|
||||||
|
|
||||||
|
// Settings storage
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
SERVER_URL: 'funmc_server_url',
|
||||||
|
PLAYER_NAME: 'funmc_player_name',
|
||||||
|
RECENT_SERVERS: 'funmc_recent_servers',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const storage = {
|
||||||
|
async getServerUrl(): Promise<string> {
|
||||||
|
return (await AsyncStorage.getItem(STORAGE_KEYS.SERVER_URL)) || '';
|
||||||
|
},
|
||||||
|
async setServerUrl(url: string) {
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.SERVER_URL, url);
|
||||||
|
},
|
||||||
|
async getPlayerName(): Promise<string> {
|
||||||
|
return (await AsyncStorage.getItem(STORAGE_KEYS.PLAYER_NAME)) || 'Player';
|
||||||
|
},
|
||||||
|
async setPlayerName(name: string) {
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.PLAYER_NAME, name);
|
||||||
|
},
|
||||||
|
async getRecentServers(): Promise<string[]> {
|
||||||
|
const raw = await AsyncStorage.getItem(STORAGE_KEYS.RECENT_SERVERS);
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
},
|
||||||
|
async addRecentServer(url: string) {
|
||||||
|
const recent = await this.getRecentServers();
|
||||||
|
const filtered = recent.filter(s => s !== url);
|
||||||
|
filtered.unshift(url);
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.RECENT_SERVERS, JSON.stringify(filtered.slice(0, 10)));
|
||||||
|
},
|
||||||
|
};
|
||||||
24
mobile/src/lib/theme.ts
Normal file
24
mobile/src/lib/theme.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export const colors = {
|
||||||
|
bg: '#1a1a2e',
|
||||||
|
bgDarker: '#16213e',
|
||||||
|
bgAccent: '#0f3460',
|
||||||
|
green: '#4CAF50',
|
||||||
|
greenDark: '#388E3C',
|
||||||
|
red: '#e94560',
|
||||||
|
gold: '#FFD700',
|
||||||
|
blue: '#4fc3f7',
|
||||||
|
purple: '#b388ff',
|
||||||
|
text: '#e0e0e0',
|
||||||
|
textDim: '#8a8a9a',
|
||||||
|
border: 'rgba(15, 52, 96, 0.6)',
|
||||||
|
card: '#16213e',
|
||||||
|
input: '#1a1a2e',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const spacing = {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 16,
|
||||||
|
lg: 24,
|
||||||
|
xl: 32,
|
||||||
|
};
|
||||||
158
mobile/src/screens/ConnectScreen.tsx
Normal file
158
mobile/src/screens/ConnectScreen.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, TouchableOpacity, StyleSheet,
|
||||||
|
ScrollView, Alert, ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { colors, spacing } from '../lib/theme';
|
||||||
|
import { apiClient, storage, ServerHealth } from '../lib/api';
|
||||||
|
|
||||||
|
export default function ConnectScreen() {
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [health, setHealth] = useState<ServerHealth | null>(null);
|
||||||
|
const [recentServers, setRecentServers] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSaved();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadSaved() {
|
||||||
|
const savedUrl = await storage.getServerUrl();
|
||||||
|
if (savedUrl) setUrl(savedUrl);
|
||||||
|
const recent = await storage.getRecentServers();
|
||||||
|
setRecentServers(recent);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConnect() {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) { Alert.alert('提示', '请输入服务器地址'); return; }
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
apiClient.configure(trimmed);
|
||||||
|
const data = await apiClient.getHealth();
|
||||||
|
setHealth(data);
|
||||||
|
setConnected(true);
|
||||||
|
await storage.setServerUrl(trimmed);
|
||||||
|
await storage.addRecentServer(trimmed);
|
||||||
|
setRecentServers(await storage.getRecentServers());
|
||||||
|
} catch (err: any) {
|
||||||
|
Alert.alert('连接失败', err.message || '无法连接到服务器');
|
||||||
|
setConnected(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
<Text style={styles.title}>连接服务器</Text>
|
||||||
|
<Text style={styles.desc}>输入 FunConnect 中继服务器地址</Text>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.label}>服务器地址</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={url}
|
||||||
|
onChangeText={setUrl}
|
||||||
|
placeholder="http://your-server:3000"
|
||||||
|
placeholderTextColor="#555"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{recentServers.length > 0 && (
|
||||||
|
<View style={styles.recentBox}>
|
||||||
|
<Text style={styles.recentTitle}>最近连接</Text>
|
||||||
|
{recentServers.map((s, i) => (
|
||||||
|
<TouchableOpacity key={i} onPress={() => setUrl(s)} style={styles.recentItem}>
|
||||||
|
<Text style={styles.recentText}>{s}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.btn, loading && styles.btnDisabled]}
|
||||||
|
onPress={handleConnect}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.btnText}>
|
||||||
|
{connected ? '重新连接' : '连接服务器'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{connected && health && (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>✅ 服务器已连接</Text>
|
||||||
|
<View style={styles.infoGrid}>
|
||||||
|
<InfoItem label="节点名称" value={health.nodeName} />
|
||||||
|
<InfoItem label="节点ID" value={health.nodeId?.slice(0, 8) || 'N/A'} />
|
||||||
|
<InfoItem label="运行模式" value={health.isMaster ? '主节点' : '工作节点'} />
|
||||||
|
<InfoItem label="运行时间" value={formatUptime(health.uptime)} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<Text style={styles.infoLabel}>{label}</Text>
|
||||||
|
<Text style={styles.infoValue}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(s: number): string {
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
if (h > 0) return `${h}时${m}分`;
|
||||||
|
return `${m}分`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
content: { padding: spacing.lg },
|
||||||
|
title: { fontSize: 24, fontWeight: '700', color: colors.text, marginBottom: 4 },
|
||||||
|
desc: { fontSize: 13, color: colors.textDim, marginBottom: 20 },
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.card, borderRadius: 12,
|
||||||
|
borderWidth: 1, borderColor: colors.border,
|
||||||
|
padding: spacing.lg, marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
cardTitle: { fontSize: 16, fontWeight: '700', color: colors.green, marginBottom: 12 },
|
||||||
|
label: { fontSize: 12, color: colors.textDim, marginBottom: 6, fontWeight: '500' },
|
||||||
|
input: {
|
||||||
|
backgroundColor: colors.input, borderWidth: 1, borderColor: colors.border,
|
||||||
|
borderRadius: 8, padding: 12, color: colors.text, fontSize: 14,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
backgroundColor: colors.green, borderRadius: 8,
|
||||||
|
padding: 14, alignItems: 'center', marginTop: 16,
|
||||||
|
},
|
||||||
|
btnDisabled: { opacity: 0.6 },
|
||||||
|
btnText: { color: '#fff', fontWeight: '600', fontSize: 14 },
|
||||||
|
recentBox: { marginTop: 12 },
|
||||||
|
recentTitle: { fontSize: 11, color: colors.textDim, marginBottom: 6 },
|
||||||
|
recentItem: {
|
||||||
|
backgroundColor: colors.bg, borderRadius: 6, padding: 10,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
recentText: { fontSize: 12, color: colors.textDim },
|
||||||
|
infoGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||||
|
infoItem: {
|
||||||
|
backgroundColor: colors.bg, borderRadius: 8,
|
||||||
|
padding: 10, width: '48%',
|
||||||
|
},
|
||||||
|
infoLabel: { fontSize: 11, color: colors.textDim, marginBottom: 4 },
|
||||||
|
infoValue: { fontSize: 14, fontWeight: '600', color: colors.text },
|
||||||
|
});
|
||||||
170
mobile/src/screens/CreateScreen.tsx
Normal file
170
mobile/src/screens/CreateScreen.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, TouchableOpacity, StyleSheet,
|
||||||
|
ScrollView, Alert, ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import { colors, spacing } from '../lib/theme';
|
||||||
|
import { apiClient, storage } from '../lib/api';
|
||||||
|
|
||||||
|
export default function CreateScreen() {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [port, setPort] = useState('25565');
|
||||||
|
const [version, setVersion] = useState('1.20.4');
|
||||||
|
const [edition, setEdition] = useState<'java' | 'bedrock'>('java');
|
||||||
|
const [maxPlayers, setMaxPlayers] = useState('10');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [roomId, setRoomId] = useState('');
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!apiClient.isConfigured) { Alert.alert('提示', '请先连接服务器'); return; }
|
||||||
|
if (!name.trim()) { Alert.alert('提示', '请输入房间名称'); return; }
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const playerName = await storage.getPlayerName();
|
||||||
|
const result = await apiClient.createRoom({
|
||||||
|
name: name.trim(),
|
||||||
|
hostName: playerName,
|
||||||
|
hostPort: parseInt(port) || 25565,
|
||||||
|
gameVersion: version,
|
||||||
|
gameEdition: edition,
|
||||||
|
maxPlayers: parseInt(maxPlayers) || 10,
|
||||||
|
password: password || undefined,
|
||||||
|
});
|
||||||
|
setRoomId(result.room.id);
|
||||||
|
} catch (err: any) {
|
||||||
|
Alert.alert('创建失败', err.response?.data?.error || err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyRoomId() {
|
||||||
|
await Clipboard.setStringAsync(roomId);
|
||||||
|
Alert.alert('已复制', '房间号已复制到剪贴板,分享给你的好友吧!');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomId) {
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
<View style={styles.resultCard}>
|
||||||
|
<Text style={{ fontSize: 48, textAlign: 'center' }}>🎉</Text>
|
||||||
|
<Text style={styles.resultTitle}>房间创建成功!</Text>
|
||||||
|
<Text style={styles.resultId}>{roomId}</Text>
|
||||||
|
<Text style={styles.resultHint}>将房间号分享给好友即可联机</Text>
|
||||||
|
<TouchableOpacity style={styles.btn} onPress={copyRoomId}>
|
||||||
|
<Text style={styles.btnText}>复制房间号</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.btnSecondary} onPress={() => setRoomId('')}>
|
||||||
|
<Text style={styles.btnSecondaryText}>创建新房间</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
<Text style={styles.title}>创建房间</Text>
|
||||||
|
<Text style={styles.desc}>创建联机房间并分享给好友</Text>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.label}>房间名称 *</Text>
|
||||||
|
<TextInput style={styles.input} value={name} onChangeText={setName}
|
||||||
|
placeholder="我的联机世界" placeholderTextColor="#555" />
|
||||||
|
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.label}>本地端口</Text>
|
||||||
|
<TextInput style={styles.input} value={port} onChangeText={setPort}
|
||||||
|
keyboardType="number-pad" placeholder="25565" placeholderTextColor="#555" />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.label}>最大玩家</Text>
|
||||||
|
<TextInput style={styles.input} value={maxPlayers} onChangeText={setMaxPlayers}
|
||||||
|
keyboardType="number-pad" placeholder="10" placeholderTextColor="#555" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.label}>游戏版本</Text>
|
||||||
|
<TextInput style={styles.input} value={version} onChangeText={setVersion}
|
||||||
|
placeholder="1.20.4" placeholderTextColor="#555" />
|
||||||
|
|
||||||
|
<Text style={styles.label}>版本类型</Text>
|
||||||
|
<View style={styles.editionRow}>
|
||||||
|
{(['java', 'bedrock'] as const).map(e => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={e}
|
||||||
|
style={[styles.editionBtn, edition === e && styles.editionActive]}
|
||||||
|
onPress={() => setEdition(e)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.editionText, edition === e && styles.editionTextActive]}>
|
||||||
|
{e === 'java' ? 'Java 版' : '基岩版'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.label}>房间密码(可选)</Text>
|
||||||
|
<TextInput style={styles.input} value={password} onChangeText={setPassword}
|
||||||
|
placeholder="留空则无密码" placeholderTextColor="#555" secureTextEntry />
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.btn, loading && { opacity: 0.6 }]}
|
||||||
|
onPress={handleCreate} disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? <ActivityIndicator color="#fff" /> :
|
||||||
|
<Text style={styles.btnText}>创建房间</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
content: { padding: spacing.lg },
|
||||||
|
title: { fontSize: 24, fontWeight: '700', color: colors.text, marginBottom: 4 },
|
||||||
|
desc: { fontSize: 13, color: colors.textDim, marginBottom: 20 },
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.card, borderRadius: 12,
|
||||||
|
borderWidth: 1, borderColor: colors.border, padding: spacing.lg,
|
||||||
|
},
|
||||||
|
label: { fontSize: 12, color: colors.textDim, marginBottom: 6, fontWeight: '500', marginTop: 12 },
|
||||||
|
input: {
|
||||||
|
backgroundColor: colors.input, borderWidth: 1, borderColor: colors.border,
|
||||||
|
borderRadius: 8, padding: 12, color: colors.text, fontSize: 14,
|
||||||
|
},
|
||||||
|
row: { flexDirection: 'row', gap: 12 },
|
||||||
|
editionRow: { flexDirection: 'row', gap: 8 },
|
||||||
|
editionBtn: {
|
||||||
|
flex: 1, padding: 12, borderRadius: 8, alignItems: 'center',
|
||||||
|
backgroundColor: colors.bgAccent,
|
||||||
|
},
|
||||||
|
editionActive: { backgroundColor: colors.green },
|
||||||
|
editionText: { color: colors.textDim, fontSize: 13, fontWeight: '500' },
|
||||||
|
editionTextActive: { color: '#fff' },
|
||||||
|
btn: {
|
||||||
|
backgroundColor: colors.green, borderRadius: 8,
|
||||||
|
padding: 14, alignItems: 'center', marginTop: 20,
|
||||||
|
},
|
||||||
|
btnText: { color: '#fff', fontWeight: '600', fontSize: 14 },
|
||||||
|
btnSecondary: {
|
||||||
|
borderWidth: 1, borderColor: colors.border, borderRadius: 8,
|
||||||
|
padding: 14, alignItems: 'center', marginTop: 10,
|
||||||
|
},
|
||||||
|
btnSecondaryText: { color: colors.textDim, fontWeight: '500', fontSize: 14 },
|
||||||
|
resultCard: {
|
||||||
|
backgroundColor: colors.card, borderRadius: 12,
|
||||||
|
borderWidth: 1, borderColor: colors.border, padding: spacing.xl,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
resultTitle: { fontSize: 20, fontWeight: '700', color: colors.green, marginTop: 12 },
|
||||||
|
resultId: {
|
||||||
|
fontSize: 28, fontWeight: '700', color: colors.gold,
|
||||||
|
letterSpacing: 3, marginVertical: 16,
|
||||||
|
},
|
||||||
|
resultHint: { fontSize: 13, color: colors.textDim, marginBottom: 16 },
|
||||||
|
});
|
||||||
159
mobile/src/screens/JoinScreen.tsx
Normal file
159
mobile/src/screens/JoinScreen.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, TouchableOpacity, StyleSheet,
|
||||||
|
ScrollView, Alert, ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { colors, spacing } from '../lib/theme';
|
||||||
|
import { apiClient } from '../lib/api';
|
||||||
|
|
||||||
|
export default function JoinScreen() {
|
||||||
|
const [roomId, setRoomId] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [joined, setJoined] = useState(false);
|
||||||
|
const [connectInfo, setConnectInfo] = useState<any>(null);
|
||||||
|
|
||||||
|
async function handleJoin() {
|
||||||
|
if (!apiClient.isConfigured) { Alert.alert('提示', '请先连接服务器'); return; }
|
||||||
|
if (!roomId.trim()) { Alert.alert('提示', '请输入房间号'); return; }
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await apiClient.joinRoom(roomId.trim(), password || undefined);
|
||||||
|
setConnectInfo(result);
|
||||||
|
setJoined(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
Alert.alert('加入失败', err.response?.data?.error || err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (joined && connectInfo) {
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
<View style={styles.successCard}>
|
||||||
|
<Text style={{ fontSize: 48, textAlign: 'center' }}>✅</Text>
|
||||||
|
<Text style={styles.successTitle}>验证通过!</Text>
|
||||||
|
<Text style={styles.successDesc}>请在 Minecraft 中添加以下服务器地址:</Text>
|
||||||
|
|
||||||
|
<View style={styles.addressBox}>
|
||||||
|
<Text style={styles.addressLabel}>服务器地址</Text>
|
||||||
|
<Text style={styles.addressValue}>{apiClient.serverUrl.replace(/https?:\/\//, '').replace(/:3000$/, '')}:25565</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.infoBox}>
|
||||||
|
<Text style={styles.infoTitle}>连接说明</Text>
|
||||||
|
<Text style={styles.infoStep}>1. 打开 Minecraft</Text>
|
||||||
|
<Text style={styles.infoStep}>2. 选择「多人游戏」/「添加服务器」</Text>
|
||||||
|
<Text style={styles.infoStep}>3. 输入上方服务器地址</Text>
|
||||||
|
<Text style={styles.infoStep}>4. 连接即可开始联机!</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.btnSecondary} onPress={() => { setJoined(false); setRoomId(''); }}>
|
||||||
|
<Text style={styles.btnSecondaryText}>加入其他房间</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
<Text style={styles.title}>加入房间</Text>
|
||||||
|
<Text style={styles.desc}>输入房间号加入好友的联机世界</Text>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.label}>房间号 *</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={roomId}
|
||||||
|
onChangeText={setRoomId}
|
||||||
|
placeholder="输入房间号"
|
||||||
|
placeholderTextColor="#555"
|
||||||
|
autoCapitalize="characters"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>房间密码(如需要)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder="如无密码请留空"
|
||||||
|
placeholderTextColor="#555"
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.btn, loading && { opacity: 0.6 }]}
|
||||||
|
onPress={handleJoin}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? <ActivityIndicator color="#fff" /> :
|
||||||
|
<Text style={styles.btnText}>加入房间</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.helpCard}>
|
||||||
|
<Text style={styles.helpTitle}>💡 如何联机?</Text>
|
||||||
|
<Text style={styles.helpText}>
|
||||||
|
1. 在「连接服务器」页面连接到中继服务器{'\n'}
|
||||||
|
2. 输入房主分享的房间号{'\n'}
|
||||||
|
3. 验证通过后,在 Minecraft 中添加服务器地址{'\n'}
|
||||||
|
4. 连接即可开始游戏!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
content: { padding: spacing.lg },
|
||||||
|
title: { fontSize: 24, fontWeight: '700', color: colors.text, marginBottom: 4 },
|
||||||
|
desc: { fontSize: 13, color: colors.textDim, marginBottom: 20 },
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.card, borderRadius: 12,
|
||||||
|
borderWidth: 1, borderColor: colors.border, padding: spacing.lg,
|
||||||
|
},
|
||||||
|
label: { fontSize: 12, color: colors.textDim, marginBottom: 6, fontWeight: '500', marginTop: 12 },
|
||||||
|
input: {
|
||||||
|
backgroundColor: colors.input, borderWidth: 1, borderColor: colors.border,
|
||||||
|
borderRadius: 8, padding: 12, color: colors.text, fontSize: 14,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
backgroundColor: colors.green, borderRadius: 8,
|
||||||
|
padding: 14, alignItems: 'center', marginTop: 20,
|
||||||
|
},
|
||||||
|
btnText: { color: '#fff', fontWeight: '600', fontSize: 14 },
|
||||||
|
btnSecondary: {
|
||||||
|
borderWidth: 1, borderColor: colors.border, borderRadius: 8,
|
||||||
|
padding: 14, alignItems: 'center', marginTop: 16,
|
||||||
|
},
|
||||||
|
btnSecondaryText: { color: colors.textDim, fontWeight: '500', fontSize: 14 },
|
||||||
|
successCard: {
|
||||||
|
backgroundColor: colors.card, borderRadius: 12,
|
||||||
|
borderWidth: 1, borderColor: colors.border, padding: spacing.xl,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
successTitle: { fontSize: 20, fontWeight: '700', color: colors.green, marginTop: 12 },
|
||||||
|
successDesc: { fontSize: 13, color: colors.textDim, marginTop: 8, marginBottom: 16 },
|
||||||
|
addressBox: {
|
||||||
|
backgroundColor: colors.bg, borderRadius: 10, padding: 16,
|
||||||
|
width: '100%', alignItems: 'center', marginBottom: 16,
|
||||||
|
},
|
||||||
|
addressLabel: { fontSize: 11, color: colors.textDim, marginBottom: 6 },
|
||||||
|
addressValue: { fontSize: 18, fontWeight: '700', color: colors.gold },
|
||||||
|
infoBox: {
|
||||||
|
backgroundColor: colors.bg, borderRadius: 10, padding: 16, width: '100%',
|
||||||
|
},
|
||||||
|
infoTitle: { fontSize: 13, fontWeight: '600', color: colors.green, marginBottom: 8 },
|
||||||
|
infoStep: { fontSize: 12, color: colors.textDim, marginBottom: 4 },
|
||||||
|
helpCard: {
|
||||||
|
backgroundColor: colors.card, borderRadius: 12,
|
||||||
|
borderWidth: 1, borderColor: colors.border,
|
||||||
|
padding: spacing.lg, marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
helpTitle: { fontSize: 14, fontWeight: '600', color: colors.green, marginBottom: 8 },
|
||||||
|
helpText: { fontSize: 12, color: colors.textDim, lineHeight: 20 },
|
||||||
|
});
|
||||||
186
mobile/src/screens/RoomsScreen.tsx
Normal file
186
mobile/src/screens/RoomsScreen.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, TouchableOpacity, StyleSheet, FlatList,
|
||||||
|
RefreshControl, Alert, TextInput,
|
||||||
|
} from 'react-native';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import { colors, spacing } from '../lib/theme';
|
||||||
|
import { apiClient, RoomInfo } from '../lib/api';
|
||||||
|
|
||||||
|
export default function RoomsScreen() {
|
||||||
|
const [rooms, setRooms] = useState<RoomInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [filter, setFilter] = useState<string>('all');
|
||||||
|
|
||||||
|
const loadRooms = useCallback(async () => {
|
||||||
|
if (!apiClient.isConfigured) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await apiClient.getRooms();
|
||||||
|
setRooms(data.rooms);
|
||||||
|
} catch {
|
||||||
|
Alert.alert('错误', '加载房间列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRooms();
|
||||||
|
const timer = setInterval(loadRooms, 15000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [loadRooms]);
|
||||||
|
|
||||||
|
const filtered = rooms.filter(r => {
|
||||||
|
const matchSearch = !search ||
|
||||||
|
r.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
r.id.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
r.hostName.toLowerCase().includes(search.toLowerCase());
|
||||||
|
const matchFilter = filter === 'all' || r.gameEdition === filter;
|
||||||
|
return matchSearch && matchFilter;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function copyId(id: string) {
|
||||||
|
await Clipboard.setStringAsync(id);
|
||||||
|
Alert.alert('已复制', `房间号 ${id} 已复制到剪贴板`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoom({ item }: { item: RoomInfo }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.roomCard}>
|
||||||
|
<View style={styles.roomTop}>
|
||||||
|
<View style={styles.roomIcon}><Text style={{ fontSize: 20 }}>🎮</Text></View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.roomName}>
|
||||||
|
{item.name} {item.password ? '🔒' : ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.roomMeta}>
|
||||||
|
房间号: {item.id} · 房主: {item.hostName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.roomTags}>
|
||||||
|
<View style={[styles.tag, { backgroundColor: 'rgba(79,195,247,0.1)' }]}>
|
||||||
|
<Text style={[styles.tagText, { color: colors.blue }]}>
|
||||||
|
👥 {item.currentPlayers}/{item.maxPlayers}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.tag, { backgroundColor: 'rgba(179,136,255,0.1)' }]}>
|
||||||
|
<Text style={[styles.tagText, { color: colors.purple }]}>
|
||||||
|
{item.gameEdition === 'java' ? 'Java' : '基岩'} {item.gameVersion}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity style={styles.copyBtn} onPress={() => copyId(item.id)}>
|
||||||
|
<Text style={styles.copyText}>复制号</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiClient.isConfigured) {
|
||||||
|
return (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={{ fontSize: 40 }}>🔗</Text>
|
||||||
|
<Text style={styles.emptyText}>请先连接服务器</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>房间列表</Text>
|
||||||
|
<Text style={styles.desc}>
|
||||||
|
{rooms.length} 个房间在线 · 自动刷新
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.filterRow}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
placeholder="搜索房间..."
|
||||||
|
placeholderTextColor="#555"
|
||||||
|
/>
|
||||||
|
<View style={styles.filterBtns}>
|
||||||
|
{['all', 'java', 'bedrock'].map(f => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={f}
|
||||||
|
style={[styles.filterBtn, filter === f && styles.filterActive]}
|
||||||
|
onPress={() => setFilter(f)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.filterText, filter === f && styles.filterTextActive]}>
|
||||||
|
{f === 'all' ? '全部' : f === 'java' ? 'Java' : '基岩'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={filtered}
|
||||||
|
renderItem={renderRoom}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
refreshControl={<RefreshControl refreshing={loading} onRefresh={loadRooms} tintColor={colors.green} />}
|
||||||
|
contentContainerStyle={{ padding: spacing.md }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={{ fontSize: 40 }}>🎮</Text>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
{search ? '无匹配房间' : '暂无在线房间'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
header: { paddingHorizontal: spacing.lg, paddingTop: spacing.lg },
|
||||||
|
title: { fontSize: 24, fontWeight: '700', color: colors.text },
|
||||||
|
desc: { fontSize: 12, color: colors.textDim, marginTop: 4 },
|
||||||
|
filterRow: { paddingHorizontal: spacing.md, paddingTop: spacing.md },
|
||||||
|
searchInput: {
|
||||||
|
backgroundColor: colors.input, borderWidth: 1, borderColor: colors.border,
|
||||||
|
borderRadius: 8, padding: 10, color: colors.text, fontSize: 13, marginBottom: 8,
|
||||||
|
},
|
||||||
|
filterBtns: { flexDirection: 'row', gap: 8, marginBottom: 4 },
|
||||||
|
filterBtn: {
|
||||||
|
paddingHorizontal: 14, paddingVertical: 6, borderRadius: 16,
|
||||||
|
backgroundColor: colors.bgAccent,
|
||||||
|
},
|
||||||
|
filterActive: { backgroundColor: colors.green },
|
||||||
|
filterText: { fontSize: 12, color: colors.textDim },
|
||||||
|
filterTextActive: { color: '#fff', fontWeight: '600' },
|
||||||
|
roomCard: {
|
||||||
|
backgroundColor: colors.card, borderRadius: 12,
|
||||||
|
borderWidth: 1, borderColor: colors.border,
|
||||||
|
padding: spacing.md, marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
roomTop: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
||||||
|
roomIcon: {
|
||||||
|
width: 42, height: 42, borderRadius: 10,
|
||||||
|
backgroundColor: 'rgba(76,175,80,0.15)',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
},
|
||||||
|
roomName: { fontSize: 14, fontWeight: '600', color: colors.text },
|
||||||
|
roomMeta: { fontSize: 11, color: colors.textDim, marginTop: 2 },
|
||||||
|
roomTags: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 8,
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
tag: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12 },
|
||||||
|
tagText: { fontSize: 11 },
|
||||||
|
copyBtn: {
|
||||||
|
marginLeft: 'auto', backgroundColor: colors.green,
|
||||||
|
paddingHorizontal: 12, paddingVertical: 5, borderRadius: 6,
|
||||||
|
},
|
||||||
|
copyText: { color: '#fff', fontSize: 11, fontWeight: '600' },
|
||||||
|
empty: { alignItems: 'center', paddingTop: 60 },
|
||||||
|
emptyText: { fontSize: 14, color: colors.textDim, marginTop: 12 },
|
||||||
|
});
|
||||||
113
mobile/src/screens/SettingsScreen.tsx
Normal file
113
mobile/src/screens/SettingsScreen.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, TouchableOpacity, StyleSheet,
|
||||||
|
ScrollView, Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { colors, spacing } from '../lib/theme';
|
||||||
|
import { storage } from '../lib/api';
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
const [playerName, setPlayerName] = useState('');
|
||||||
|
const [recentServers, setRecentServers] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
setPlayerName(await storage.getPlayerName());
|
||||||
|
setRecentServers(await storage.getRecentServers());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
await storage.setPlayerName(playerName.trim() || 'Player');
|
||||||
|
Alert.alert('已保存', '设置已保存');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
<Text style={styles.title}>设置</Text>
|
||||||
|
<Text style={styles.desc}>客户端偏好设置</Text>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.label}>玩家名称</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={playerName}
|
||||||
|
onChangeText={setPlayerName}
|
||||||
|
placeholder="你的游戏名"
|
||||||
|
placeholderTextColor="#555"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.btn} onPress={handleSave}>
|
||||||
|
<Text style={styles.btnText}>保存设置</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>最近连接的服务器</Text>
|
||||||
|
{recentServers.length === 0 ? (
|
||||||
|
<Text style={styles.emptyText}>暂无记录</Text>
|
||||||
|
) : (
|
||||||
|
recentServers.map((s, i) => (
|
||||||
|
<View key={i} style={styles.recentItem}>
|
||||||
|
<Text style={styles.recentText}>{s}</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>关于 FunConnect</Text>
|
||||||
|
<View style={styles.aboutRow}>
|
||||||
|
<Text style={styles.aboutLabel}>版本</Text>
|
||||||
|
<Text style={styles.aboutValue}>v1.1.0</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.aboutRow}>
|
||||||
|
<Text style={styles.aboutLabel}>平台</Text>
|
||||||
|
<Text style={styles.aboutValue}>iOS / Android</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.aboutRow}>
|
||||||
|
<Text style={styles.aboutLabel}>框架</Text>
|
||||||
|
<Text style={styles.aboutValue}>React Native + Expo</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.copyright}>© 2024 FunMC Team</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
content: { padding: spacing.lg },
|
||||||
|
title: { fontSize: 24, fontWeight: '700', color: colors.text, marginBottom: 4 },
|
||||||
|
desc: { fontSize: 13, color: colors.textDim, marginBottom: 20 },
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.card, borderRadius: 12,
|
||||||
|
borderWidth: 1, borderColor: colors.border,
|
||||||
|
padding: spacing.lg, marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
cardTitle: { fontSize: 15, fontWeight: '600', color: colors.green, marginBottom: 12 },
|
||||||
|
label: { fontSize: 12, color: colors.textDim, marginBottom: 6, fontWeight: '500' },
|
||||||
|
input: {
|
||||||
|
backgroundColor: colors.input, borderWidth: 1, borderColor: colors.border,
|
||||||
|
borderRadius: 8, padding: 12, color: colors.text, fontSize: 14,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
backgroundColor: colors.green, borderRadius: 8,
|
||||||
|
padding: 14, alignItems: 'center', marginTop: 16,
|
||||||
|
},
|
||||||
|
btnText: { color: '#fff', fontWeight: '600', fontSize: 14 },
|
||||||
|
emptyText: { fontSize: 12, color: '#555' },
|
||||||
|
recentItem: {
|
||||||
|
backgroundColor: colors.bg, borderRadius: 6, padding: 10, marginBottom: 4,
|
||||||
|
},
|
||||||
|
recentText: { fontSize: 12, color: colors.textDim },
|
||||||
|
aboutRow: {
|
||||||
|
flexDirection: 'row', justifyContent: 'space-between',
|
||||||
|
paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
aboutLabel: { fontSize: 13, color: colors.textDim },
|
||||||
|
aboutValue: { fontSize: 13, color: colors.text, fontWeight: '500' },
|
||||||
|
copyright: { fontSize: 11, color: '#555', textAlign: 'center', marginTop: 16 },
|
||||||
|
});
|
||||||
10
mobile/tsconfig.json
Normal file
10
mobile/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user