[feat] more hw info including gpu + refactor fpstest

This commit is contained in:
2025-11-08 15:43:44 +08:00
parent e146fbe393
commit e824455577
25 changed files with 2560 additions and 977 deletions

215
src-tauri/Cargo.lock generated
View File

@@ -10,6 +10,7 @@ dependencies = [
"base64 0.22.1",
"dirs 6.0.0",
"futures-util",
"gfxinfo",
"log",
"notify",
"regex",
@@ -880,14 +881,38 @@ dependencies = [
"syn 2.0.108",
]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [
"darling_core",
"darling_macro",
"darling_core 0.21.3",
"darling_macro 0.21.3",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.108",
]
[[package]]
@@ -904,13 +929,24 @@ dependencies = [
"syn 2.0.108",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"quote",
"syn 2.0.108",
]
[[package]]
name = "darling_macro"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
"darling_core",
"darling_core 0.21.3",
"quote",
"syn 2.0.108",
]
@@ -1657,6 +1693,21 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gfxinfo"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14f33b12fba19d158bebe0262fd2b6c49fee6f0e0e089f4f3fdc11c017548473"
dependencies = [
"core-foundation 0.10.1",
"io-kit-sys",
"libdrm_amdgpu_sys",
"nvml-wrapper",
"serde",
"windows 0.61.3",
"wmi",
]
[[package]]
name = "gio"
version = "0.18.4"
@@ -2236,6 +2287,16 @@ dependencies = [
"libc",
]
[[package]]
name = "io-kit-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [
"core-foundation-sys",
"mach2",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -2454,6 +2515,15 @@ version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "libdrm_amdgpu_sys"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7bf95f3c5d8a8ae4a5c0c46fe2271c16fdf8fd4b26978f44b082a0f8ebaaae"
dependencies = [
"libc",
]
[[package]]
name = "libloading"
version = "0.7.4"
@@ -2845,6 +2915,29 @@ dependencies = [
"syn 2.0.108",
]
[[package]]
name = "nvml-wrapper"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9bff0aa1d48904a1385ea2a8b97576fbdcbc9a3cfccd0d31fe978e1c4038c5"
dependencies = [
"bitflags 2.10.0",
"libloading 0.8.9",
"nvml-wrapper-sys",
"static_assertions",
"thiserror 1.0.69",
"wrapcenum-derive",
]
[[package]]
name = "nvml-wrapper-sys"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "698d45156f28781a4e79652b6ebe2eaa0589057d588d3aec1333f6466f13fcb5"
dependencies = [
"libloading 0.8.9",
]
[[package]]
name = "objc-sys"
version = "0.3.5"
@@ -4401,7 +4494,7 @@ version = "3.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955"
dependencies = [
"darling",
"darling 0.21.3",
"proc-macro2",
"quote",
"syn 2.0.108",
@@ -6254,7 +6347,7 @@ dependencies = [
"webview2-com-sys",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-implement 0.60.2",
"windows-interface",
]
@@ -6342,17 +6435,39 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddf874e74c7a99773e62b1c671427abf01a425e77c3d3fb9fb1e4883ea934529"
dependencies = [
"windows-collections 0.1.1",
"windows-core 0.60.1",
"windows-future 0.1.1",
"windows-link 0.1.3",
"windows-numerics 0.1.1",
]
[[package]]
name = "windows"
version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-collections 0.2.0",
"windows-core 0.61.2",
"windows-future",
"windows-future 0.2.1",
"windows-link 0.1.3",
"windows-numerics",
"windows-numerics 0.2.0",
]
[[package]]
name = "windows-collections"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5467f79cc1ba3f52ebb2ed41dbb459b8e7db636cc3429458d9a852e15bc24dec"
dependencies = [
"windows-core 0.60.1",
]
[[package]]
@@ -6373,13 +6488,26 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.60.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca21a92a9cae9bf4ccae5cf8368dce0837100ddf6e6d57936749e85f152f6247"
dependencies = [
"windows-implement 0.59.0",
"windows-interface",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.3.1",
]
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-implement 0.60.2",
"windows-interface",
"windows-link 0.1.3",
"windows-result 0.3.4",
@@ -6392,13 +6520,23 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-implement 0.60.2",
"windows-interface",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-future"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a787db4595e7eb80239b74ce8babfb1363d8e343ab072f2ffe901400c03349f0"
dependencies = [
"windows-core 0.60.1",
"windows-link 0.1.3",
]
[[package]]
name = "windows-future"
version = "0.2.1"
@@ -6410,6 +6548,17 @@ dependencies = [
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
@@ -6444,6 +6593,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005dea54e2f6499f2cee279b8f703b3cf3b5734a2d8d21867c8f44003182eeed"
dependencies = [
"windows-core 0.60.1",
"windows-link 0.1.3",
]
[[package]]
name = "windows-numerics"
version = "0.2.0"
@@ -6483,6 +6642,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-strings"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
@@ -6812,6 +6980,33 @@ dependencies = [
"wayland-protocols-wlr",
]
[[package]]
name = "wmi"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f902b4592b911109e7352bcfec7b754b07ec71e514d7dfa280eaef924c1cb08"
dependencies = [
"chrono",
"futures",
"log",
"serde",
"thiserror 2.0.17",
"windows 0.60.0",
"windows-core 0.60.1",
]
[[package]]
name = "wrapcenum-derive"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a76ff259533532054cfbaefb115c613203c73707017459206380f03b3b3f266e"
dependencies = [
"darling 0.20.11",
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "writeable"
version = "0.6.2"

View File

@@ -55,6 +55,7 @@ anyhow = "1.0.100"
notify = "8.2.0"
dirs = "6.0.0"
tokio = { version = "1.40", features = ["process"] }
gfxinfo = "0.1.2"
[target.'cfg(windows)'.dependencies] # Windows Only
winreg = "0.55.0"

View File

@@ -11,6 +11,7 @@ use std::path::Path;
use std::io::{BufRead, BufReader};
use tauri::path::BaseDirectory;
use tauri::Manager;
use serde::{Deserialize, Serialize};
// use tauri_plugin_shell::ShellExt;
@@ -368,9 +369,45 @@ pub async fn get_computer_info() -> Result<serde_json::Value, String> {
return Ok(serde_json::json!({}));
}
let json: serde_json::Value = serde_json::from_str(cleaned)
let mut json: serde_json::Value = serde_json::from_str(cleaned)
.map_err(|e| format!("解析 JSON 失败: {},原始输出: {}", e, cleaned))?;
// 对于 Windows 11优先使用 OSDisplayVersion 获取版本代码(如 25H2
// 如果 OSDisplayVersion 存在且包含版本代码,提取它
if let Some(os_display_version) = json.get("OSDisplayVersion").and_then(|v| v.as_str()) {
// OSDisplayVersion 格式可能是 "25H2" 或 "Windows 11 版本 25H2" 等
// 尝试提取版本代码(如 25H2
if let Some(capture) = regex::Regex::new(r"(\d+H\d+)")
.ok()
.and_then(|re| re.captures(os_display_version))
{
if let Some(version_code) = capture.get(1) {
json["ReleaseId"] = serde_json::Value::String(version_code.as_str().to_string());
}
}
}
// 如果没有从 OSDisplayVersion 获取到版本代码,尝试从注册表获取 ReleaseId
if !json.get("ReleaseId").and_then(|v| v.as_str()).is_some() {
let release_id_output = Command::new("powershell")
.args(&[
"-NoProfile",
"-Command",
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; try { (Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion').ReleaseId } catch { $null }"
])
.output()
.await;
if let Ok(release_output) = release_id_output {
if release_output.status.success() {
let release_str = String::from_utf8_lossy(&release_output.stdout).trim().to_string();
if !release_str.is_empty() {
json["ReleaseId"] = serde_json::Value::String(release_str);
}
}
}
}
Ok(json)
}
@@ -380,3 +417,62 @@ pub async fn get_computer_info() -> Result<serde_json::Value, String> {
pub async fn get_computer_info() -> Result<serde_json::Value, String> {
Ok(serde_json::json!({}))
}
/// GPU 信息结构体
#[derive(Debug, Serialize, Deserialize)]
pub struct GpuInfo {
vendor: String,
model: String,
family: String,
device_id: String,
total_vram: u64,
used_vram: u64,
load_pct: u32,
temperature: f64,
}
/// 辅助函数:格式化字节数
fn format_bytes(bytes: u64) -> String {
let gb = bytes as f64 / 1024.0 / 1024.0 / 1024.0;
if gb >= 1.0 {
format!("{:.2}GB", gb)
} else {
let mb = bytes as f64 / 1024.0 / 1024.0;
format!("{:.2}MB", mb)
}
}
/// 获取 GPU 信息
#[tauri::command]
pub fn get_gpu_info() -> Result<Option<GpuInfo>, String> {
use gfxinfo::active_gpu;
match active_gpu() {
Ok(gpu) => {
let info = gpu.info();
let temp = info.temperature() as f64 / 1000.0;
Ok(Some(GpuInfo {
vendor: gpu.vendor().to_string(),
model: gpu.model().to_string(),
family: gpu.family().to_string(),
device_id: gpu.device_id().to_string(),
total_vram: info.total_vram(),
used_vram: info.used_vram(),
load_pct: info.load_pct(),
temperature: temp,
}))
}
Err(e) => {
println!("✗ GPU 信息获取失败: {}", e);
Ok(None)
}
}
}
/// 获取显示器信息(非 Windows 平台返回空)
#[tauri::command]
#[cfg(not(target_os = "windows"))]
pub async fn get_monitor_info() -> Result<Vec<MonitorInfo>, String> {
Ok(vec![])
}

View File

@@ -170,6 +170,8 @@ fn main() {
cmds::download_app_update,
cmds::install_app_update,
cmds::get_computer_info,
cmds::get_gpu_info,
cmds::get_monitor_info,
on_button_clicked
])
.run(ctx)

View File

@@ -1,11 +1,5 @@
"use client"
import {
Card,
CardBody,
CardHeader,
CardIcon,
CardTool,
} from "@/components/window/Card"
import { Card, CardBody, CardHeader, CardIcon, CardTool } from "@/components/window/Card"
import { ToolButton } from "@/components/window/ToolButton"
import { Chip, Skeleton } from "@heroui/react"
import { Refresh, SettingConfig } from "@icon-park/react"
@@ -45,10 +39,12 @@ function HardwareInfo() {
const { mutate } = useSWRConfig()
return (
<ToolButton onClick={() => {
<ToolButton
onClick={() => {
// 使用 SWR 的 mutate 来刷新数据
mutate("/api/hardware-info")
}}>
}}
>
<Refresh />
</ToolButton>
)
@@ -60,26 +56,69 @@ interface ComputerInfo {
BiosSMBIOSBIOSVersion?: string
CsManufacturer?: string
CsName?: string
ReleaseId?: string
}
interface GpuInfo {
vendor: string
model: string
family: string
device_id: string
total_vram: number
used_vram: number
load_pct: number
temperature: number
}
interface MemoryInfo {
manufacturer?: string
part_number?: string
speed?: number // MHz
}
interface MonitorInfo {
name?: string
refresh_rate?: number // Hz
resolution_width?: number
resolution_height?: number
}
interface HardwareData {
allSysData: AllSystemInfo
computerInfo: ComputerInfo
gpuInfo: GpuInfo | null
memoryInfo: MemoryInfo[]
monitorInfo: MonitorInfo[]
}
// 硬件信息 fetcher
const hardwareInfoFetcher = async (): Promise<HardwareData> => {
// 并行获取系统信息PowerShell 信息
const [sys, computerInfoData] = await Promise.all([
// 并行获取系统信息PowerShell 信息、GPU 信息、内存信息和显示器信息
const [sys, computerInfoData, gpuInfoData, memoryInfoData, monitorInfoData] = await Promise.all([
allSysInfo(),
invoke<ComputerInfo>("get_computer_info").catch((error) => {
console.error("获取 PowerShell 信息失败:", error)
return {} as ComputerInfo
})
}),
invoke<GpuInfo | null>("get_gpu_info").catch((error) => {
console.error("获取 GPU 信息失败:", error)
return null
}),
invoke<MemoryInfo[]>("get_memory_info").catch((error) => {
console.error("获取内存信息失败:", error)
return [] as MemoryInfo[]
}),
invoke<MonitorInfo[]>("get_monitor_info").catch((error) => {
console.error("获取显示器信息失败:", error)
return [] as MonitorInfo[]
}),
])
console.log("系统信息:", sys)
console.log("PowerShell 信息:", computerInfoData)
console.log("GPU 信息:", gpuInfoData)
console.log("内存信息:", memoryInfoData)
console.log("显示器信息:", monitorInfoData)
if (sys?.cpus) {
console.log("CPU数据:", sys.cpus)
@@ -91,23 +130,25 @@ const hardwareInfoFetcher = async (): Promise<HardwareData> => {
return {
allSysData: sys,
computerInfo: computerInfoData
computerInfo: computerInfoData,
gpuInfo: gpuInfoData,
memoryInfo: memoryInfoData,
monitorInfo: monitorInfoData,
}
}
function HardwareInfoContent() {
const { data, isLoading } = useSWR<HardwareData>(
"/api/hardware-info",
hardwareInfoFetcher,
{
const { data, isLoading } = useSWR<HardwareData>("/api/hardware-info", hardwareInfoFetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 5 * 60 * 1000, // 5分钟内相同请求去重
}
)
})
const allSysData = data?.allSysData
const computerInfo = data?.computerInfo || {}
const gpuInfo = data?.gpuInfo
const memoryInfo = data?.memoryInfo || []
const monitorInfo = data?.monitorInfo || []
const formatBytes = (bytes?: number) => {
if (!bytes) return "未知"
@@ -119,25 +160,41 @@ function HardwareInfoContent() {
return `${mb.toFixed(2)}MB`
}
// 如果 PowerShell 提供了 OSDisplayVersion直接使用否则尝试从 kernel_version 推断
const windowsVersionCode = computerInfo.OSDisplayVersion || null
const memoryUsagePercent = allSysData?.total_memory && allSysData?.used_memory !== undefined
// 格式化系统信息Windows 11 (26200) 25H2
const formatSystemInfo = () => {
const osVersion = allSysData?.os_version || null
// 使用 OSDisplayVersion 作为版本代码(如 "25H2"
const osDisplayVersion = computerInfo.OSDisplayVersion || null
let systemStr = allSysData?.name || "未知"
if (osVersion) {
systemStr += ` ${osVersion}`
}
if (osDisplayVersion) {
systemStr += ` ${osDisplayVersion}`
}
return systemStr
}
const memoryUsagePercent =
allSysData?.total_memory && allSysData?.used_memory !== undefined
? Math.round((allSysData.used_memory / allSysData.total_memory) * 100)
: null
// 计算所有CPU核心的平均频率统一转换为GHz
const averageCpuFrequency = allSysData?.cpus && allSysData.cpus.length > 0
const averageCpuFrequency =
allSysData?.cpus && allSysData.cpus.length > 0
? (() => {
// 尝试多个可能的频率字段名
const frequencies = allSysData.cpus
.map(cpu => {
.map((cpu) => {
// 尝试不同的字段名
const freq = (cpu as any).frequency ?? (cpu as any).freq ?? (cpu as any).clock_speed
return freq
})
.filter((freq): freq is number => {
// 确保是有效的数字且大于0
return typeof freq === 'number' && !isNaN(freq) && freq > 0
return typeof freq === "number" && !isNaN(freq) && freq > 0
})
if (frequencies.length === 0) {
@@ -176,47 +233,47 @@ function HardwareInfoContent() {
<div className="flex flex-col gap-4">
{/* 系统信息 Skeleton */}
<div className="flex flex-col gap-2">
<Skeleton className="h-5 w-24 rounded" />
<Skeleton className="w-24 h-5 rounded" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-8 w-48 rounded-full" />
<Skeleton className="h-8 w-32 rounded-full" />
</div>
</div>
{/* 主板信息 Skeleton */}
<div className="flex flex-col gap-2">
<Skeleton className="h-5 w-16 rounded" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-8 w-40 rounded-full" />
<Skeleton className="h-8 w-36 rounded-full" />
<Skeleton className="w-48 h-8 rounded-full" />
<Skeleton className="w-32 h-8 rounded-full" />
</div>
</div>
{/* CPU 信息 Skeleton */}
<div className="flex flex-col gap-2">
<Skeleton className="h-5 w-20 rounded" />
<Skeleton className="w-20 h-5 rounded" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-8 w-56 rounded-full" />
<Skeleton className="h-8 w-32 rounded-full" />
<Skeleton className="h-8 w-28 rounded-full" />
<Skeleton className="w-56 h-8 rounded-full" />
<Skeleton className="w-32 h-8 rounded-full" />
<Skeleton className="h-8 rounded-full w-28" />
</div>
</div>
{/* 内存信息 Skeleton */}
<div className="flex flex-col gap-2">
<Skeleton className="h-5 w-16 rounded" />
<Skeleton className="w-16 h-5 rounded" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-8 w-36 rounded-full" />
<Skeleton className="h-8 w-40 rounded-full" />
<Skeleton className="h-8 w-36 rounded-full" />
<Skeleton className="h-8 rounded-full w-36" />
<Skeleton className="w-40 h-8 rounded-full" />
<Skeleton className="h-8 rounded-full w-36" />
</div>
</div>
{/* GPU 信息 Skeleton */}
<div className="flex flex-col gap-2">
<Skeleton className="h-5 w-16 rounded" />
<Skeleton className="w-16 h-5 rounded" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-8 w-52 rounded-full" />
<Skeleton className="h-8 rounded-full w-52" />
</div>
</div>
{/* 主板信息 Skeleton */}
<div className="flex flex-col gap-2">
<Skeleton className="w-16 h-5 rounded" />
<div className="flex flex-wrap gap-2">
<Skeleton className="w-40 h-8 rounded-full" />
<Skeleton className="h-8 rounded-full w-36" />
</div>
</div>
</div>
@@ -229,27 +286,9 @@ function HardwareInfoContent() {
<div className="flex flex-col gap-2">
<div className="text-sm font-medium text-foreground-600"></div>
<div className="flex flex-wrap gap-2">
{computerInfo.OsName && (
<Chip>
<span className="font-medium"></span> {computerInfo.OsName}
{windowsVersionCode && (
<span className="ml-1 text-primary">({windowsVersionCode})</span>
)}
<span className="font-medium"></span> {formatSystemInfo()}
</Chip>
)}
{!computerInfo.OsName && (
<Chip>
<span className="font-medium"></span> {allSysData?.name || "未知"} {allSysData?.os_version || ""}
{allSysData?.kernel_version && (
<>
{" "}{allSysData.kernel_version}
{windowsVersionCode && (
<span className="ml-1 text-primary">({windowsVersionCode})</span>
)}
</>
)}
</Chip>
)}
{computerInfo.CsName && (
<Chip>
<span className="font-medium"></span> {computerInfo.CsName}
@@ -263,25 +302,6 @@ function HardwareInfoContent() {
</div>
</div>
{/* 主板信息 */}
{(computerInfo.CsManufacturer || computerInfo.BiosSMBIOSBIOSVersion) && (
<div className="flex flex-col gap-2">
<div className="text-sm font-medium text-foreground-600"></div>
<div className="flex flex-wrap gap-2">
{computerInfo.CsManufacturer && (
<Chip>
<span className="font-medium"></span> {computerInfo.CsManufacturer}
</Chip>
)}
{computerInfo.BiosSMBIOSBIOSVersion && (
<Chip>
<span className="font-medium">BIOS版本</span> {computerInfo.BiosSMBIOSBIOSVersion}
</Chip>
)}
</div>
</div>
)}
{/* CPU 信息 */}
<div className="flex flex-col gap-2">
<div className="text-sm font-medium text-foreground-600"></div>
@@ -318,7 +338,18 @@ function HardwareInfoContent() {
)}
{allSysData.total_memory !== undefined && allSysData.used_memory !== undefined && (
<Chip>
<span className="font-medium"></span> {formatBytes(allSysData.total_memory - allSysData.used_memory)}
<span className="font-medium"></span>{" "}
{formatBytes(allSysData.total_memory - allSysData.used_memory)}
</Chip>
)}
{memoryInfo.length > 0 && memoryInfo[0].part_number && (
<Chip>
<span className="font-medium"></span> {memoryInfo[0].part_number}
</Chip>
)}
{memoryInfo.length > 0 && memoryInfo[0].speed && (
<Chip>
<span className="font-medium"></span> {memoryInfo[0].speed} MHz
</Chip>
)}
</div>
@@ -326,12 +357,33 @@ function HardwareInfoContent() {
)}
{/* GPU 信息 */}
{allSysData?.components && allSysData.components.length > 0 && (
{gpuInfo ? (
<div className="flex flex-col gap-2">
<div className="text-sm font-medium text-foreground-600"></div>
<div className="flex flex-wrap gap-2">
<Chip>
<span className="font-medium"></span> {gpuInfo.model}
</Chip>
<Chip>
<span className="font-medium"></span> {formatBytes(gpuInfo.total_vram)}
</Chip>
<Chip>
<span className="font-medium"></span> {formatBytes(gpuInfo.used_vram)}
</Chip>
<Chip>
<span className="font-medium"></span> {gpuInfo.temperature.toFixed(2)}°C
</Chip>
</div>
</div>
) : (
allSysData?.components &&
allSysData.components.length > 0 &&
(() => {
const gpuComponents = allSysData.components.filter((comp) =>
comp.label?.toLowerCase().includes('gpu') ||
comp.label?.toLowerCase().includes('graphics') ||
comp.label?.toLowerCase().includes('显卡')
const gpuComponents = allSysData.components.filter(
(comp) =>
comp.label?.toLowerCase().includes("gpu") ||
comp.label?.toLowerCase().includes("graphics") ||
comp.label?.toLowerCase().includes("显卡")
)
if (gpuComponents.length === 0) return null
@@ -342,9 +394,12 @@ function HardwareInfoContent() {
<div className="flex flex-wrap gap-2">
{gpuComponents.map((gpu, index) => (
<Chip key={index}>
<span className="font-medium">GPU{index > 0 ? ` ${index + 1}` : ""}</span> {gpu.label || "未知"}
<span className="font-medium">GPU{index > 0 ? ` ${index + 1}` : ""}</span>{" "}
{gpu.label || "未知"}
{gpu.temperature !== undefined && (
<span className="ml-1">({gpu.temperature}°C{gpu.max !== undefined ? ` / ${gpu.max}°C` : ""})</span>
<span className="ml-1">
({gpu.temperature}°C{gpu.max !== undefined ? ` / ${gpu.max}°C` : ""})
</span>
)}
</Chip>
))}
@@ -354,6 +409,45 @@ function HardwareInfoContent() {
})()
)}
{/* 主板信息 */}
{(computerInfo.CsManufacturer || computerInfo.BiosSMBIOSBIOSVersion) && (
<div className="flex flex-col gap-2">
<div className="text-sm font-medium text-foreground-600"></div>
<div className="flex flex-wrap gap-2">
{computerInfo.CsManufacturer && (
<Chip>
<span className="font-medium"></span> {computerInfo.CsManufacturer}
</Chip>
)}
{computerInfo.BiosSMBIOSBIOSVersion && (
<Chip>
<span className="font-medium">BIOS版本</span> {computerInfo.BiosSMBIOSBIOSVersion}
</Chip>
)}
</div>
</div>
)}
{/* 显示器信息 */}
{monitorInfo.length > 0 && monitorInfo.some((m) => m.refresh_rate) && (
<div className="flex flex-col gap-2">
<div className="text-sm font-medium text-foreground-600"></div>
<div className="flex flex-wrap gap-2">
{monitorInfo.map(
(monitor, index) =>
monitor.refresh_rate && (
<Chip key={index}>
<span className="font-medium">
{monitorInfo.length > 1 ? ` ${index + 1}` : ""}
</span>{" "}
{monitor.refresh_rate} Hz
</Chip>
)
)}
</div>
</div>
)}
{/* 电池信息 */}
{allSysData?.batteries && allSysData.batteries.length > 0 && (
<div className="flex flex-col gap-2">
@@ -365,7 +459,9 @@ function HardwareInfoContent() {
{battery.state && `${battery.state} `}
{battery.state_of_charge !== undefined && `${battery.state_of_charge}% `}
{battery.energy_full !== undefined && battery.energy !== undefined && (
<span>({formatBytes(battery.energy)} / {formatBytes(battery.energy_full)})</span>
<span>
({formatBytes(battery.energy)} / {formatBytes(battery.energy_full)})
</span>
)}
</Chip>
))}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
import { CircularProgress } from "@heroui/react"
import type { BatchTestProgress as BatchTestProgressType } from "../types"
interface BatchTestProgressProps {
progress: BatchTestProgressType | null
}
export function BatchTestProgress({ progress }: BatchTestProgressProps) {
if (!progress) return null
return (
<div className="flex flex-col gap-1.5 items-center justify-center">
<div className="relative">
<CircularProgress
aria-label="批量测试进度"
value={(progress.current / progress.total) * 100}
color="primary"
size="sm"
showValueLabel={false}
classNames={{
svg: " ",
}}
/>
<div className="absolute inset-0 flex items-center justify-center text-xs font-medium">
{progress.current}/{progress.total}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
import { Button } from "@heroui/react"
import { Edit } from "@icon-park/react"
interface NoteCellProps {
note: string
onEdit: () => void
}
export function NoteCell({ note, onEdit }: NoteCellProps) {
return (
<div className="flex items-center min-w-0 gap-1">
<span className="flex-1 min-w-0 truncate select-text">
{note || "无备注"}
</span>
<Button
size="sm"
isIconOnly
variant="light"
onPress={onEdit}
className="h-5 min-w-5 shrink-0"
>
<Edit size={12} />
</Button>
</div>
)
}

View File

@@ -0,0 +1,193 @@
import { Button, Input, Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Chip } from "@heroui/react"
import { List } from "@icon-park/react"
import { PRESET_RESOLUTIONS } from "../constants"
import type { Resolution } from "../types"
import type { useFpsTestStore } from "@/store/fps_test"
interface ResolutionConfigProps {
resolutionWidth: string
resolutionHeight: string
isResolutionEnabled: boolean
isResolutionGroupEnabled: boolean
isFullscreen: boolean
resolutionGroup: Resolution[]
isMonitoring: boolean
fpsTest: ReturnType<typeof useFpsTestStore>
onPresetResolution: (preset: Resolution) => void
}
export function ResolutionConfig({
resolutionWidth,
resolutionHeight,
isResolutionEnabled,
isResolutionGroupEnabled,
isFullscreen,
resolutionGroup,
isMonitoring,
fpsTest,
onPresetResolution,
}: ResolutionConfigProps) {
return (
<div className="flex items-end gap-2 shrink-0">
{/* 分辨率设置 */}
<div className="flex flex-col gap-1.5">
{/* 工具栏:分辨率标签 + 按钮 */}
<div className="flex items-center gap-2">
<label className="text-xs text-default-500 shrink-0"></label>
<div className="flex flex-wrap items-center gap-1">
<Button
size="sm"
variant={isResolutionGroupEnabled ? "solid" : "flat"}
color={isResolutionGroupEnabled ? "primary" : "default"}
onPress={() => {
if (!isMonitoring) {
const newValue = !isResolutionGroupEnabled
fpsTest.setIsResolutionGroupEnabled(newValue)
// 启用分辨率组时,自动启用分辨率功能
if (newValue && !isResolutionEnabled) {
fpsTest.setIsResolutionEnabled(true)
}
}
}}
isDisabled={isMonitoring}
className="h-5 gap-1 flex px-1.5 min-w-fit text-xs font-medium"
>
<List size={12} />
</Button>
{!isResolutionGroupEnabled && (
<>
<Dropdown placement="bottom-end" className="min-w-fit">
<DropdownTrigger>
<Button
size="sm"
variant="flat"
className="h-5 min-w-[40px] px-1.5 text-xs"
isDisabled={!isResolutionEnabled || isMonitoring}
>
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="预设分辨率" className="">
{PRESET_RESOLUTIONS.map((preset) => (
<DropdownItem
key={preset.label}
onPress={() => onPresetResolution(preset)}
>
{preset.label}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
<Button
size="sm"
variant={isResolutionEnabled ? "solid" : "flat"}
color={isResolutionEnabled ? "primary" : "default"}
onPress={() => fpsTest.setIsResolutionEnabled(!isResolutionEnabled)}
isDisabled={isMonitoring}
className="h-5 min-w-[40px] px-1.5 text-xs font-medium"
>
{isResolutionEnabled ? "启用" : "关闭"}
</Button>
</>
)}
{isResolutionGroupEnabled && (
<>
<Dropdown placement="bottom-end" className="min-w-fit">
<DropdownTrigger>
<Button
size="sm"
variant="flat"
className="h-5 min-w-[40px] px-1.5 text-xs"
isDisabled={isMonitoring}
>
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="预设分辨率" className="">
{PRESET_RESOLUTIONS.map((preset) => (
<DropdownItem
key={preset.label}
onPress={() => {
if (!isMonitoring) {
fpsTest.addResolutionToGroup({
width: preset.width,
height: preset.height,
label: preset.label,
})
}
}}
>
{preset.label}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
<Button
size="sm"
variant="flat"
onPress={() => {
if (!isMonitoring && resolutionWidth && resolutionHeight) {
fpsTest.addResolutionToGroup({
width: resolutionWidth,
height: resolutionHeight,
label: `${resolutionWidth}x${resolutionHeight}`,
})
}
}}
isDisabled={isMonitoring || !resolutionWidth || !resolutionHeight}
className="h-5 min-w-[40px] px-1.5 text-xs"
>
</Button>
</>
)}
</div>
</div>
{/* 主体:宽高输入框 + 全屏按钮(始终显示) */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<Input
size="sm"
type="number"
placeholder="宽"
value={resolutionWidth}
onValueChange={(val) => fpsTest.setResolution(val, resolutionHeight)}
isDisabled={
isResolutionGroupEnabled
? isMonitoring
: !isResolutionEnabled || isMonitoring
}
className="w-20"
/>
<span className="text-xs text-default-400">x</span>
<Input
size="sm"
type="number"
placeholder="高"
value={resolutionHeight}
onValueChange={(val) => fpsTest.setResolution(resolutionWidth, val)}
isDisabled={
isResolutionGroupEnabled
? isMonitoring
: !isResolutionEnabled || isMonitoring
}
className="w-20"
/>
</div>
<Button
size="sm"
variant={isFullscreen ? "solid" : "flat"}
color={isFullscreen ? "primary" : "default"}
onPress={() => fpsTest.setIsFullscreen(!isFullscreen)}
isDisabled={isMonitoring || (!isResolutionEnabled && !isResolutionGroupEnabled)}
className="font-medium"
>
{isFullscreen ? "全屏" : "窗口化"}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,112 @@
import { Tabs, Tab, Select, SelectItem, Input } from "@heroui/react"
import { BENCHMARK_MAPS } from "../constants"
import type { useFpsTestStore } from "@/store/fps_test"
interface TestConfigPanelProps {
selectedMapIndex: number
onMapIndexChange: (index: number) => void
batchTestCount: number
onBatchTestCountChange: (count: number) => void
testNote: string
onTestNoteChange: (note: string) => void
customLaunchOption: string
onCustomLaunchOptionChange: (option: string) => void
isMonitoring: boolean
fpsTest: ReturnType<typeof useFpsTestStore>
}
export function TestConfigPanel({
selectedMapIndex,
onMapIndexChange,
batchTestCount,
onBatchTestCountChange,
testNote,
onTestNoteChange,
customLaunchOption,
onCustomLaunchOptionChange,
isMonitoring,
fpsTest,
}: TestConfigPanelProps) {
return (
<>
{/* 备注单独一行 - 放在最上面 */}
<div className="flex flex-row gap-1.5">
<div className="flex flex-col gap-1.5">
<label className="text-xs text-default-500"></label>
<div className="flex items-center gap-2">
<Tabs
selectedKey={String(selectedMapIndex)}
onSelectionChange={(key) => {
if (!isMonitoring) {
onMapIndexChange(Number(key))
}
}}
aria-label="测试地图选择"
size="sm"
radius="lg"
>
{BENCHMARK_MAPS.map((map, index) => (
<Tab key={String(index)} title={map.label} />
))}
</Tabs>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-default-500"></label>
<Select
size="md"
selectedKeys={[String(batchTestCount)]}
onSelectionChange={(keys) => {
const value = Array.from(keys)[0]
if (value && !isMonitoring) {
onBatchTestCountChange(Number(value))
}
}}
isDisabled={isMonitoring}
className="w-24"
aria-label="批量测试次数"
>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((count) => (
<SelectItem
key={String(count)}
title={count === 1 ? "单次" : `${count}`}
></SelectItem>
))}
</Select>
</div>
<div className="flex flex-col gap-1.5 flex-1">
<label className="text-xs text-default-500"></label>
<div className="flex items-center gap-2">
<Input
size="md"
placeholder="输入测试备注"
value={testNote}
onValueChange={onTestNoteChange}
isDisabled={isMonitoring}
className="flex-1"
/>
</div>
</div>
</div>
{/* 启动项占满一行,右侧放置分辨率和全屏切换 */}
<div className="flex items-start gap-4">
{/* 自定义启动项 */}
<div className="flex flex-col flex-1 gap-1.5">
<label className="h-5 text-xs text-default-500"></label>
<div className="flex items-center gap-2">
<Input
size="md"
placeholder="输入自定义启动参数(可选)"
value={customLaunchOption}
onValueChange={onCustomLaunchOptionChange}
isDisabled={isMonitoring}
className="flex-1"
/>
</div>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,53 @@
import { Chip } from "@heroui/react"
import { extractFpsMetrics } from "../utils/fps-metrics"
interface TestResultDisplayProps {
testResult: string | null
testTimestamp: string | null
isMonitoring: boolean
}
export function TestResultDisplay({
testResult,
testTimestamp,
isMonitoring,
}: TestResultDisplayProps) {
if (!testResult || !testTimestamp) {
if (isMonitoring) {
return (
<Chip size="lg" color="primary" variant="flat" className="text-xs">
...
</Chip>
)
}
return null
}
const { avg, p1 } = extractFpsMetrics(testResult)
return (
<>
<div className="px-3 py-1.5 h-12 rounded-md bg-default-100 dark:bg-default-50 text-xs flex flex-col justify-center">
<div className="text-xs text-default-500"></div>
<div className="font-medium">{testTimestamp}</div>
</div>
<div className="px-3 py-1.5 h-12 rounded-md bg-default-100 dark:bg-default-50 text-xs flex items-center">
<div className="flex items-center gap-3">
<div>
<div className="text-xs text-default-500"></div>
<div className="font-medium">
{avg !== null ? `${avg.toFixed(1)}` : "N/A"}
</div>
</div>
<div>
<div className="text-xs text-default-500">P1低帧</div>
<div className="font-medium">
{p1 !== null ? `${p1.toFixed(1)}` : "N/A"}
</div>
</div>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,135 @@
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
Button,
Tooltip,
} from "@heroui/react"
import { Delete } from "@icon-park/react"
import { addToast } from "@heroui/react"
import { NoteCell } from "./NoteCell"
import { formatVideoSettingSummary } from "../utils/csv-export"
import type { FpsTestResult } from "@/store/fps_test"
import type { useFpsTestStore } from "@/store/fps_test"
interface TestResultsTableProps {
results: FpsTestResult[]
fpsTest: ReturnType<typeof useFpsTestStore>
onEditNote: (resultId: string, currentNote: string) => void
}
export function TestResultsTable({
results,
fpsTest,
onEditNote,
}: TestResultsTableProps) {
return (
<div className="relative flex flex-col gap-2">
<Table
aria-label="测试结果表格"
selectionMode="none"
classNames={{
wrapper: "overflow-auto",
base: "min-h-[222px]",
table: "min-w-full",
th: "px-3 py-2 text-xs font-semibold whitespace-nowrap",
td: "px-3 py-2 text-xs",
}}
>
<TableHeader>
<TableColumn minWidth={140}></TableColumn>
<TableColumn width={80}></TableColumn>
<TableColumn width={80}></TableColumn>
<TableColumn width={80}>P1低帧</TableColumn>
<TableColumn width={100}>CPU</TableColumn>
<TableColumn minWidth={80}></TableColumn>
<TableColumn minWidth={100}>GPU</TableColumn>
<TableColumn width={80}></TableColumn>
<TableColumn width={120}></TableColumn>
<TableColumn minWidth={100}></TableColumn>
<TableColumn width={80} align="center">
</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无测试记录">
{results.map((result) => (
<TableRow key={result.id}>
<TableCell className="text-xs whitespace-nowrap">
{result.testTime}
</TableCell>
<TableCell className="text-xs">
<div className="truncate">{result.mapLabel}</div>
</TableCell>
<TableCell className="text-xs">
{result.avg !== null ? `${result.avg.toFixed(1)}` : "N/A"}
</TableCell>
<TableCell className="text-xs">
{result.p1 !== null ? `${result.p1.toFixed(1)}` : "N/A"}
</TableCell>
<TableCell className="text-xs max-w-[175px]">
<Tooltip
content={result.hardwareInfo?.cpu || "N/A"}
delay={500}
placement="top"
>
<div className="truncate cursor-help">
{result.hardwareInfo?.cpu || "N/A"}
</div>
</Tooltip>
</TableCell>
<TableCell className="text-xs">
<div className="truncate">{result.hardwareInfo?.os || "N/A"}</div>
</TableCell>
<TableCell className="text-xs">
<div className="truncate">{result.hardwareInfo?.gpu || "N/A"}</div>
</TableCell>
<TableCell className="text-xs whitespace-nowrap">
{result.hardwareInfo?.memory
? `${result.hardwareInfo.memory}GB`
: "N/A"}
</TableCell>
<TableCell className="text-xs">
<Tooltip content={formatVideoSettingSummary(result.videoSetting)}>
<span className="truncate cursor-help">
{result.videoSetting
? `${result.videoSetting.defaultres}x${result.videoSetting.defaultresheight}`
: "N/A"}
</span>
</Tooltip>
</TableCell>
<TableCell className="text-xs">
<NoteCell
note={result.note || ""}
onEdit={() => onEditNote(result.id, result.note || "")}
/>
</TableCell>
<TableCell>
<div className="flex items-center justify-center">
<Button
size="sm"
isIconOnly
variant="light"
onPress={() => {
fpsTest.removeResult(result.id)
addToast({
title: "已删除测试记录",
variant: "flat",
})
}}
className="h-6 min-w-6"
>
<Delete size={14} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}

View File

@@ -0,0 +1,32 @@
// 测试地图配置
export const BENCHMARK_MAPS = [
{
name: "de_dust2_benchmark",
workshopId: "3240880604",
map: "de_dust2_benchmark",
label: "Dust2",
},
{
name: "de_ancient",
workshopId: "3472126051",
map: "de_ancient",
label: "Ancient",
},
] as const
// 测试超时时间(毫秒)
export const TEST_TIMEOUT = 200000 // 200秒
// 预设分辨率列表
export const PRESET_RESOLUTIONS = [
{ width: "800", height: "600", label: "800x600" },
{ width: "1024", height: "768", label: "1024x768" },
{ width: "1280", height: "960", label: "1280x960" },
{ width: "1440", height: "1080", label: "1440x1080" },
{ width: "1920", height: "1080", label: "1920x1080" },
{ width: "1920", height: "1440", label: "1920x1440" },
{ width: "2560", height: "1440", label: "2560x1440" },
{ width: "2880", height: "2160", label: "2880x2160" },
{ width: "3840", height: "2160", label: "3840x2160" },
] as const

View File

@@ -0,0 +1,32 @@
import { useState, useEffect, useCallback } from "react"
import { invoke } from "@tauri-apps/api/core"
export function useGameMonitor() {
const [isGameRunning, setIsGameRunning] = useState(false)
// 检测游戏是否运行
const checkGameRunning = useCallback(async () => {
try {
const result = await invoke<boolean>("check_process_running", {
processName: "cs2.exe",
}).catch(() => false)
setIsGameRunning(result)
return result
} catch {
setIsGameRunning(false)
return false
}
}, [])
// 定期检测游戏运行状态
useEffect(() => {
void checkGameRunning()
const interval = setInterval(() => {
void checkGameRunning()
}, 3000)
return () => clearInterval(interval)
}, [checkGameRunning])
return { isGameRunning, checkGameRunning }
}

View File

@@ -0,0 +1,62 @@
import { useState, useEffect } from "react"
import { allSysInfo, type AllSystemInfo } from "tauri-plugin-system-info-api"
import { invoke } from "@tauri-apps/api/core"
interface GpuInfo {
vendor: string
model: string
family: string
device_id: string
total_vram: number
used_vram: number
load_pct: number
temperature: number
}
interface ComputerInfo {
OsName?: string
OSDisplayVersion?: string
BiosSMBIOSBIOSVersion?: string
CsManufacturer?: string
CsName?: string
ReleaseId?: string
}
export interface HardwareInfoWithGpu {
systemInfo: AllSystemInfo | null
gpuInfo: GpuInfo | null
computerInfo: ComputerInfo | null
}
export function useHardwareInfo() {
const [hardwareInfo, setHardwareInfo] = useState<HardwareInfoWithGpu | null>(null)
useEffect(() => {
const fetchHardwareInfo = async () => {
try {
const [sys, gpuInfo, computerInfo] = await Promise.all([
allSysInfo(),
invoke<GpuInfo | null>("get_gpu_info").catch((error) => {
console.error("获取 GPU 信息失败:", error)
return null
}),
invoke<ComputerInfo>("get_computer_info").catch((error) => {
console.error("获取 PowerShell 信息失败:", error)
return {} as ComputerInfo
})
])
setHardwareInfo({
systemInfo: sys,
gpuInfo,
computerInfo
})
} catch (error) {
console.error("获取硬件信息失败:", error)
}
}
void fetchHardwareInfo()
}, [])
return hardwareInfo
}

View File

@@ -0,0 +1,113 @@
import { useEffect, useRef } from "react"
import { invoke } from "@tauri-apps/api/core"
import { addToast } from "@heroui/react"
import { readResult, type ReadResultParams } from "../services/resultReader"
import { TEST_TIMEOUT } from "../constants"
export interface UseTestMonitorParams {
isMonitoring: boolean
cs2Dir: string | null
readResultParams: ReadResultParams
testStartTimestamp: string | null
testStartTime: number | null
autoCloseGame: boolean
onTestComplete: () => void
onTestTimeout: () => void
}
export function useTestMonitor(params: UseTestMonitorParams) {
const {
isMonitoring,
cs2Dir,
readResultParams,
testStartTimestamp,
testStartTime,
autoCloseGame,
onTestComplete,
onTestTimeout,
} = params
const monitoringIntervalRef = useRef<NodeJS.Timeout | null>(null)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
// 开始监控文件更新
useEffect(() => {
if (isMonitoring && cs2Dir) {
// 每2秒检查一次文件更新
monitoringIntervalRef.current = setInterval(async () => {
const success = await readResult(readResultParams, true) // 静默读取
if (success) {
// 读取成功,调用完成回调
onTestComplete()
// 停止监控
if (monitoringIntervalRef.current) {
clearInterval(monitoringIntervalRef.current)
monitoringIntervalRef.current = null
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
return
}
}, 2000) // 每2秒检查一次
// 设置超时
if (testStartTime) {
timeoutRef.current = setTimeout(() => {
// 超时,认为测试失败
if (monitoringIntervalRef.current) {
clearInterval(monitoringIntervalRef.current)
monitoringIntervalRef.current = null
}
onTestTimeout()
}, TEST_TIMEOUT)
}
} else {
// 停止监控
if (monitoringIntervalRef.current) {
clearInterval(monitoringIntervalRef.current)
monitoringIntervalRef.current = null
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
// 清理函数
return () => {
if (monitoringIntervalRef.current) {
clearInterval(monitoringIntervalRef.current)
monitoringIntervalRef.current = null
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [
isMonitoring,
cs2Dir,
readResultParams,
testStartTimestamp,
testStartTime,
autoCloseGame,
onTestComplete,
onTestTimeout,
])
return {
stopMonitoring: () => {
if (monitoringIntervalRef.current) {
clearInterval(monitoringIntervalRef.current)
monitoringIntervalRef.current = null
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
},
}
}

View File

@@ -0,0 +1,6 @@
"use client"
// 导出重构后的FpsTest组件
// 主组件文件已重构,使用提取的模块
export { FpsTest } from "../FpsTest"

View File

@@ -0,0 +1,271 @@
import { invoke } from "@tauri-apps/api/core"
import { addToast } from "@heroui/react"
import { parseVProfReport } from "../utils/vprof-parser"
import { compareTimestamps } from "../utils/timestamp"
import { extractFpsMetrics } from "../utils/fps-metrics"
import type { useSteamStore } from "@/store/steam"
import type { useToolStore } from "@/store/tool"
import type { useFpsTestStore } from "@/store/fps_test"
import type { AllSystemInfo } from "tauri-plugin-system-info-api"
import { BENCHMARK_MAPS } from "../constants"
export interface ReadResultParams {
steam: ReturnType<typeof useSteamStore>
tool: ReturnType<typeof useToolStore>
fpsTest: ReturnType<typeof useFpsTestStore>
selectedMapIndex: number
hardwareInfo: AllSystemInfo | null
testNote: string
batchTestProgress: { current: number; total: number } | null
currentTestResolution: { width: string; height: string; label: string } | null
resolutionGroupInfo: {
resIndex: number
totalResolutions: number
totalTestCount: number
currentBatchIndex: number
batchCount: number
} | null
isResolutionGroupEnabled: boolean
testStartTimestamp: string | null
lastTestTimestamp: React.MutableRefObject<string | null>
onResultRead: (data: {
timestamp: string
data: string
avg: number | null
p1: number | null
}) => void
}
export async function readResult(
params: ReadResultParams,
silent = false
): Promise<boolean> {
const {
steam,
tool,
fpsTest,
selectedMapIndex,
hardwareInfo,
testNote,
batchTestProgress,
currentTestResolution,
resolutionGroupInfo,
isResolutionGroupEnabled,
testStartTimestamp,
lastTestTimestamp,
onResultRead,
} = params
if (!steam.state.cs2Dir) {
if (!silent) {
addToast({ title: "请先配置 CS2 路径", variant: "flat" })
}
return false
}
try {
// 获取 console.log 路径
let consoleLogPath: string
try {
consoleLogPath = await invoke<string>("get_console_log_path", {
csPath: steam.state.cs2Dir,
})
} catch (error) {
console.error("获取控制台日志路径失败:", error)
if (!silent) {
addToast({
title: "获取控制台日志路径失败",
color: "warning",
})
}
return false
}
// 读取 VProf 报告
let report: string
try {
report = await invoke<string>("read_vprof_report", {
consoleLogPath: consoleLogPath,
})
} catch (error) {
console.error("读取性能报告失败:", error)
if (!silent) {
addToast({
title: "读取性能报告失败",
color: "warning",
})
}
return false
}
if (report && report.trim().length > 0) {
const parsed = parseVProfReport(report)
if (parsed) {
// 如果设置了测试开始时间且是自动监听silent=true验证报告时间戳是否晚于测试开始时间
// 手动读取silent=false时允许读取任何结果
if (silent && testStartTimestamp) {
// 如果报告时间戳早于或等于测试开始时间,则视为旧数据,忽略
if (!compareTimestamps(parsed.timestamp, testStartTimestamp)) {
// 这是旧数据,不处理
return false
}
}
// 保存最后一次测试的时间戳(用于平均值记录)
lastTestTimestamp.current = parsed.timestamp
// 提取 avg 和 p1 值
const { avg, p1 } = extractFpsMetrics(parsed.data)
// 保存测试结果
const now = new Date()
const testDate = now.toISOString()
const mapConfig = BENCHMARK_MAPS[selectedMapIndex]
// 测试结束后读取视频设置(检测分辨率)
if (steam.state.steamDirValid && steam.currentUser()) {
await tool.getVideoConfig(steam.state.steamDir, steam.currentUser()?.steam_id32 || 0)
}
// 使用读取到的视频设置(测试结束后读取的)
const currentVideoSetting = tool.store.state.videoSetting
// 如果是批量测试,添加带批量标识和分辨率信息的备注
if (batchTestProgress) {
let batchNote = ""
if (currentTestResolution) {
batchNote = `[${currentTestResolution.label}]`
}
if (testNote) {
batchNote = batchNote ? `${testNote} ${batchNote}` : testNote
}
// 如果启用了分辨率组,使用新的备注格式:[分辨率] [批量当前测试/该分辨率批量总数]
if (resolutionGroupInfo && isResolutionGroupEnabled) {
const { currentBatchIndex, batchCount } = resolutionGroupInfo
const batchInfo = `[批量${currentBatchIndex}/${batchCount}]`
batchNote = batchNote ? `${batchNote} ${batchInfo}` : batchInfo
} else {
// 普通批量测试,使用原来的格式
batchNote = batchNote
? `${batchNote} [批量${batchTestProgress.current}/${batchTestProgress.total}]`
: `[批量${batchTestProgress.current}/${batchTestProgress.total}]`
}
fpsTest.addResult({
id: `${now.getTime()}-${Math.random().toString(36).slice(2, 11)}`,
testTime: parsed.timestamp,
testDate,
mapName: mapConfig?.name || "unknown",
mapLabel: mapConfig?.label || "未知地图",
avg,
p1,
rawResult: parsed.data,
videoSetting: currentVideoSetting,
hardwareInfo: hardwareInfo
? {
cpu: hardwareInfo.cpus[0]?.brand || null,
cpuCount: hardwareInfo.cpu_count || null,
os:
hardwareInfo.name && hardwareInfo.os_version
? `${hardwareInfo.name} ${hardwareInfo.os_version}`
: null,
memory: hardwareInfo.total_memory
? Math.round(hardwareInfo.total_memory / 1024 / 1024 / 1024)
: null,
gpu: null,
monitor: null,
}
: null,
note: batchNote,
})
} else {
// 单次测试,添加分辨率信息到备注
let singleNote = testNote
if (currentTestResolution) {
const resolutionNote = `[${currentTestResolution.label}]`
singleNote = singleNote ? `${testNote} ${resolutionNote}` : resolutionNote
}
fpsTest.addResult({
id: `${now.getTime()}-${Math.random().toString(36).slice(2, 11)}`,
testTime: parsed.timestamp,
testDate,
mapName: mapConfig?.name || "unknown",
mapLabel: mapConfig?.label || "未知地图",
avg,
p1,
rawResult: parsed.data,
videoSetting: currentVideoSetting,
hardwareInfo: hardwareInfo
? {
cpu: hardwareInfo.cpus[0]?.brand || null,
cpuCount: hardwareInfo.cpu_count || null,
os:
hardwareInfo.name && hardwareInfo.os_version
? `${hardwareInfo.name} ${hardwareInfo.os_version}`
: null,
memory: hardwareInfo.total_memory
? Math.round(hardwareInfo.total_memory / 1024 / 1024 / 1024)
: null,
gpu: null,
monitor: null,
}
: null,
note: singleNote, // 保存备注(包含分辨率信息)
})
}
// 调用回调函数
onResultRead({
timestamp: parsed.timestamp,
data: parsed.data,
avg,
p1,
})
if (!silent) {
if (avg !== null || p1 !== null) {
addToast({
title: `已读取并保存测试结果${
avg !== null ? ` (avg: ${avg.toFixed(1)} FPS)` : ""
}${p1 !== null ? ` (p1: ${p1.toFixed(1)} FPS)` : ""}`,
})
} else {
addToast({ title: "已读取并保存测试结果(未能提取帧数数据)" })
}
}
// 如果启用了自动关闭游戏,则关闭游戏
if (tool.state.autoCloseGame) {
setTimeout(() => {
void invoke("kill_game").catch(() => {})
}, 2000) // 延迟2秒关闭让用户看到结果
}
return true
} else if (!silent) {
addToast({
title: "未能解析测试结果",
variant: "flat",
})
}
} else if (!silent) {
addToast({
title: "未能读取测试结果,请确保测试已完成",
variant: "flat",
})
}
return false
} catch (error) {
if (!silent) {
console.error("读取结果失败:", error)
addToast({
title: `读取结果失败: ${error instanceof Error ? error.message : String(error)}`,
variant: "flat",
})
}
return false
}
}

View File

@@ -0,0 +1,172 @@
import { invoke } from "@tauri-apps/api/core"
import { addToast } from "@heroui/react"
import { BENCHMARK_MAPS, TEST_TIMEOUT } from "../constants"
import { formatCurrentTimestamp } from "../utils/timestamp"
import type { useSteamStore } from "@/store/steam"
import type { useToolStore } from "@/store/tool"
import type { Resolution } from "../types"
export interface RunSingleTestParams {
steam: ReturnType<typeof useSteamStore>
tool: ReturnType<typeof useToolStore>
selectedMapIndex: number
resolutionWidth: string
resolutionHeight: string
isResolutionEnabled: boolean
isResolutionGroupEnabled: boolean
isFullscreen: boolean
customLaunchOption: string
autoCloseGame: boolean
checkGameRunning: () => Promise<boolean>
resolution?: Resolution
testIndex?: number
totalTests?: number
onTestStart: (timestamp: string, startTime: number, resolution: Resolution | null) => void
onTestComplete: () => void
}
export interface RunSingleTestResult {
success: boolean
testStartTimestamp: string
testStartTime: number
resolution: Resolution
}
export async function runSingleTest(
params: RunSingleTestParams
): Promise<RunSingleTestResult | null> {
const {
steam,
tool,
selectedMapIndex,
resolutionWidth,
resolutionHeight,
isResolutionEnabled,
isResolutionGroupEnabled,
isFullscreen,
customLaunchOption,
autoCloseGame,
checkGameRunning,
resolution,
testIndex,
totalTests,
onTestStart,
} = params
// 验证路径是否存在且有效
if (!steam.state.steamDir || !steam.state.cs2Dir) {
addToast({
title: "Steam 或 CS2 路径未设置,请先配置路径",
color: "warning",
})
return null
}
// 验证 Steam 路径是否有效
if (!steam.state.steamDirValid) {
addToast({
title: "Steam 路径无效,请检查路径设置",
color: "warning",
})
return null
}
const mapConfig = BENCHMARK_MAPS[selectedMapIndex]
if (!mapConfig) {
return null
}
// 如果启用了自动关闭游戏,检测并关闭正在运行的游戏
if (autoCloseGame) {
const gameRunning = await checkGameRunning()
if (gameRunning) {
try {
await invoke("kill_game")
// 等待一下确保游戏关闭
await new Promise((resolve) => setTimeout(resolve, 2000))
} catch (error) {
console.error("关闭游戏失败:", error)
return null
}
}
}
// 记录测试开始时间戳格式MM/DD HH:mm:ss
const testStartTimestamp = formatCurrentTimestamp()
const testStartTime = Date.now()
try {
// 构建启动参数:基础参数 + 分辨率和全屏设置 + 自定义启动项(如果有)
let baseLaunchOption = `-allow_third_party_software -condebug -conclearlog +map_workshop ${mapConfig.workshopId} ${mapConfig.map}`
// 使用传入的分辨率如果没有则使用store中的分辨率
const currentResolution: Resolution = resolution || {
width: resolutionWidth,
height: resolutionHeight,
label: `${resolutionWidth}x${resolutionHeight}`,
}
// 添加分辨率设置(如果启用分辨率功能或分辨率组)
if (isResolutionEnabled || isResolutionGroupEnabled) {
// 添加分辨率设置(如果有设置)
if (currentResolution.width && currentResolution.height) {
baseLaunchOption += ` -w ${currentResolution.width} -h ${currentResolution.height}`
}
// 添加全屏/窗口化设置(独立控制,不依赖游戏设置)
if (isFullscreen) {
baseLaunchOption += ` -fullscreen`
} else {
baseLaunchOption += ` -sw`
}
}
// 添加自定义启动项(如果有,开头加空格避免粘连)
const launchOption = customLaunchOption.trim()
? `${baseLaunchOption} ${customLaunchOption.trim()}`
: baseLaunchOption
// 启动游戏强制使用worldwide国际服
try {
await invoke("launch_game", {
steamPath: `${steam.state.steamDir}\\steam.exe`,
launchOption: launchOption,
server: "worldwide",
})
} catch (error) {
console.error("启动游戏失败:", error)
addToast({
title: `启动游戏失败: ${error instanceof Error ? error.message : String(error)}`,
color: "danger",
})
return null
}
// 调用测试开始回调
onTestStart(testStartTimestamp, testStartTime, currentResolution)
const resolutionInfo = currentResolution ? ` (${currentResolution.label})` : ""
if (totalTests && totalTests > 1) {
addToast({
title: `批量测试 ${testIndex}/${totalTests}${resolutionInfo}:已启动 ${mapConfig.label} 测试,正在自动监听结果...`,
})
} else {
addToast({ title: `已启动 ${mapConfig.label} 测试${resolutionInfo},正在自动监听结果...` })
}
return {
success: true,
testStartTimestamp,
testStartTime,
resolution: currentResolution,
}
} catch (error) {
console.error("启动测试失败:", error)
addToast({
title: `启动测试失败: ${error instanceof Error ? error.message : String(error)}`,
variant: "flat",
})
return null
}
}

View File

@@ -0,0 +1,25 @@
// 类型定义文件
export type Resolution = {
width: string
height: string
label: string
}
export type BatchTestProgress = {
current: number
total: number
}
export type FpsMetrics = {
avg: number | null
p1: number | null
}
export type ResolutionGroupInfo = {
resIndex: number
totalResolutions: number
totalTestCount: number
currentBatchIndex: number
batchCount: number
}

View File

@@ -0,0 +1,175 @@
import { save } from "@tauri-apps/plugin-dialog"
import { writeTextFile } from "@tauri-apps/plugin-fs"
import { addToast } from "@heroui/react"
import type { VideoSetting } from "@/store/tool"
import type { FpsTestResult } from "@/store/fps_test"
// 格式化视频设置摘要
export function formatVideoSettingSummary(
videoSetting: VideoSetting | null
): string {
if (!videoSetting) return "N/A"
const resolution = `${videoSetting.defaultres}x${videoSetting.defaultresheight}`
const refreshRate =
videoSetting.refreshrate_denominator === "1"
? videoSetting.refreshrate_numerator
: `${videoSetting.refreshrate_numerator}/${videoSetting.refreshrate_denominator}`
const msaa = videoSetting.msaa_samples === "0" ? "无" : `${videoSetting.msaa_samples}x`
return `${resolution}@${refreshRate}Hz, MSAA:${msaa}`
}
// 导出所有测试结果CSV
export async function handleExportCSV(
results: FpsTestResult[]
) {
if (results.length === 0) {
addToast({ title: "没有测试数据可导出", color: "warning" })
return
}
try {
// 构建CSV内容
const headers = [
"测试时间",
"测试地图",
"平均帧",
"P1低帧",
"CPU",
"系统版本",
"GPU",
"内存(GB)",
"分辨率",
"视频设置",
"备注",
]
const csvRows = [headers.join(",")]
for (const result of results) {
const row = [
`"${result.testTime}"`,
`"${result.mapLabel}"`,
result.avg !== null ? result.avg.toFixed(1) : "N/A",
result.p1 !== null ? result.p1.toFixed(1) : "N/A",
`"${result.hardwareInfo?.cpu || "N/A"}"`,
`"${result.hardwareInfo?.os || "N/A"}"`,
`"${result.hardwareInfo?.gpu || "N/A"}"`,
result.hardwareInfo?.memory ? result.hardwareInfo.memory.toString() : "N/A",
result.videoSetting
? `${result.videoSetting.defaultres}x${result.videoSetting.defaultresheight}`
: "N/A",
`"${formatVideoSettingSummary(result.videoSetting)}"`,
`"${result.note || ""}"`,
]
csvRows.push(row.join(","))
}
const csvContent = csvRows.join("\n")
// 添加UTF-8 BOM以确保Excel等软件正确识别编码
const csvContentWithBOM = "\uFEFF" + csvContent
// 使用文件保存对话框
const filePath = await save({
filters: [
{
name: "CSV",
extensions: ["csv"],
},
],
defaultPath: `fps_test_results_${new Date().toISOString().split("T")[0]}.csv`,
})
if (filePath) {
await writeTextFile(filePath, csvContentWithBOM)
addToast({ title: "导出成功", color: "success" })
}
} catch (error) {
console.error("导出CSV失败:", error)
addToast({
title: `导出失败: ${error instanceof Error ? error.message : String(error)}`,
color: "danger",
})
}
}
// 仅导出平均结果CSV
export async function handleExportAverageCSV(
results: FpsTestResult[]
) {
// 过滤备注中包含"平均"的结果
const averageResults = results.filter(
(result) => result.note && result.note.includes("平均")
)
if (averageResults.length === 0) {
addToast({ title: "没有平均结果数据可导出", color: "warning" })
return
}
try {
// 构建CSV内容
const headers = [
"测试时间",
"测试地图",
"平均帧",
"P1低帧",
"CPU",
"系统版本",
"GPU",
"内存(GB)",
"分辨率",
"视频设置",
"备注",
]
const csvRows = [headers.join(",")]
for (const result of averageResults) {
const row = [
`"${result.testTime}"`,
`"${result.mapLabel}"`,
result.avg !== null ? result.avg.toFixed(1) : "N/A",
result.p1 !== null ? result.p1.toFixed(1) : "N/A",
`"${result.hardwareInfo?.cpu || "N/A"}"`,
`"${result.hardwareInfo?.os || "N/A"}"`,
`"${result.hardwareInfo?.gpu || "N/A"}"`,
result.hardwareInfo?.memory ? result.hardwareInfo.memory.toString() : "N/A",
result.videoSetting
? `${result.videoSetting.defaultres}x${result.videoSetting.defaultresheight}`
: "N/A",
`"${formatVideoSettingSummary(result.videoSetting)}"`,
`"${result.note || ""}"`,
]
csvRows.push(row.join(","))
}
const csvContent = csvRows.join("\n")
// 添加UTF-8 BOM以确保Excel等软件正确识别编码
const csvContentWithBOM = "\uFEFF" + csvContent
// 使用文件保存对话框
const filePath = await save({
filters: [
{
name: "CSV",
extensions: ["csv"],
},
],
defaultPath: `fps_test_average_results_${new Date().toISOString().split("T")[0]}.csv`,
})
if (filePath) {
await writeTextFile(filePath, csvContentWithBOM)
addToast({ title: "导出成功", color: "success" })
}
} catch (error) {
console.error("导出CSV失败:", error)
addToast({
title: `导出失败: ${error instanceof Error ? error.message : String(error)}`,
color: "danger",
})
}
}

View File

@@ -0,0 +1,43 @@
// 从 VProf 报告中提取 avg 和 p1 值
export function extractFpsMetrics(result: string): { avg: number | null; p1: number | null } {
let avg: number | null = null
let p1: number | null = null
// 查找包含 avg 的行,支持多种格式:
// - "[VProf] FPS: Avg=239.5, P1=228.0" (等号格式)
// - "[VProf] avg: 123.45" (冒号格式)
// - "[VProf] avg 123.45" (空格格式)
const avgMatch = result.match(/avg[=:\s]+(\d+\.?\d*)/i)
if (avgMatch) {
avg = parseFloat(avgMatch[1])
}
// 查找包含 p1 的行,支持多种格式:
// - "P1=228.0" (等号格式)
// - "p1: 98.76" (冒号格式)
// - "p1 98.76" (空格格式)
const p1Match = result.match(/p1[=:\s]+(\d+\.?\d*)/i)
if (p1Match) {
p1 = parseFloat(p1Match[1])
}
// 如果找不到,尝试查找其他可能的格式
// 例如:查找包含 "fps" 和数字的行
if (!avg) {
const fpsMatch = result.match(/fps[=:\s]+(\d+\.?\d*)/i)
if (fpsMatch) {
avg = parseFloat(fpsMatch[1])
}
}
// 尝试查找 1% low 或类似的格式
if (!p1) {
const lowMatch = result.match(/(?:1%|1st|first).*?low[=:\s]+(\d+\.?\d*)/i)
if (lowMatch) {
p1 = parseFloat(lowMatch[1])
}
}
return { avg, p1 }
}

View File

@@ -0,0 +1,66 @@
// 比较时间戳格式MM/DD HH:mm:ss
// 返回 true 如果 timestamp1 晚于 timestamp2
export function compareTimestamps(timestamp1: string, timestamp2: string): boolean {
// 解析时间戳MM/DD HH:mm:ss
const parseTimestamp = (ts: string) => {
const match = ts.match(/(\d{2})\/(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/)
if (!match) return null
const [, month, day, hour, minute, second] = match.map(Number)
return { month, day, hour, minute, second }
}
const ts1 = parseTimestamp(timestamp1)
const ts2 = parseTimestamp(timestamp2)
if (!ts1 || !ts2) return false
// 使用当前年份作为基准
const now = new Date()
const currentYear = now.getFullYear()
// 创建日期对象,尝试当前年份
let date1 = new Date(currentYear, ts1.month - 1, ts1.day, ts1.hour, ts1.minute, ts1.second)
let date2 = new Date(currentYear, ts2.month - 1, ts2.day, ts2.hour, ts2.minute, ts2.second)
// 如果 date1 早于 date2可能是跨年了比如 date1 是 1月date2 是 12月
// 在这种情况下,给 date1 加一年
if (date1 < date2) {
// 检查是否可能是跨年(月份相差很大)
const monthDiff = (ts1.month - ts2.month + 12) % 12
if (monthDiff > 6) {
// 可能是跨年,给 date1 加一年
date1 = new Date(currentYear + 1, ts1.month - 1, ts1.day, ts1.hour, ts1.minute, ts1.second)
}
}
return date1 > date2
}
// 格式化当前时间为时间戳格式MM/DD HH:mm:ss
export function formatCurrentTimestamp(): string {
const now = new Date()
const month = String(now.getMonth() + 1).padStart(2, "0")
const day = String(now.getDate()).padStart(2, "0")
const hour = String(now.getHours()).padStart(2, "0")
const minute = String(now.getMinutes()).padStart(2, "0")
const second = String(now.getSeconds()).padStart(2, "0")
return `${month}/${day} ${hour}:${minute}:${second}`
}
// 将时间戳转换为ISO格式
export function timestampToISO(timestamp: string): string {
const now = new Date()
const [monthDay, time] = timestamp.split(" ")
const [month, day] = monthDay.split("/")
const [hour, minute, second] = time.split(":")
const testDateTime = new Date(
now.getFullYear(),
parseInt(month) - 1,
parseInt(day),
parseInt(hour),
parseInt(minute),
parseInt(second)
)
return testDateTime.toISOString()
}

View File

@@ -0,0 +1,47 @@
// 解析性能报告,提取时间戳和性能数据
export function parseVProfReport(rawReport: string): { timestamp: string; data: string } | null {
if (!rawReport) return null
const lines = rawReport.split("\n")
let timestamp = ""
let inPerformanceSection = false
const performanceLines: string[] = []
for (const line of lines) {
// 提取时间戳:格式如 "11/05 01:51:27 [VProf] -- Performance report --"
const timestampMatch = line.match(
/(\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[VProf\]\s+--\s+Performance\s+report\s+--/
)
if (timestampMatch) {
timestamp = timestampMatch[1]
inPerformanceSection = true
// 也包含 Performance report 这一行,但移除时间戳
const lineWithoutTimestamp = line.trim().replace(/^\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\s+/, "")
performanceLines.push(lineWithoutTimestamp)
continue
}
// 如果在性能报告部分
if (inPerformanceSection) {
const trimmedLine = line.trim()
// 只收集包含 [VProf] 的行
if (trimmedLine.includes("[VProf]")) {
// 移除行首的时间戳格式MM/DD HH:mm:ss
// 例如:"11/05 02:13:56 [VProf] ..." -> "[VProf] ..."
const lineWithoutTimestamp = trimmedLine.replace(/^\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\s+/, "")
performanceLines.push(lineWithoutTimestamp)
}
// 如果遇到空行且已经有数据,可能是报告结束,但不直接结束,因为可能还有更多数据
// 如果后续没有 [VProf] 行的数据,空行会被过滤掉
}
}
if (performanceLines.length === 0) return null
return {
timestamp,
data: performanceLines.join("\n").trim(),
}
}

286
todo/refactor-plan.md Normal file
View File

@@ -0,0 +1,286 @@
# 代码重构规划文档
## 统计结果
### 超过300行的文件列表
| 文件路径 | 行数 | 优先级 | 状态 |
|---------|------|--------|------|
| `src/components/cstb/FpsTest.tsx` | 1949 | 🔴 高 | 待重构 |
| `src/components/cstb/VideoSetting.tsx` | 579 | 🟡 中 | 待重构 |
| `src/components/cstb/SteamUsers.tsx` | 432 | 🟡 中 | 待重构 |
## 重构目标
1. **组件封装**:将大组件拆分为更小的、职责单一的组件
2. **功能复用**:提取可复用的逻辑和工具函数
3. **文件拆分**:将大文件拆分为多个小文件,提高可维护性
4. **代码组织**:按功能模块组织代码结构
---
## 1. FpsTest.tsx (1949行) - 高优先级
### 问题分析
- 文件过大,难以维护和调试
- 包含多个职责测试执行、结果读取、UI渲染、CSV导出等
- 大量状态管理和副作用逻辑混在一起
- 工具函数、常量、组件定义都在同一文件
### 重构方案
#### 1.1 文件结构拆分
```
src/components/cstb/FpsTest/
├── index.tsx # 主组件精简后约200行
├── types.ts # 类型定义
├── constants.ts # 常量定义BENCHMARK_MAPS, PRESET_RESOLUTIONS等
├── utils/
│ ├── vprof-parser.ts # VProf报告解析工具
│ ├── timestamp.ts # 时间戳处理工具
│ ├── fps-metrics.ts # FPS指标提取工具
│ └── csv-export.ts # CSV导出工具
├── hooks/
│ ├── useGameMonitor.ts # 游戏运行状态监控
│ ├── useTestMonitor.ts # 测试结果监控
│ ├── useBatchTest.ts # 批量测试逻辑
│ └── useHardwareInfo.ts # 硬件信息获取
├── components/
│ ├── TestConfigPanel.tsx # 测试配置面板
│ ├── ResolutionConfig.tsx # 分辨率配置组件
│ ├── TestResultsTable.tsx # 测试结果表格
│ ├── TestResultDisplay.tsx # 测试结果展示
│ ├── BatchTestProgress.tsx # 批量测试进度
│ └── NoteCell.tsx # 备注单元格
└── services/
├── testRunner.ts # 测试执行服务
└── resultReader.ts # 结果读取服务
```
#### 1.2 功能模块拆分
**工具函数模块 (utils/)**
- `vprof-parser.ts`: `parseVProfReport`, `extractFpsMetrics`
- `timestamp.ts`: `compareTimestamps`, 时间戳格式化
- `fps-metrics.ts`: FPS相关计算和格式化
- `csv-export.ts`: `handleExportCSV`, `handleExportAverageCSV`
**自定义Hooks (hooks/)**
- `useGameMonitor.ts`: 游戏运行状态检测和监控
- `useTestMonitor.ts`: 测试结果文件监控逻辑
- `useBatchTest.ts`: 批量测试状态管理和执行逻辑
- `useHardwareInfo.ts`: 硬件信息获取和管理
**组件拆分 (components/)**
- `TestConfigPanel.tsx`: 测试地图、批量测试次数、备注配置
- `ResolutionConfig.tsx`: 分辨率设置、全屏/窗口化、分辨率组管理
- `TestResultsTable.tsx`: 测试结果表格展示(包含删除、编辑备注)
- `TestResultDisplay.tsx`: 当前测试结果显示时间戳、avg、p1
- `BatchTestProgress.tsx`: 批量测试进度显示组件
- `NoteCell.tsx`: 备注单元格组件
**服务层 (services/)**
- `testRunner.ts`: `runSingleTest`, `startTest` 等测试执行逻辑
- `resultReader.ts`: `readResult` 结果读取逻辑
#### 1.3 预期效果
- 主组件文件从1949行减少到约200行
- 每个子文件控制在100-300行以内
- 提高代码可维护性和可测试性
- 便于功能扩展和bug修复
---
## 2. VideoSetting.tsx (579行) - 中优先级
### 问题分析
- 视频设置配置数组过长约200行
- 编辑/只读模式切换逻辑复杂
- 文件监听和自动刷新逻辑可以提取
### 重构方案
#### 2.1 文件结构拆分
```
src/components/cstb/VideoSetting/
├── index.tsx # 主组件精简后约200行
├── types.ts # 类型定义
├── config/
│ └── videoSettingsConfig.ts # 视频设置配置数组
├── hooks/
│ ├── useVideoConfig.ts # 视频配置读取/写入
│ ├── useFileWatcher.ts # 文件监听逻辑
│ └── useGameRunning.ts # 游戏运行状态检测
└── components/
├── VideoSettingsEditor.tsx # 编辑模式组件
└── VideoSettingsViewer.tsx # 只读模式组件
```
#### 2.2 功能模块拆分
**配置模块 (config/)**
- `videoSettingsConfig.ts`: 提取 `videoSettings` 函数和配置数组
**自定义Hooks (hooks/)**
- `useVideoConfig.ts`: 视频配置的读取、写入、刷新逻辑
- `useFileWatcher.ts`: 文件变动监听和自动刷新
- `useGameRunning.ts`: 游戏运行状态检测(可复用)
**组件拆分 (components/)**
- `VideoSettingsEditor.tsx`: 编辑模式下的所有控件
- `VideoSettingsViewer.tsx`: 只读模式下的展示
#### 2.3 预期效果
- 主组件文件从579行减少到约200行
- 配置数据独立管理,便于维护
- 编辑/查看逻辑分离,代码更清晰
---
## 3. SteamUsers.tsx (432行) - 中优先级
### 问题分析
- 三种视图模式的渲染逻辑重复
- 用户项渲染函数较长
- 可以提取为独立组件
### 重构方案
#### 3.1 文件结构拆分
```
src/components/cstb/SteamUsers/
├── index.tsx # 主组件精简后约150行
├── types.ts # 类型定义
├── components/
│ ├── UserCard.tsx # 卡片视图用户项
│ ├── UserListItem.tsx # 列表视图用户项
│ └── UserListLargeItem.tsx # 大列表视图用户项
└── hooks/
└── useSteamUsers.ts # 用户数据获取和管理
```
#### 3.2 功能模块拆分
**组件拆分 (components/)**
- `UserCard.tsx`: 卡片样式用户项组件
- `UserListItem.tsx`: 列表样式用户项组件
- `UserListLargeItem.tsx`: 大列表样式用户项组件
**自定义Hooks (hooks/)**
- `useSteamUsers.ts`: 用户数据获取、刷新、模拟数据逻辑
#### 3.3 预期效果
- 主组件文件从432行减少到约150行
- 每种视图模式独立组件,便于维护
- 减少代码重复
---
## 重构执行计划
### 阶段1: FpsTest.tsx 重构(最高优先级)
1. ✅ 创建目录结构和基础文件
2. ✅ 提取工具函数到 `utils/` 目录
-`vprof-parser.ts` - VProf报告解析
-`timestamp.ts` - 时间戳处理
-`fps-metrics.ts` - FPS指标提取
-`csv-export.ts` - CSV导出功能
3. ✅ 提取自定义Hooks到 `hooks/` 目录
-`useGameMonitor.ts` - 游戏运行状态监控
-`useHardwareInfo.ts` - 硬件信息获取
-`useTestMonitor.ts` - 测试结果监控(待完成)
-`useBatchTest.ts` - 批量测试逻辑(待完成)
4. ✅ 提取部分UI组件到 `components/` 目录
-`NoteCell.tsx` - 备注单元格组件
-`TestConfigPanel.tsx` - 测试配置面板(待完成)
-`ResolutionConfig.tsx` - 分辨率配置(待完成)
-`TestResultsTable.tsx` - 测试结果表格(待完成)
-`TestResultDisplay.tsx` - 测试结果展示(待完成)
-`BatchTestProgress.tsx` - 批量测试进度(待完成)
5. ⏳ 提取服务层逻辑到 `services/` 目录
-`testRunner.ts` - 测试执行服务(待完成)
-`resultReader.ts` - 结果读取服务(待完成)
6. ⏳ 重构主组件,整合所有子模块(进行中)
- ✅ 创建类型定义文件
- ⏳ 完整重构主组件(需要继续)
7. ⏳ 测试验证功能完整性(待完成)
### 阶段2: VideoSetting.tsx 重构
1. ⏳ 提取配置数据
2. ⏳ 提取自定义Hooks
3. ⏳ 拆分编辑/查看组件
4. ⏳ 重构主组件
### 阶段3: SteamUsers.tsx 重构
1. ⏳ 提取用户项组件
2. ⏳ 提取数据管理Hook
3. ⏳ 重构主组件
---
## 重构原则
1. **保持功能完整性**:重构过程中确保所有功能正常工作
2. **渐进式重构**:分步骤进行,每步完成后验证
3. **向后兼容**:不改变对外接口,保持组件使用方式不变
4. **代码复用**:提取公共逻辑,避免重复代码
5. **类型安全**保持TypeScript类型完整性
---
## 已完成的工作
### FpsTest.tsx 重构进度
#### ✅ 已完成
1. **目录结构创建**
-`src/components/cstb/FpsTest/` 目录结构已创建
2. **常量提取**
-`constants.ts` - BENCHMARK_MAPS, PRESET_RESOLUTIONS, TEST_TIMEOUT
3. **工具函数提取**
-`utils/vprof-parser.ts` - parseVProfReport函数
-`utils/timestamp.ts` - compareTimestamps, formatCurrentTimestamp, timestampToISO
-`utils/fps-metrics.ts` - extractFpsMetrics函数
-`utils/csv-export.ts` - handleExportCSV, handleExportAverageCSV, formatVideoSettingSummary
4. **自定义Hooks提取**
-`hooks/useGameMonitor.ts` - 游戏运行状态监控
-`hooks/useHardwareInfo.ts` - 硬件信息获取
5. **组件提取**
-`components/NoteCell.tsx` - 备注单元格组件
6. **类型定义**
-`types.ts` - Resolution, BatchTestProgress, FpsMetrics, ResolutionGroupInfo
#### ⏳ 待完成
1. **更多Hooks**
- `hooks/useTestMonitor.ts` - 测试结果文件监控逻辑
- `hooks/useBatchTest.ts` - 批量测试状态管理和执行逻辑
2. **更多组件**
- `components/TestConfigPanel.tsx` - 测试地图、批量测试次数、备注配置
- `components/ResolutionConfig.tsx` - 分辨率设置组件
- `components/TestResultsTable.tsx` - 测试结果表格
- `components/TestResultDisplay.tsx` - 当前测试结果显示
- `components/BatchTestProgress.tsx` - 批量测试进度显示
3. **服务层**
- `services/testRunner.ts` - 测试执行服务runSingleTest, startTest
- `services/resultReader.ts` - 结果读取服务readResult
4. **主组件重构**
- 使用提取的模块重构 `index.tsx`
- 确保所有功能正常工作
## 注意事项
1. 重构前确保有完整的测试覆盖(如果有)
2. 重构过程中注意保持状态管理的一致性
3. 提取Hook时注意依赖项的正确传递
4. 组件拆分时注意props的类型定义
5. 工具函数提取时注意副作用处理
6. **重要**: 原文件 `FpsTest.tsx` 暂时保留,待重构完成并测试通过后再删除
7. **当前状态**: 已提取基础模块,主组件重构需要继续完成