feat: 新增移动客户端(iOS + Android)
- 新增 mobile/ 项目:React Native + Expo - 5个核心页面:连接服务器、房间列表、创建房间、加入房间、设置 - Tab 导航 + Minecraft 风格深色 UI - 房间搜索/筛选(名称、房间号、房主、版本类型) - 15秒自动刷新房间列表 - 设置持久化(AsyncStorage) - EAS Build 配置(云端构建 iOS/Android) - 完整 README 含构建指南 - 更新顶层 README 为三项目全平台架构
This commit is contained in:
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 },
|
||||
});
|
||||
Reference in New Issue
Block a user