feat: enhance instance details and server management #1169#1338
feat: enhance instance details and server management #1169#1338zaixizaiximeow wants to merge 1 commit intoUNIkeEN:mainfrom
Conversation
| "versionAsc" => { | ||
| summary_list.sort_by(|a, b| { | ||
| version_cmp_fn(&a.version, &b.version) | ||
| .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) |
| use url::Url; | ||
| use zip::read::ZipArchive; | ||
| // [new] | ||
| //use base64::{Engine as _, engine::general_purpose}; |
| Ok(results) => { | ||
| if let Some(res) = results.first() { | ||
| Ok(PingResult { | ||
| // 映射结果 |
| favicon: res.icon_src.clone(), | ||
| }) | ||
| } else { | ||
| Err("服务器未响应".to_string()) |
| const delayTimer = setTimeout(async () => { | ||
| setIsQuerying(true); | ||
| try { | ||
| const result = await invoke("ping_server", { address }); |
There was a problem hiding this comment.
不符合前端 service-handleService 格式规范
| name, | ||
| address, | ||
| }); | ||
| toast({ title: t("Common.success"), status: "success" }); |
There was a problem hiding this comment.
不符合 service-handleService 前端调用接口规范;且返回不应该是 Common.success,下同
| !window.confirm( | ||
| t("InstanceWorldsPage.serverList.deleteConfirm", { address }) | ||
| ) | ||
| ) |
There was a problem hiding this comment.
本项目内应使用 generalConfirmDialog 来进行确认,目前从未使用 window.confirm
| handleRetrieveGameServerList(false); | ||
| } catch (e) { | ||
| toast({ | ||
| title: t("Common.deleteFailed"), |
There was a problem hiding this comment.
service 函数处理请使用 service-handleService 规范
| return () => clearInterval(intervalId); | ||
| }, [instanceId, handleRetrieveGameServerList]); | ||
|
|
||
| // UI |
| )} | ||
| </Section> | ||
|
|
||
| {} |
There was a problem hiding this comment.
Pull request overview
This PR adds server management functionality to the instance details page, allowing users to add and delete servers from an instance's server list with a real-time server preview feature that uses debouncing.
Changes:
- Adds UI for adding and deleting servers in the instance worlds page
- Implements a new modal component for adding servers with live server preview
- Adds three new Tauri backend commands for server management (add, delete, ping)
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 20 comments.
| File | Description |
|---|---|
| src/pages/instances/details/[id]/worlds.tsx | Adds handlers for server add/delete operations, integrates new AddServerModal component, and adds delete button to server list UI |
| src/components/modals/add-server-modal.tsx | New modal component for adding servers with debounced live preview functionality |
| src-tauri/src/instance/commands.rs | Implements backend commands for adding/deleting servers to servers.dat file and pinging servers for preview |
| 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<any>(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 ( | ||
| <Modal isOpen={isOpen} onClose={handleClose} isCentered size="sm"> | ||
| <ModalOverlay backdropFilter="blur(5px)" /> | ||
| <ModalContent | ||
| bg="rgba(25, 25, 25, 0.9)" | ||
| color="white" | ||
| borderRadius="xl" | ||
| border="1px solid rgba(255,255,255,0.1)" | ||
| > | ||
| <ModalHeader fontSize="md">添加服务器</ModalHeader> | ||
| <ModalCloseButton /> | ||
| <ModalBody> | ||
| <VStack spacing={4} align="stretch"> | ||
| <FormControl> | ||
| <FormLabel fontSize="xs" opacity={0.6}> | ||
| 名称 (可选) | ||
| </FormLabel> | ||
| <Input | ||
| variant="filled" | ||
| bg="rgba(0,0,0,0.3)" | ||
| _hover={{ bg: "rgba(0,0,0,0.4)" }} | ||
| value={name} | ||
| onChange={(e) => setName(e.target.value)} | ||
| /> | ||
| </FormControl> | ||
|
|
||
| <FormControl isRequired> | ||
| <FormLabel fontSize="xs" opacity={0.6}> | ||
| 地址 (IP / 域名) | ||
| </FormLabel> | ||
| <Input | ||
| variant="filled" | ||
| bg="rgba(0,0,0,0.3)" | ||
| _hover={{ bg: "rgba(0,0,0,0.4)" }} | ||
| placeholder="play.example.com" | ||
| value={address} | ||
| onChange={(e) => setAddress(e.target.value)} | ||
| /> | ||
| </FormControl> | ||
|
|
||
| {/*overview */} | ||
| <Box | ||
| minH="70px" | ||
| p={3} | ||
| borderRadius="md" | ||
| bg="whiteAlpha.50" | ||
| border="1px solid" | ||
| borderColor="whiteAlpha.100" | ||
| > | ||
| {isQuerying ? ( | ||
| <HStack spacing={3}> | ||
| <Skeleton | ||
| startColor="whiteAlpha.200" | ||
| endColor="whiteAlpha.300" | ||
| borderRadius="sm" | ||
| boxSize="40px" | ||
| /> | ||
|
|
||
| <VStack align="start" spacing={2} flex={1}> | ||
| <Skeleton | ||
| startColor="whiteAlpha.200" | ||
| endColor="whiteAlpha.300" | ||
| h="12px" | ||
| w="80%" | ||
| /> | ||
| <Skeleton | ||
| startColor="whiteAlpha.200" | ||
| endColor="whiteAlpha.300" | ||
| h="10px" | ||
| w="40%" | ||
| /> | ||
| </VStack> | ||
| </HStack> | ||
| ) : queryResult?.error ? ( | ||
| <Text fontSize="xs" color="red.300" textAlign="center" py={4}> | ||
| 无法解析或连接服务器 | ||
| </Text> | ||
| ) : queryResult ? ( | ||
| <HStack spacing={3}> | ||
| <Image | ||
| src={queryResult.favicon} | ||
| alt="Server Favicon" | ||
| fallbackSrc="/images/icons/UnknownWorld.webp" | ||
| boxSize="40px" | ||
| borderRadius="sm" | ||
| /> | ||
| <VStack align="start" spacing={0} flex={1}> | ||
| <Text | ||
| fontSize="xs" | ||
| fontWeight="bold" | ||
| noOfLines={1} | ||
| color="blue.200" | ||
| > | ||
| {queryResult.motd || "Minecraft Server"} | ||
| </Text> | ||
| <Text fontSize="2xs" opacity={0.6}> | ||
| {queryResult.version} • {queryResult.players}/ | ||
| {queryResult.maxPlayers} | ||
| </Text> | ||
| </VStack> | ||
| <Badge colorScheme="green" variant="subtle" fontSize="2xs"> | ||
| 在线 | ||
| </Badge> | ||
| </HStack> | ||
| ) : ( | ||
| <Text | ||
| fontSize="xs" | ||
| color="whiteAlpha.400" | ||
| textAlign="center" | ||
| py={4} | ||
| > | ||
| 输入地址后自动预览 | ||
| </Text> | ||
| )} | ||
| </Box> | ||
| </VStack> | ||
| </ModalBody> | ||
|
|
||
| <ModalFooter> | ||
| <Button size="sm" variant="ghost" mr={3} onClick={handleClose}> | ||
| 取消 | ||
| </Button> | ||
| <Button | ||
| size="sm" | ||
| colorScheme="blue" | ||
| onClick={handleConfirm} | ||
| isDisabled={!address.trim() || isQuerying} | ||
| > | ||
| 确定添加 | ||
| </Button> | ||
| </ModalFooter> | ||
| </ModalContent> | ||
| </Modal> | ||
| ); | ||
| }; | ||
|
|
||
| export default AddServerModal; |
There was a problem hiding this comment.
This component contains hardcoded Chinese text strings instead of using the i18n translation system. All user-facing text should be internationalized using the t() function with locale keys. The following strings need to be moved to locale files: "添加服务器" (line 85), "名称 (可选)" (line 91), "地址 (IP / 域名)" (line 104), "无法解析或连接服务器" (line 151), "Minecraft Server" (line 169), "在线" (line 177), "输入地址后自动预览" (line 187), "取消" (line 196), "确定添加" (line 204).
| // confirm | ||
| if ( | ||
| !window.confirm( | ||
| t("InstanceWorldsPage.serverList.deleteConfirm", { address }) |
There was a problem hiding this comment.
The translation key "InstanceWorldsPage.serverList.deleteConfirm" does not exist in the locale files. This needs to be added to all locale files (en.json, zh-Hans.json, zh-Hant.json, ja.json, fr.json) with appropriate translations.
| t("InstanceWorldsPage.serverList.deleteConfirm", { address }) | |
| `Are you sure you want to delete server "${address}"?` |
| "versionAsc" => { | ||
| 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)); | ||
| } |
There was a problem hiding this comment.
The removal of the secondary sort by name (.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))) changes the existing behavior. When instances have the same version, they will no longer be sorted alphabetically by name. This appears to be an unrelated change to the PR's stated purpose of adding server management functionality. If this is intentional, it should be documented in the PR description or made in a separate commit. If unintentional, the secondary sort should be restored.
| }: AddServerModalProps) => { | ||
| const [name, setName] = useState(""); | ||
| const [address, setAddress] = useState(""); | ||
| const [queryResult, setQueryResult] = useState<any>(null); |
There was a problem hiding this comment.
The queryResult state is typed as any, which bypasses TypeScript's type safety. You should define a proper interface for the server query result that matches the PingResult structure returned from the backend (with fields: motd, version, players, maxPlayers, favicon) or handle the error case with a discriminated union type.
| if (!address || !address.includes(".") || address.length < 3) { | ||
| setQueryResult(null); | ||
| setIsQuerying(false); | ||
| return; | ||
| } |
There was a problem hiding this comment.
The validation logic !address || !address.includes(".") || address.length < 3 is overly simplistic and may reject valid server addresses or accept invalid ones. For example, it would reject "localhost" (a valid address) but accept "a.b" (too short to be useful). Consider implementing proper validation that:
- Accepts both domain names and IP addresses (IPv4/IPv6)
- Validates domain format using a regex or URL parser
- Optionally validates port numbers if included (e.g., "server.com:25565")
- Allows "localhost" as a valid address
| }; | ||
|
|
||
| const handleDeleteServer = async (address: string) => { | ||
| const versionPath = (summary as any)?.versionPath; |
There was a problem hiding this comment.
Using (summary as any) to bypass TypeScript's type checking is unsafe and can lead to runtime errors. You should properly type the summary object or access versionPath through a properly typed interface. Consider checking the InstanceSummary type definition to see if versionPath should be added as a field, or use a type guard to safely access this property.
| 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", | ||
| }); | ||
| } |
There was a problem hiding this comment.
Using window.confirm() is inconsistent with the UI patterns in the rest of the codebase. The project has a GenericConfirmDialog component (see src/components/modals/generic-confirm-dialog.tsx) that provides a styled, consistent confirmation dialog. Consider using this component instead for a better user experience and consistency with the application's design system.
| 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 handleDeleteServer = (address: string) => { | |
| const versionPath = (summary as any)?.versionPath; | |
| if (!versionPath) return; | |
| openSharedModal("confirm", { | |
| title: t("Common.confirm"), | |
| description: t("InstanceWorldsPage.serverList.deleteConfirm", { address }), | |
| confirmText: t("Common.delete"), | |
| confirmColorScheme: "red", | |
| onConfirm: async () => { | |
| try { | |
| await invoke("delete_server_from_instance", { | |
| instancePath: versionPath, | |
| address, | |
| }); | |
| toast({ title: t("Common.deleteSuccess"), status: "success" }); | |
| handleRetrieveGameServerList(false); | |
| } catch (e) { | |
| toast({ | |
| title: t("Common.deleteFailed"), | |
| description: String(e), | |
| status: "error", | |
| }); | |
| } | |
| }, | |
| }); |
| {/* add server */} | ||
| <CommonIconButton | ||
| icon={LuPlus} | ||
| label="添加服务器" |
There was a problem hiding this comment.
This label is hardcoded as Chinese text "添加服务器" instead of using the i18n translation system. It should be replaced with t("InstanceWorldsPage.serverList.addServer") or a similar locale key after adding it to the locale files.
| label="添加服务器" | |
| label={t("InstanceWorldsPage.serverList.addServer")} |
| Err("服务器未响应".to_string()) | ||
| } | ||
| } | ||
| Err(e) => Err(format!("查询失败: {:?}", e)), |
There was a problem hiding this comment.
These error messages are hardcoded in Chinese ("服务器未响应" and "查询失败"). For consistency with the rest of the codebase, error messages should be in English since they will be displayed in the frontend's error handling which can then apply proper i18n. Change these to English messages like "Server did not respond" and "Query failed".
| Err("服务器未响应".to_string()) | |
| } | |
| } | |
| Err(e) => Err(format!("查询失败: {:?}", e)), | |
| Err("Server did not respond".to_string()) | |
| } | |
| } | |
| Err(e) => Err(format!("Query failed: {:?}", e)), |
| #[tauri::command] | ||
| pub async fn ping_server(address: String) -> Result<PingResult, String> { | ||
| 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)), | ||
| } | ||
| } |
There was a problem hiding this comment.
The new Tauri command ping_server is not registered in the invoke_handler in src-tauri/src/lib.rs. This command will not be accessible from the frontend and will cause runtime errors when invoked. You need to add instance::commands::ping_server to the tauri::generate_handler![] macro in lib.rs.
|
基于 #1328 修改并合并此功能,先关闭此 PR~ |
Checklist
This PR is a ..
Related Issues
closes #1169
Description
新增添加管理服务器功能,且新增输入服务器地址时预览功能(包含防抖)



p2是查询的加载动画