feat: 新增移动客户端(iOS + Android)

- 新增 mobile/ 项目:React Native + Expo
- 5个核心页面:连接服务器、房间列表、创建房间、加入房间、设置
- Tab 导航 + Minecraft 风格深色 UI
- 房间搜索/筛选(名称、房间号、房主、版本类型)
- 15秒自动刷新房间列表
- 设置持久化(AsyncStorage)
- EAS Build 配置(云端构建 iOS/Android)
- 完整 README 含构建指南
- 更新顶层 README 为三项目全平台架构
This commit is contained in:
FunMC
2026-02-23 08:08:46 +08:00
parent e73c8e536e
commit 09470c0465
17 changed files with 1309 additions and 26 deletions

View File

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

@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};

37
mobile/eas.json Normal file
View 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
View File

@@ -0,0 +1,4 @@
import { registerRootComponent } from 'expo';
import App from './App';
registerRootComponent(App);

34
mobile/package.json Normal file
View 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
View 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
View 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,
};

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

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

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

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

View 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
View File

@@ -0,0 +1,10 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}