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

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