CI/CD / Test (ubuntu-latest) (push) Successful in 2m3s
CI/CD / Build Frontend (push) Failing after 11s
CI/CD / Test (macos-latest) (push) Has been cancelled
CI/CD / Test (windows-latest) (push) Has been cancelled
CI/CD / Build Desktop (linux) (push) Has been cancelled
CI/CD / Build Desktop (macos) (push) Has been cancelled
CI/CD / Build Desktop (windows) (push) Has been cancelled
CI/CD / Release (push) Has been cancelled
- Add real X25519 ECDH key exchange for initivexpand2 bootstrap - Add P-256 identity key generation with TeamSpeak tomcrypt format - Add session event loop with tokio::select! for packet/command handling - Add ServerQuery TCP client with typed parsing (channels, clients, permissions) - Add Tauri commands: connect (session-based), join_channel, send_message, disconnect - Add frontend ServerQuery snapshot panel - Fix CI: Node 22, rustup setup, npm install, workspace checks - Add mock UDP handshake tests through initserver - 54 tests passing across shared, tscore, tsaudio, tsdb
290 lines
9.0 KiB
TypeScript
290 lines
9.0 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
|
|
interface Identity {
|
|
id: string;
|
|
name: string;
|
|
counter: number;
|
|
max_counter: number;
|
|
}
|
|
|
|
interface Bookmark {
|
|
id: string;
|
|
name: string;
|
|
address: string;
|
|
port: number;
|
|
nickname: string | null;
|
|
auto_connect: boolean;
|
|
last_connected: string | null;
|
|
}
|
|
|
|
interface ServerQueryChannel {
|
|
id: number;
|
|
name: string;
|
|
total_clients: number;
|
|
}
|
|
|
|
interface ServerQueryClient {
|
|
id: number;
|
|
database_id: number;
|
|
nickname: string;
|
|
}
|
|
|
|
interface ServerQueryServerInfo {
|
|
name: string;
|
|
platform: string;
|
|
version: string;
|
|
max_clients: number;
|
|
clients_online: number;
|
|
}
|
|
|
|
interface ServerQuerySnapshot {
|
|
server: ServerQueryServerInfo | null;
|
|
channels: ServerQueryChannel[];
|
|
clients: ServerQueryClient[];
|
|
permissions: unknown[];
|
|
}
|
|
|
|
function App() {
|
|
const [identities, setIdentities] = useState<Identity[]>([]);
|
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
|
|
const [selectedBookmark, setSelectedBookmark] = useState<Bookmark | null>(null);
|
|
const [nickname, setNickname] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [connected, setConnected] = useState(false);
|
|
const [queryPort, setQueryPort] = useState(10011);
|
|
const [querySnapshot, setQuerySnapshot] = useState<ServerQuerySnapshot | null>(null);
|
|
const [queryLoading, setQueryLoading] = useState(false);
|
|
const [queryError, setQueryError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadIdentities();
|
|
loadBookmarks();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setQuerySnapshot(null);
|
|
setQueryError(null);
|
|
}, [selectedBookmark]);
|
|
|
|
async function loadIdentities() {
|
|
try {
|
|
const result = await invoke<Identity[]>('get_identities');
|
|
setIdentities(result);
|
|
} catch (error) {
|
|
console.error('Failed to load identities:', error);
|
|
}
|
|
}
|
|
|
|
async function loadBookmarks() {
|
|
try {
|
|
const result = await invoke<Bookmark[]>('get_bookmarks');
|
|
setBookmarks(result);
|
|
} catch (error) {
|
|
console.error('Failed to load bookmarks:', error);
|
|
}
|
|
}
|
|
|
|
async function handleConnect() {
|
|
if (!selectedBookmark) return;
|
|
|
|
try {
|
|
await invoke('connect', {
|
|
address: selectedBookmark.address,
|
|
port: selectedBookmark.port,
|
|
nickname: nickname || selectedBookmark.nickname || 'User',
|
|
password: password || null,
|
|
});
|
|
setConnected(true);
|
|
} catch (error) {
|
|
console.error('Failed to connect:', error);
|
|
}
|
|
}
|
|
|
|
async function handleDisconnect() {
|
|
try {
|
|
await invoke('disconnect');
|
|
setConnected(false);
|
|
} catch (error) {
|
|
console.error('Failed to disconnect:', error);
|
|
}
|
|
}
|
|
|
|
async function handleLoadServerQuery() {
|
|
if (!selectedBookmark || queryLoading) return;
|
|
|
|
setQueryLoading(true);
|
|
setQueryError(null);
|
|
try {
|
|
const snapshot = await invoke<ServerQuerySnapshot>('server_query_snapshot', {
|
|
request: {
|
|
address: selectedBookmark.address,
|
|
port: queryPort,
|
|
username: null,
|
|
password: null,
|
|
virtual_server_id: null,
|
|
include_permissions: false,
|
|
},
|
|
});
|
|
setQuerySnapshot(snapshot);
|
|
} catch (error) {
|
|
setQueryError(String(error));
|
|
setQuerySnapshot(null);
|
|
} finally {
|
|
setQueryLoading(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="app">
|
|
<header className="app-header">
|
|
<h1>ReTeamSpeak</h1>
|
|
<div className="connection-status">
|
|
{connected ? (
|
|
<span className="status connected">已连接</span>
|
|
) : (
|
|
<span className="status disconnected">未连接</span>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
<main className="app-main">
|
|
<aside className="sidebar">
|
|
<section className="bookmarks-section">
|
|
<h2>服务器书签</h2>
|
|
<div className="identity-summary">身份数量:{identities.length}</div>
|
|
<ul className="bookmark-list">
|
|
{bookmarks.map((bookmark) => (
|
|
<li
|
|
key={bookmark.id}
|
|
className={`bookmark-item ${selectedBookmark?.id === bookmark.id ? 'selected' : ''}`}
|
|
onClick={() => setSelectedBookmark(bookmark)}
|
|
>
|
|
<span className="bookmark-name">{bookmark.name}</span>
|
|
<span className="bookmark-address">{bookmark.address}:{bookmark.port}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
</aside>
|
|
|
|
<div className="content">
|
|
{selectedBookmark ? (
|
|
<div className="server-panel">
|
|
<div className="connect-form">
|
|
<h2>连接到 {selectedBookmark.name}</h2>
|
|
<div className="form-group">
|
|
<label>服务器地址</label>
|
|
<input
|
|
type="text"
|
|
value={`${selectedBookmark.address}:${selectedBookmark.port}`}
|
|
disabled
|
|
/>
|
|
</div>
|
|
<div className="form-group">
|
|
<label>昵称</label>
|
|
<input
|
|
type="text"
|
|
value={nickname}
|
|
onChange={(e) => setNickname(e.target.value)}
|
|
placeholder={selectedBookmark.nickname || '请输入昵称'}
|
|
/>
|
|
</div>
|
|
<div className="form-group">
|
|
<label>密码</label>
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="可选"
|
|
/>
|
|
</div>
|
|
<div className="form-actions">
|
|
{connected ? (
|
|
<button className="disconnect-btn" onClick={handleDisconnect}>
|
|
断开连接
|
|
</button>
|
|
) : (
|
|
<button className="connect-btn" onClick={handleConnect}>
|
|
连接
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<section className="query-panel">
|
|
<div className="query-header">
|
|
<div>
|
|
<h2>ServerQuery 快照</h2>
|
|
<p>读取公开 ServerQuery 信息,默认端口通常是 10011。</p>
|
|
</div>
|
|
<div className="query-actions">
|
|
<input
|
|
type="number"
|
|
value={queryPort}
|
|
min={1}
|
|
max={65535}
|
|
onChange={(e) => setQueryPort(Number(e.target.value))}
|
|
aria-label="ServerQuery port"
|
|
/>
|
|
<button className="connect-btn" onClick={handleLoadServerQuery} disabled={queryLoading}>
|
|
{queryLoading ? '读取中...' : '读取快照'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{queryError && <div className="query-error">{queryError}</div>}
|
|
|
|
{querySnapshot && (
|
|
<div className="query-grid">
|
|
<div className="query-card">
|
|
<h3>{querySnapshot.server?.name || '服务器'}</h3>
|
|
<p>{querySnapshot.server?.platform || '未知平台'}</p>
|
|
<p>{querySnapshot.server?.version || '未知版本'}</p>
|
|
<strong>
|
|
{querySnapshot.server?.clients_online ?? querySnapshot.clients.length}/
|
|
{querySnapshot.server?.max_clients ?? '-'} 在线
|
|
</strong>
|
|
</div>
|
|
|
|
<div className="query-card">
|
|
<h3>频道</h3>
|
|
<ul className="query-list">
|
|
{querySnapshot.channels.map((channel) => (
|
|
<li key={channel.id}>
|
|
<span>{channel.name}</span>
|
|
<small>{channel.total_clients} 人</small>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="query-card">
|
|
<h3>客户端</h3>
|
|
<ul className="query-list">
|
|
{querySnapshot.clients.map((client) => (
|
|
<li key={client.id}>
|
|
<span>{client.nickname}</span>
|
|
<small>#{client.id}</small>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
) : (
|
|
<div className="welcome">
|
|
<h2>欢迎使用 ReTeamSpeak</h2>
|
|
<p>请从左侧选择一个服务器书签进行连接</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|