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即可轻松联机。
|
||||
|
||||
本仓库包含 **两个独立项目**,可分别独立开发、部署和运行。
|
||||
本仓库包含 **三个独立项目**,覆盖全平台客户端和服务端。
|
||||
|
||||
## 支持平台
|
||||
|
||||
| 平台 | 类型 | 项目 |
|
||||
|------|------|------|
|
||||
| **Windows** | 桌面客户端 (NSIS 安装包) | `client/` |
|
||||
| **macOS** | 桌面客户端 (DMG, x64/arm64) | `client/` |
|
||||
| **Linux** | 桌面客户端 (AppImage/deb) | `client/` |
|
||||
| **iOS** | 移动客户端 | `mobile/` |
|
||||
| **Android** | 移动客户端 (APK/AAB) | `mobile/` |
|
||||
| **Ubuntu** | 中继服务器 + Web 管理面板 | `server/` |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
FunConnect/
|
||||
├── server/ # 服务端(中继服务器 + Web 管理面板 + 部署脚本)
|
||||
└── client/ # 客户端(Electron 桌面应用)
|
||||
├── client/ # 桌面客户端(Electron: Windows / macOS / Linux)
|
||||
└── mobile/ # 移动客户端(React Native + Expo: iOS / Android)
|
||||
```
|
||||
|
||||
## 服务端 (`server/`)
|
||||
@@ -25,46 +37,63 @@ FunConnect/
|
||||
- **一键部署** - Ubuntu 自动安装脚本 + systemd 服务
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run dev
|
||||
cd server && 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。
|
||||
|
||||
- **连接服务器** - 输入中继地址一键连接
|
||||
- **房间管理** - 浏览/创建/加入联机房间
|
||||
- **本地代理** - 自动建立本地代理,MC 添加 `127.0.0.1:25566` 即可联机
|
||||
- **本地代理** - 自动建立 TCP 代理,MC 添加 `127.0.0.1:25566` 即可联机
|
||||
- **设置持久化** - 记住服务器地址、玩家名等偏好
|
||||
- **系统托盘** - 最小化到托盘后台运行
|
||||
|
||||
```bash
|
||||
cd client
|
||||
npm install
|
||||
npm run dev
|
||||
cd client && npm install && npm run dev # 开发
|
||||
npm run dist:win # 打包 Windows
|
||||
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) │
|
||||
│ 桌面客户端 │◄─────►│ server/ 项目独立部署 │
|
||||
│ client/ │ │ ┌─────────┐ ┌──────────┐ │
|
||||
└───────────────┘ │ │ TCP中继 │ │ REST API │ │
|
||||
│ └─────────┘ └──────────┘ │
|
||||
┌───────────────┐ │ ┌──────────────────────┐ │
|
||||
│ Minecraft │ TCP │ │ Web 管理面板 │ │
|
||||
│ 游戏客户端 │◄─────►│ │ (React + Vite) │ │
|
||||
└───────────────┘ │ └──────────────────────┘ │
|
||||
└──────────────────────────┘
|
||||
┌──────────────────┐
|
||||
│ 桌面客户端 │ Windows / macOS / Linux
|
||||
│ Electron │ TCP 本地代理
|
||||
│ client/ │─────────┐
|
||||
└──────────────────┘ │
|
||||
▼
|
||||
┌──────────────────┐ ┌──────────────────────────┐
|
||||
│ 移动客户端 │ │ 中继服务器 (Ubuntu) │
|
||||
│ React Native │──►│ TCP 中继 + REST API │
|
||||
│ mobile/ │ │ Web 管理面板 (React) │
|
||||
└──────────────────┘ └──────────────────────────┘
|
||||
▲
|
||||
┌──────────────────┐ │
|
||||
│ Minecraft │─────────┘
|
||||
│ 游戏客户端 │ TCP 直连中继
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## 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