From 8193dce6794ceec8fcc43a9d01224e5e6f69ec7e Mon Sep 17 00:00:00 2001 From: zaixizaiximeow <2770401208@qq.com> Date: Wed, 28 Jan 2026 01:53:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E6=B7=BB=E5=8A=A0=E4=B8=8E=E5=AE=9E=E4=BE=8B=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/instance/commands.rs | 156 +++++++++++++- src/components/modals/add-server-modal.tsx | 212 ++++++++++++++++++++ src/pages/instances/details/[id]/worlds.tsx | 162 +++++++++++---- 3 files changed, 478 insertions(+), 52 deletions(-) create mode 100644 src/components/modals/add-server-modal.tsx diff --git a/src-tauri/src/instance/commands.rs b/src-tauri/src/instance/commands.rs index 17e048427..cf6614e7f 100644 --- a/src-tauri/src/instance/commands.rs +++ b/src-tauri/src/instance/commands.rs @@ -65,6 +65,9 @@ use tokio; use tokio::sync::Semaphore; use url::Url; use zip::read::ZipArchive; +// [new] +//use base64::{Engine as _, engine::general_purpose}; +use serde::Serialize; #[tauri::command] pub async fn retrieve_instance_list(app: AppHandle) -> SJMCLResult> { @@ -97,16 +100,10 @@ pub async fn retrieve_instance_list(app: AppHandle) -> SJMCLResult { - summary_list.sort_by(|a, b| { - version_cmp_fn(&a.version, &b.version) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) - }); + summary_list.sort_by(|a, b| version_cmp_fn(&a.version, &b.version)); } "versionDesc" => { - summary_list.sort_by(|a, b| { - version_cmp_fn(&b.version, &a.version) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) - }); + summary_list.sort_by(|a, b| version_cmp_fn(&b.version, &a.version)); } _ => { summary_list.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); @@ -1323,3 +1320,146 @@ pub fn add_custom_instance_icon( Ok(()) } + +// [New] add server to instance's servers.dat +#[tauri::command] +pub async fn add_server_to_instance( + instance_path: String, + name: String, + address: String, +) -> Result { + use quartz_nbt::io::{read_nbt, write_nbt, Flavor}; + use quartz_nbt::{NbtCompound, NbtList}; + use std::fs::File; + use std::path::PathBuf; + + let mut dat_path = PathBuf::from(&instance_path); + dat_path.push("servers.dat"); + + let mut server_entry = NbtCompound::new(); + server_entry.insert("name", name); + server_entry.insert("ip", address); + server_entry.insert("hiddenAddress", false); + + let mut root = if dat_path.exists() { + let mut file = File::open(&dat_path).map_err(|e| e.to_string())?; + + match read_nbt(&mut file, Flavor::Uncompressed) { + Ok((compound, _)) => compound, + Err(_) => { + let mut c = NbtCompound::new(); + c.insert("servers", NbtList::new()); + c + } + } + } else { + let mut c = NbtCompound::new(); + c.insert("servers", NbtList::new()); + c + }; + + if let Ok(servers_list) = root.get_mut::<_, &mut NbtList>("servers") { + servers_list.push(server_entry); + } else { + let mut new_list = NbtList::new(); + new_list.push(server_entry); + root.insert("servers", new_list); + } + + let mut file = File::create(&dat_path).map_err(|e| e.to_string())?; + + write_nbt(&mut file, None, &root, Flavor::Uncompressed).map_err(|e| e.to_string())?; + + Ok("OK".into()) +} + +// [New] delete server from instance's servers.dat +#[tauri::command] +pub async fn delete_server_from_instance( + instance_path: String, + address: String, +) -> Result<(), String> { + use quartz_nbt::io::{read_nbt, write_nbt, Flavor}; + use quartz_nbt::{NbtCompound, NbtList, NbtTag}; + use std::fs::File; + use std::path::PathBuf; + + let mut dat_path = PathBuf::from(&instance_path); + dat_path.push("servers.dat"); + + if !dat_path.exists() { + return Ok(()); + } + + let mut file = File::open(&dat_path).map_err(|e| e.to_string())?; + let (mut root, _) = read_nbt(&mut file, Flavor::Uncompressed).map_err(|e| e.to_string())?; + + if let Ok(servers) = root.get_mut::<_, &mut NbtList>("servers") { + let mut new_servers = Vec::new(); + + for i in 0..servers.len() { + let mut should_keep = true; + if let Ok(entry) = servers.get::<&NbtCompound>(i) { + if let Ok(ip) = entry.get::<_, &str>("ip") { + if ip == address { + should_keep = false; + } + } + } + + if should_keep { + // clone new entry + new_servers.push(servers[i].clone()); + } + } + *servers = NbtList::from(new_servers); + } + + let mut file = File::create(&dat_path).map_err(|e| e.to_string())?; + write_nbt(&mut file, None, &root, Flavor::Uncompressed).map_err(|e| e.to_string())?; + + Ok(()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PingResult { + pub motd: String, + pub version: String, + pub players: u32, + pub max_players: u32, + pub favicon: String, +} + +#[tauri::command] +pub async fn ping_server(address: String) -> Result { + let server_infos = vec![GameServerInfo { + name: "Preview".to_string(), + ip: address.clone(), + icon_src: String::new(), + hidden: false, + description: String::new(), + is_queried: false, + online: false, + players_max: 0, + players_online: 0, + }]; + + match query_servers_online(server_infos).await { + Ok(results) => { + if let Some(res) = results.first() { + Ok(PingResult { + // 映射结果 + motd: res.description.clone(), + version: "Unknown".to_string(), + players: res.players_online as u32, + max_players: res.players_max as u32, + favicon: res.icon_src.clone(), + }) + } else { + Err("服务器未响应".to_string()) + } + } + Err(e) => Err(format!("查询失败: {:?}", e)), + } +} diff --git a/src/components/modals/add-server-modal.tsx b/src/components/modals/add-server-modal.tsx new file mode 100644 index 000000000..517b2f556 --- /dev/null +++ b/src/components/modals/add-server-modal.tsx @@ -0,0 +1,212 @@ +import { + Badge, + Box, + Button, + FormControl, + FormLabel, + HStack, + Image, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Skeleton, + Text, + VStack, +} from "@chakra-ui/react"; +import { invoke } from "@tauri-apps/api/core"; +import { useEffect, useState } from "react"; + +interface AddServerModalProps { + isOpen: boolean; + onClose: () => void; + onAdd: (name: string, address: string) => void; +} + +export const AddServerModal = ({ + isOpen, + onClose, + onAdd, +}: AddServerModalProps) => { + const [name, setName] = useState(""); + const [address, setAddress] = useState(""); + const [queryResult, setQueryResult] = useState(null); + const [isQuerying, setIsQuerying] = useState(false); + + useEffect(() => { + if (!address || !address.includes(".") || address.length < 3) { + setQueryResult(null); + setIsQuerying(false); + return; + } + + const delayTimer = setTimeout(async () => { + setIsQuerying(true); + try { + const result = await invoke("ping_server", { address }); + setQueryResult(result); + } catch (err) { + setQueryResult({ error: true }); + } finally { + setIsQuerying(false); + } + }, 600); + + return () => clearTimeout(delayTimer); + }, [address]); + + const handleConfirm = () => { + if (address.trim()) { + onAdd(name || "Minecraft Server", address.trim()); + handleClose(); + } + }; + + const handleClose = () => { + setName(""); + setAddress(""); + setQueryResult(null); + onClose(); + }; + + return ( + + + + 添加服务器 + + + + + + 名称 (可选) + + setName(e.target.value)} + /> + + + + + 地址 (IP / 域名) + + setAddress(e.target.value)} + /> + + + {/*overview */} + + {isQuerying ? ( + + + + + + + + + ) : queryResult?.error ? ( + + 无法解析或连接服务器 + + ) : queryResult ? ( + + Server Favicon + + + {queryResult.motd || "Minecraft Server"} + + + {queryResult.version} • {queryResult.players}/ + {queryResult.maxPlayers} + + + + 在线 + + + ) : ( + + 输入地址后自动预览 + + )} + + + + + + + + + + + ); +}; + +export default AddServerModal; diff --git a/src/pages/instances/details/[id]/worlds.tsx b/src/pages/instances/details/[id]/worlds.tsx index 58a2597b6..e67bdfd6e 100644 --- a/src/pages/instances/details/[id]/worlds.tsx +++ b/src/pages/instances/details/[id]/worlds.tsx @@ -7,17 +7,18 @@ import { Text, useDisclosure, } from "@chakra-ui/react"; -import { convertFileSrc } from "@tauri-apps/api/core"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import { openPath } from "@tauri-apps/plugin-opener"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { LuCheck, LuX } from "react-icons/lu"; +import { LuCheck, LuPlus, LuTrash2, LuX } from "react-icons/lu"; import { BeatLoader } from "react-spinners"; import { CommonIconButton } from "@/components/common/common-icon-button"; import CountTag from "@/components/common/count-tag"; import Empty from "@/components/common/empty"; import { OptionItem, OptionItemGroup } from "@/components/common/option-item"; import { Section } from "@/components/common/section"; +import AddServerModal from "@/components/modals/add-server-modal"; import WorldLevelDataModal from "@/components/modals/world-level-data-modal"; import { useLauncherConfig } from "@/contexts/config"; import { useInstanceSharedData } from "@/contexts/instance"; @@ -35,6 +36,8 @@ import { base64ImgSrc } from "@/utils/string"; const InstanceWorldsPage = () => { const { t } = useTranslation(); const { config, update } = useLauncherConfig(); + const toast = useToast(); + const { openSharedModal } = useSharedModals(); const { instanceId, summary, @@ -43,20 +46,74 @@ const InstanceWorldsPage = () => { getWorldList, isWorldListLoading: isLoading, } = useInstanceSharedData(); - const accordionStates = config.states.instanceWorldsPage.accordionStates; - const toast = useToast(); - const { openSharedModal } = useSharedModals(); const [worlds, setWorlds] = useState([]); const [selectedWorldName, setSelectedWorldName] = useState(); const [gameServers, setGameServers] = useState([]); + const accordionStates = config.states.instanceWorldsPage.accordionStates; const { isOpen: isWorldLevelDataModalOpen, onOpen: onWorldLevelDataModallOpen, onClose: onWorldLevelDataModalClose, } = useDisclosure(); + const { + isOpen: isAddServerOpen, + onOpen: onAddServerOpen, + onClose: onAddServerClose, + } = useDisclosure(); + + const handleAddServer = async (name: string, address: string) => { + const versionPath = (summary as any)?.versionPath; + if (!versionPath) return; + + try { + await invoke("add_server_to_instance", { + instancePath: versionPath, + name, + address, + }); + toast({ title: t("Common.success"), status: "success" }); + onAddServerClose(); + handleRetrieveGameServerList(false); + } catch (e) { + toast({ + title: t("Common.error"), + description: String(e), + status: "error", + }); + } + }; + + const handleDeleteServer = async (address: string) => { + const versionPath = (summary as any)?.versionPath; + if (!versionPath) return; + + // confirm + if ( + !window.confirm( + t("InstanceWorldsPage.serverList.deleteConfirm", { address }) + ) + ) + return; + + try { + await invoke("delete_server_from_instance", { + instancePath: versionPath, + address: address, + }); + toast({ title: t("Common.deleteSuccess"), status: "success" }); + handleRetrieveGameServerList(false); + } catch (e) { + toast({ + title: t("Common.deleteFailed"), + description: String(e), + status: "error", + }); + } + }; + const getWorldListWrapper = useCallback( (sync?: boolean) => { getWorldList(sync) @@ -97,40 +154,35 @@ const InstanceWorldsPage = () => { useEffect(() => { handleRetrieveGameServerList(false); handleRetrieveGameServerList(true); - - // refresh every minute to query server info const intervalId = setInterval(async () => { handleRetrieveGameServerList(true); }, 60000); return () => clearInterval(intervalId); }, [instanceId, handleRetrieveGameServerList]); + // UI const worldSecMenuOperations = [ { icon: "openFolder", - onClick: () => { - openInstanceSubdir(InstanceSubdirType.Saves); - }, + onClick: () => openInstanceSubdir(InstanceSubdirType.Saves), }, { icon: "download", - onClick: () => { + onClick: () => openSharedModal("download-resource", { initialResourceType: OtherResourceType.World, - }); - }, + }), }, { icon: "add", - onClick: () => { + onClick: () => handleImportResource({ filterName: t("InstanceDetailsLayout.instanceTabList.worlds"), filterExt: ["zip"], tgtDirType: InstanceSubdirType.Saves, decompress: true, onSuccessCallback: () => getWorldListWrapper(true), - }); - }, + }), }, { icon: "refresh", @@ -145,18 +197,13 @@ const InstanceWorldsPage = () => { { label: "", icon: "copyOrMove", - onClick: () => { + onClick: () => openSharedModal("copy-or-move", { srcResName: save.name, srcFilePath: save.dirPath, - }); - }, - }, - { - label: "", - icon: "revealFile", - onClick: () => openPath(save.dirPath), + }), }, + { label: "", icon: "revealFile", onClick: () => openPath(save.dirPath) }, { label: t("InstanceWorldsPage.worldList.viewLevelData"), icon: "info", @@ -183,6 +230,7 @@ const InstanceWorldsPage = () => { return ( <> + {/* worldlist Section */}
{ const gamemode = t( `InstanceWorldsPage.worldList.gamemode.${world.gamemode}` ); - const description = [ `${t("InstanceWorldsPage.worldList.lastPlayedAt")} ${formatRelativeTime(UNIXToISOString(world.lastPlayedAt), t)}`, t("InstanceWorldsPage.worldList.gamemodeDesc", { gamemode }), @@ -232,7 +279,6 @@ const InstanceWorldsPage = () => { ] .filter(Boolean) .join(""); - return ( { )}
- - + {/* server list Section */}
{ ); }} headExtra={ - { - handleRetrieveGameServerList(false); - handleRetrieveGameServerList(true); - }} - size="xs" - fontSize="sm" - h={21} - /> + + {/* add server */} + + { + handleRetrieveGameServerList(false); + handleRetrieveGameServerList(true); + }} + size="xs" + fontSize="sm" + h={21} + /> + } > {gameServers.length > 0 ? ( @@ -319,7 +370,7 @@ const InstanceWorldsPage = () => { /> } > - + {!server.isQueried && } {server.isQueried && server.online && ( @@ -344,6 +395,16 @@ const InstanceWorldsPage = () => { ))} + + {/* 删除按钮 */} + handleDeleteServer(server.ip)} + /> + { )}
+ + {} + + ); };