[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

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"
@@ -43,12 +37,14 @@ export default function Page() {
function HardwareInfo() {
const { mutate } = useSWRConfig()
return (
<ToolButton onClick={() => {
// 使用 SWR 的 mutate 来刷新数据
mutate("/api/hardware-info")
}}>
<ToolButton
onClick={() => {
// 使用 SWR 的 mutate 来刷新数据
mutate("/api/hardware-info")
}}
>
<Refresh />
</ToolButton>
)
@@ -60,27 +56,70 @@ 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)
console.log("第一个CPU:", sys.cpus[0])
@@ -88,26 +127,28 @@ const hardwareInfoFetcher = async (): Promise<HardwareData> => {
console.log("CPU字段:", Object.keys(sys.cpus[0]))
}
}
return {
allSysData: sys,
computerInfo: computerInfoData
computerInfo: computerInfoData,
gpuInfo: gpuInfoData,
memoryInfo: memoryInfoData,
monitorInfo: monitorInfoData,
}
}
function HardwareInfoContent() {
const { data, isLoading } = useSWR<HardwareData>(
"/api/hardware-info",
hardwareInfoFetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 5 * 60 * 1000, // 5分钟内相同请求去重
}
)
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,56 +160,72 @@ 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
? Math.round((allSysData.used_memory / allSysData.total_memory) * 100)
: null
// 格式化系统信息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 frequencies = allSysData.cpus
.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
})
if (frequencies.length === 0) {
console.log("未找到有效的CPU频率数据CPU对象:", allSysData.cpus[0])
return null
}
const sum = frequencies.reduce((acc, freq) => acc + freq, 0)
const avg = sum / frequencies.length
// 判断单位并统一转换为GHz
// 如果值在2-10范围可能是GHz
// 如果值在2000-10000范围可能是MHz需要除以1000
// 如果值在百万级别(2000000+可能是Hz需要除以1,000,000
let freqInGhz: number
if (avg >= 1_000_000) {
// Hz单位转换为GHz
freqInGhz = avg / 1_000_000
} else if (avg >= 1000) {
// MHz单位转换为GHz
freqInGhz = avg / 1000
} else {
// 已经是GHz单位
freqInGhz = avg
}
console.log("CPU频率数据:", frequencies, "原始平均值:", avg, "转换为GHz:", freqInGhz)
return freqInGhz
})()
: null
const averageCpuFrequency =
allSysData?.cpus && allSysData.cpus.length > 0
? (() => {
// 尝试多个可能的频率字段名
const frequencies = allSysData.cpus
.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
})
if (frequencies.length === 0) {
console.log("未找到有效的CPU频率数据CPU对象:", allSysData.cpus[0])
return null
}
const sum = frequencies.reduce((acc, freq) => acc + freq, 0)
const avg = sum / frequencies.length
// 判断单位并统一转换为GHz
// 如果值在2-10范围可能是GHz
// 如果值在2000-10000范围,可能是MHz需要除以1000
// 如果值在百万级别2000000+可能是Hz需要除以1,000,000
let freqInGhz: number
if (avg >= 1_000_000) {
// Hz单位转换为GHz
freqInGhz = avg / 1_000_000
} else if (avg >= 1000) {
// MHz单位转换为GHz
freqInGhz = avg / 1000
} else {
// 已经是GHz单位
freqInGhz = avg
}
console.log("CPU频率数据:", frequencies, "原始平均值:", avg, "转换为GHz:", freqInGhz)
return freqInGhz
})()
: null
// 如果正在加载,显示 Skeleton 骨架屏
if (isLoading) {
@@ -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>
)}
</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>
)}
<Chip>
<span className="font-medium"></span> {formatSystemInfo()}
</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,25 +357,49 @@ 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
return (
<div className="flex flex-col gap-2">
<div className="text-sm font-medium text-foreground-600"></div>
<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(),
}
}