69 Commits

Author SHA1 Message Date
purp1e
695d246a77 update dependencies and default launch options 2025-11-21 12:30:06 +08:00
purp1e
42f49d784e [feat] adapt to coop fullscreen 2025-11-09 23:33:31 +08:00
purp1e
843fc03ddc [feat] preset resolution for video setting edit 2025-11-09 21:34:55 +08:00
purp1e
f7efcb7fc9 v0.0.6 release 2025-11-09 15:30:45 +08:00
purp1e
388c74831e misc update version and data file 2025-11-09 00:56:34 +08:00
purp1e
0f938f6f3e [fix] update data lost between page and stutter caused by periodly checking game stats in sync mode 2025-11-09 00:55:19 +08:00
purp1e
812bc64b6f [feat] fullfil tasks to minify manual actions 2025-11-09 00:30:26 +08:00
purp1e
4b7735575a [fix] pre and cdn swith 2025-11-09 00:07:44 +08:00
purp1e
5e663dc79e [fix] update setup 2025-11-08 23:57:26 +08:00
purp1e
cd19faba47 try to fix update starting but failed 2025-11-08 21:04:31 +08:00
purp1e
4c151c3dd5 [fix] update restart process 2025-11-08 20:56:02 +08:00
purp1e
11afc6dc9e [fix] powershell black screens and try to solve update re-start not working 2025-11-08 19:14:18 +08:00
purp1e
c50fedd305 disable all dev logs 2025-11-08 18:21:35 +08:00
purp1e
6103164e37 disable dev log 2025-11-08 18:16:24 +08:00
purp1e
c8d8339f30 [feat] more hw info and update feature 2025-11-08 18:09:35 +08:00
purp1e
41105d3bab [feat] more mborad and memory info 2025-11-08 16:34:37 +08:00
purp1e
e824455577 [feat] more hw info including gpu + refactor fpstest 2025-11-08 15:43:44 +08:00
purp1e
e146fbe393 [feat] hw info cache 2025-11-08 13:28:41 +08:00
purp1e
e7e0bbd953 [feat] better hw info (still no gpu) 2025-11-08 13:24:00 +08:00
purp1e
7e097cc9cc disable auto get path to avoid crash issue 2025-11-07 20:34:31 +08:00
purp1e
8bef96bb02 [fix] table width + text selectiion + batch note naming 2025-11-07 00:18:46 +08:00
purp1e
f7c9e455f7 optim fpstest ui and try to fix steam path related crash 2025-11-06 23:11:41 +08:00
purp1e
9f29857fd3 [feat] multi-resolution and batch testing 2025-11-06 21:52:49 +08:00
purp1e
dcac1295c6 [feat] batch testing and average 2025-11-06 19:26:47 +08:00
purp1e
b7f0d0b0cc [fix] csv export codec 2025-11-06 16:21:24 +08:00
purp1e
ae567eece7 [fix] permission issue of export csv and dialog save 2025-11-06 15:45:56 +08:00
purp1e
cce56eaf5e [feat] i18n for nsis installer 2025-11-06 14:55:43 +08:00
purp1e
e774b31396 [feat] more fps test settings and csv export button 2025-11-06 14:26:17 +08:00
purp1e
72eef189da [feat] user lists fit for many users and 3 display mode 2025-11-06 03:55:37 +08:00
purp1e
8eeb7347a2 [fix] home page button overflow 2025-11-06 03:14:41 +08:00
purp1e
4c0c33382f better fps testing ui + more info + comment + users minor update 2025-11-06 03:08:20 +08:00
purp1e
8550887bfb fix store dir + feat launch option switch in tray 2025-11-06 03:07:38 +08:00
purp1e
ce410f7a26 [feat] fps test results gathering 2025-11-05 13:20:50 +08:00
purp1e
c19adcc3f8 [feat] listen to steam user changes 2025-11-05 11:32:43 +08:00
purp1e
6710fe1754 [try] auth 2025-11-05 11:21:13 +08:00
purp1e
41008cf13c [feat] better installer + changelog test version switch + better video config view 2025-11-05 11:19:43 +08:00
purp1e
ea0a42dc43 [tag] 0.0.6-beta.4 build preparation 2025-11-05 02:35:35 +08:00
Purp1e
543c3344d1 [feat] fps benchmark one-key testing 2025-11-05 02:24:17 +08:00
Purp1e
d6ce9bd5f3 [misc] update dep 2025-11-05 01:21:54 +08:00
Purp1e
ae67e90745 [dep] upgrade tailwindcss v4 2025-11-05 00:22:03 +08:00
Purp1e
67b6c74907 [dep] adapt tauri dep 2025-11-04 23:33:36 +08:00
purp1e
a5f5a2a5ff [dep] update dep 2025-10-28 00:45:54 +08:00
purp1e
84d0cd7473 [feat] launch game code optim + open path after mkdir 2025-08-06 17:59:57 +08:00
purp1e
0c306b658e [dep] update js dep 2025-08-06 17:59:27 +08:00
Purp1e
44fcd81643 [feat] add highlight to selected launch option 2025-07-11 00:30:44 +08:00
purp1e
35ecf4ce0a [feat] launch option edit and remove 2025-07-08 11:31:43 +08:00
purp1e
7012f0d9fe [fix] user card overflow 2025-07-08 11:31:32 +08:00
purp1e
529ae547e0 Merge branch 'master' of https://g.upup.cool/cstb/cstb-next 2025-04-07 16:08:40 +08:00
purp1e
d814b0a3fa [update] 升级 Next.js 版本至 15.2.4 2025-04-07 16:07:28 +08:00
Purp1e
3dc271866f [fix] no react to current user changes 2025-04-02 23:05:07 +08:00
Purp1e
c5330aec8f [fix] steam users card overflow 2025-04-02 22:26:37 +08:00
Purp1e
c67c358354 [feat] enable replay analysis workflow but json file is not parsed 2025-03-29 01:12:03 +08:00
Purp1e
1d23c29ba8 [fix] fast launch from tray not working 2025-03-28 13:22:26 +08:00
Purp1e
dbedf25f0c [style] glassic styles of global and related cards 2025-03-28 01:22:42 +08:00
Purp1e
f9ff43f698 [fix] single instance can't trigger hidden program 2025-03-28 01:05:03 +08:00
Purp1e
a66100bfaf [fix] lint errors + new version tag 2025-03-27 21:17:19 +08:00
Purp1e
5b7f763221 Merge branch 'master' of https://g.upup.cool/cstb/cstb-next 2025-03-27 21:14:36 +08:00
Purp1e
4086fa88c2 [feat] dynamic page with latest stable release notes 2025-03-27 21:13:59 +08:00
purp1e
b6dbbf94ec Merge pull request 'dev-video' (#3) from dev-video into master
Reviewed-on: #3
2025-03-27 18:30:18 +08:00
Purp1e
bbcaf8e0d1 [fix] missing light(shader) setting + tray emit triggers multiple times 2025-03-27 18:19:53 +08:00
Purp1e
faec03afb1 [fix] some vconfig not applied -> stable release 2025-03-27 18:02:51 +08:00
Purp1e
7ec20984c4 v0.0.5 2025-03-27 17:40:02 +08:00
Purp1e
dabbab9f3e [fix] lint errors + new version 2025-03-27 17:39:22 +08:00
Purp1e
0e7e6dd3ba [feat] enable tab switch in edit mode 2025-03-27 17:36:04 +08:00
Purp1e
a10cf8eddf [feat] template works but cannot edit value by clicking 2025-03-27 16:11:42 +08:00
purp1e
63172f12bc [feat] set video and template 2025-03-27 15:48:09 +08:00
purp1e
114def0b96 [feat] set video config 2025-03-27 15:29:12 +08:00
Purp1e
93cda8dc85 [feat] read video config is ok 2025-03-27 13:32:30 +08:00
purp1e
afa7355f4d testing video parse 2025-03-27 11:30:03 +08:00
100 changed files with 29321 additions and 15843 deletions

9
.gitignore vendored
View File

@@ -38,4 +38,11 @@ yarn-error.log*
*.tsbuildinfo
.env
.env.*
.env.*
temp/
src-tauri/temp/
log/
.tauri/
todo/
next-env.d.ts

View File

@@ -1 +1 @@
22
25

119
.vscode/tasks.json vendored
View File

@@ -4,28 +4,117 @@
"version": "2.0.0",
"tasks": [
{
"label": "Dev Tauri",
"label": "🚀 Dev Tauri",
"type": "shell",
"command": "bun tauri dev",
"problemMatcher": [
"$vite"
],
"group": {
"kind": "build",
"isDefault": false
},
"presentation": {
"reveal": "always",
"panel": "new"
}
},
{
"label": "Build Tauri to nsis installer",
"label": "📦 Build: Release (NSIS)",
"type": "shell",
"command": "bun tauri build -b nsis",
"problemMatcher": [
"$vite"
],
"command": "& .\\.tauri\\set-env.ps1; bun tauri build -b nsis",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "new"
}
},
{
"label": "Build Tauri",
"label": "📦 Build: Release (All)",
"type": "shell",
"command": "bun tauri build",
"problemMatcher": [
"$vite"
],
"command": "& .\\.tauri\\set-env.ps1; bun tauri build",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "new"
}
},
{
"label": "⚡ Build: Fast (Dev Profile)",
"type": "shell",
"command": "& .\\.tauri\\set-env.ps1; bun tauri build -b nsis -- --profile dev",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "new"
}
},
{
"label": "⚡ Build: Fast Prod (Fast-Release Profile)",
"type": "shell",
"command": "& .\\.tauri\\set-env.ps1; bun tauri build -b nsis -- --profile fast-release",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "new"
}
},
{
"label": "📦 Build: Release + Rename + Gen Latest",
"type": "shell",
"command": "& .\\.tauri\\set-env.ps1; bun tauri build -b nsis; if ($LASTEXITCODE -eq 0) { node scripts/generate-latest-json.js --base-url https://github.com/plsgo/cstb/releases --rename }",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "new"
}
},
{
"label": "⚡ Build: Fast + Rename + Gen Latest",
"type": "shell",
"command": "& .\\.tauri\\set-env.ps1; bun tauri build -b nsis -- --profile dev; if ($LASTEXITCODE -eq 0) { node scripts/generate-latest-json.js --base-url https://github.com/plsgo/cstb/releases --target debug --rename }",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "new"
}
},
{
"label": "⚡ Build: Fast Prod + Rename + Gen Latest",
"type": "shell",
"command": "& .\\.tauri\\set-env.ps1; bun tauri build -b nsis -- --profile fast-release; if ($LASTEXITCODE -eq 0) { node scripts/generate-latest-json.js --base-url https://github.com/plsgo/cstb/releases --target fast-release --rename }",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "new"
}
},
{
"label": "🔄 Rename Build Artifacts",
"type": "shell",
"command": "node scripts/rename-build-artifacts.js --target release",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "new"
}
},
{
"label": "📄 Generate Latest JSON",
"type": "shell",
"command": "node scripts/generate-latest-json.js --base-url https://github.com/plsgo/cstb/releases",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "new"
}
},
{
"label": "📄 Generate Latest JSON (with Rename)",
"type": "shell",
"command": "node scripts/generate-latest-json.js --base-url https://github.com/plsgo/cstb/releases --rename",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "new"
}
}
]
}
}

248
UPDATE_API.md Normal file
View File

@@ -0,0 +1,248 @@
# 更新 API 文档
本文档说明 CS工具箱 的更新检查接口格式和配置方法。
## 功能概述
CS工具箱 支持两种更新检查方式:
1. **自定义更新服务器**(优先)
2. **GitHub Release**(作为备用方案)
## 配置方法
### 方式一:环境变量(推荐)
在项目根目录创建 `.env.local` 文件:
```env
NEXT_PUBLIC_UPDATE_ENDPOINT=https://your-server.com/api/update/check
NEXT_PUBLIC_GITHUB_REPO=your-username/your-repo
```
### 方式二:代码中配置
`src/app/(main)/preference/general/page.tsx` 中修改:
```typescript
const customEndpoint = "https://your-server.com/api/update/check"
const githubRepo = "your-username/your-repo"
```
## 自定义更新服务器接口格式
### 请求
- **方法**: GET
- **URL**: 你配置的 `customEndpoint`
- **Headers**: 无特殊要求
### 响应格式
服务器应返回 JSON 格式的更新信息:
```json
{
"version": "0.0.7",
"notes": "## 更新内容\n\n- 修复了已知问题\n- 添加了新功能",
"pub_date": "2025-01-15T10:00:00Z",
"download_url": "https://your-server.com/releases/v0.0.7/cstb-0.0.7-x86_64.exe",
"signature": "可选:安装包签名",
"platforms": {
"windows-x86_64": {
"url": "https://your-server.com/releases/v0.0.7/cstb-0.0.7-windows-x86_64.exe",
"signature": "可选Windows 安装包签名"
},
"darwin-x86_64": {
"url": "https://your-server.com/releases/v0.0.7/cstb-0.0.7-darwin-x86_64.dmg",
"signature": "可选macOS x86_64 安装包签名"
},
"darwin-aarch64": {
"url": "https://your-server.com/releases/v0.0.7/cstb-0.0.7-darwin-aarch64.dmg",
"signature": "可选macOS Apple Silicon 安装包签名"
},
"linux-x86_64": {
"url": "https://your-server.com/releases/v0.0.7/cstb-0.0.7-linux-x86_64.AppImage",
"signature": "可选Linux 安装包签名"
}
}
}
```
### 字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `version` | string | 是 | 新版本号,格式:`主版本.次版本.修订版本`(如 `0.0.7` |
| `notes` | string | 否 | 更新说明,支持 Markdown 格式 |
| `pub_date` | string | 否 | 发布时间ISO 8601 格式(如 `2025-01-15T10:00:00Z` |
| `download_url` | string | 是 | 默认下载链接(如果未指定平台特定链接时使用) |
| `signature` | string | 否 | 默认安装包签名 |
| `platforms` | object | 否 | 平台特定的下载信息 |
### 平台特定配置
如果提供了 `platforms` 对象,系统会优先使用当前平台的特定链接。支持的平台标识:
- `windows-x86_64`: Windows 64位
- `darwin-x86_64`: macOS Intel
- `darwin-aarch64`: macOS Apple Silicon
- `linux-x86_64`: Linux 64位
### 简化格式(仅 Windows
如果你只需要支持 Windows可以简化响应
```json
{
"version": "0.0.7",
"notes": "更新说明",
"download_url": "https://your-server.com/releases/v0.0.7/cstb-0.0.7.exe"
}
```
## GitHub Release 格式
### 仓库要求
1. 在 GitHub 上创建 Release
2. Release 标签格式:`v0.0.7``0.0.7`(系统会自动移除 `v` 前缀)
3. Release 标题和说明会作为更新说明显示
### 资源文件命名
系统会自动识别以下格式的安装包:
**Windows:**
- `.exe`
- `.msi`
- 文件名包含 `windows``x86_64`
**macOS:**
- `.dmg`
- 文件名包含 `darwin` 和架构标识(`x86_64``aarch64`
**Linux:**
- `.deb`
- `.rpm`
- `.AppImage`
- 文件名包含 `linux``x86_64`
### 推荐命名格式
```
cstb-{version}-{platform}-{arch}.{ext}
```
例如:
- `cstb-0.0.7-windows-x86_64.exe`
- `cstb-0.0.7-darwin-x86_64.dmg`
- `cstb-0.0.7-darwin-aarch64.dmg`
- `cstb-0.0.7-linux-x86_64.AppImage`
## 版本比较逻辑
系统使用简单的版本号比较:
-`.``-``_` 分割版本号
- 逐段比较数字部分
- 如果新版本号大于当前版本,则提示更新
例如:
- `0.0.7` > `0.0.6`
- `0.0.6-beta.4``0.0.6` 视为相等(简单比较)
## 更新流程
1. **检查更新**:用户点击"检查更新"按钮
2. **下载更新**:如果有新版本,用户确认后开始下载
3. **安装更新**
- Windows: 自动运行 NSIS 安装程序(静默模式)
- macOS: 打开 DMG 文件(需用户手动安装)
- Linux: 根据文件类型执行相应安装命令
## 示例服务器实现
### Node.js/Express 示例
```javascript
app.get('/api/update/check', (req, res) => {
const platform = req.headers['user-agent'] || '';
const updateInfo = {
version: "0.0.7",
notes: "## 更新内容\n\n- 修复了已知问题",
pub_date: new Date().toISOString(),
download_url: "https://your-server.com/releases/v0.0.7/cstb-0.0.7.exe",
platforms: {
"windows-x86_64": {
url: "https://your-server.com/releases/v0.0.7/cstb-0.0.7-windows-x86_64.exe"
}
}
};
res.json(updateInfo);
});
```
### Python/Flask 示例
```python
from flask import Flask, jsonify
from datetime import datetime
app = Flask(__name__)
@app.route('/api/update/check')
def check_update():
return jsonify({
"version": "0.0.7",
"notes": "## 更新内容\n\n- 修复了已知问题",
"pub_date": datetime.now().isoformat() + "Z",
"download_url": "https://your-server.com/releases/v0.0.7/cstb-0.0.7.exe",
"platforms": {
"windows-x86_64": {
"url": "https://your-server.com/releases/v0.0.7/cstb-0.0.7-windows-x86_64.exe"
}
}
})
```
## 注意事项
1. **HTTPS**: 建议使用 HTTPS 协议以确保安全
2. **CORS**: 如果从浏览器访问,需要配置 CORS 头
3. **超时**: 请求超时时间为 10 秒
4. **下载超时**: 下载超时时间为 5 分钟
5. **缓存**: 系统不会缓存更新信息,每次检查都会请求最新数据
## 故障排查
### 自定义服务器检查失败
1. 检查服务器是否可访问
2. 检查响应格式是否正确
3. 检查 HTTP 状态码是否为 200
4. 查看应用日志中的错误信息
### GitHub Release 检查失败
1. 确认仓库名称格式正确(`owner/repo`
2. 确认仓库是公开的
3. 确认已创建 Release
4. 确认 Release 中有适合当前平台的资源文件
### 下载失败
1. 检查下载链接是否有效
2. 检查网络连接
3. 检查磁盘空间是否充足
4. 检查文件权限
## 测试
可以使用以下 curl 命令测试自定义服务器:
```bash
curl https://your-server.com/api/update/check
```
应该返回符合格式的 JSON 响应。

181
UPDATE_USAGE.md Normal file
View File

@@ -0,0 +1,181 @@
# 更新功能使用说明
## 功能概述
CS工具箱 现已支持自动更新检查、下载和安装功能。系统支持两种更新源:
1. **自定义更新服务器**(优先使用)
2. **GitHub Release**(备用方案)
## 快速开始
### 1. 配置更新源
在项目根目录创建 `.env.local` 文件(如果不存在):
```env
# 自定义更新服务器 URL可选
NEXT_PUBLIC_UPDATE_ENDPOINT=https://your-server.com/api/update/check
# GitHub 仓库格式owner/repo可选
NEXT_PUBLIC_GITHUB_REPO=your-username/cstb-next
```
### 2. 使用更新功能
1. 打开应用
2. 进入 **偏好设置****通用设置**
3. 在"更新检查"部分点击"检查更新"按钮
4. 如果有新版本,系统会显示更新信息
5. 点击"下载更新"开始下载
6. 下载完成后,点击"立即安装"进行安装
## 功能特性
- ✅ 自动检测新版本
- ✅ 支持自定义更新服务器
- ✅ GitHub Release 作为备用方案
- ✅ 跨平台支持Windows、macOS、Linux
- ✅ 下载进度显示
- ✅ 更新说明显示(支持 Markdown
- ✅ 自动重启应用
## 自定义更新服务器
### 接口要求
你的服务器需要提供一个 GET 接口,返回 JSON 格式的更新信息。
详细格式请参考 [UPDATE_API.md](./UPDATE_API.md)
### 简单示例
```json
{
"version": "0.0.7",
"notes": "修复了已知问题",
"pub_date": "2025-01-15T10:00:00Z",
"download_url": "https://your-server.com/releases/v0.0.7/cstb-0.0.7.exe"
}
```
## GitHub Release
### 设置步骤
1. 在 GitHub 上创建仓库(如果还没有)
2. 创建 Release标签格式`v0.0.7``0.0.7`
3. 上传安装包到 Release 资源
4.`.env.local` 中配置仓库名称:
```env
NEXT_PUBLIC_GITHUB_REPO=your-username/cstb-next
```
### 文件命名建议
- Windows: `cstb-0.0.7-windows-x86_64.exe`
- macOS Intel: `cstb-0.0.7-darwin-x86_64.dmg`
- macOS Apple Silicon: `cstb-0.0.7-darwin-aarch64.dmg`
- Linux: `cstb-0.0.7-linux-x86_64.AppImage`
## 更新流程
```
用户点击"检查更新"
检查自定义服务器
↓ (失败)
检查 GitHub Release
比较版本号
↓ (有新版本)
显示更新对话框
用户确认下载
下载安装包
安装并重启
```
## 开发说明
### Rust 端
更新相关的代码位于:
- `src-tauri/src/tool/updater.rs` - 更新逻辑实现
- `src-tauri/src/cmds.rs` - Tauri 命令接口
- `src-tauri/src/main.rs` - 命令注册
### 前端
更新相关的代码位于:
- `src/components/cstb/UpdateChecker.tsx` - 更新检查组件
- `src/app/(main)/preference/general/page.tsx` - 设置页面
### 添加新的更新源
如果你想添加新的更新源(如 GitLab、自建服务器等可以修改 `src-tauri/src/tool/updater.rs` 中的 `check_update` 函数。
## 故障排查
### 检查更新失败
1. **确认网络连接正常**
2. **检查配置是否正确**
- 确认 `.env.local` 文件存在
- 确认环境变量名称正确
- 确认 URL 格式正确
3. **查看控制台日志**
- 打开开发者工具F12
- 查看 Console 标签页的错误信息
### 下载失败
1. **检查下载链接是否有效**
- 在浏览器中直接访问下载链接
- 确认文件存在且可访问
2. **检查磁盘空间**
- 确保有足够的磁盘空间
3. **检查文件权限**
- 确保应用有写入权限
### 安装失败
1. **Windows**
- 确认有管理员权限
- 检查防病毒软件是否阻止安装
2. **macOS**
- 确认在"系统偏好设置"中允许安装
- 可能需要手动打开 DMG 文件
3. **Linux**
- 确认有 sudo 权限
- 检查包管理器是否正确安装
## 测试
### 测试自定义服务器
1. 启动本地服务器
2. 配置 `NEXT_PUBLIC_UPDATE_ENDPOINT=http://localhost:3000/api/update`
3. 在应用中点击"检查更新"
### 测试 GitHub Release
1. 创建一个测试 Release
2. 配置 `NEXT_PUBLIC_GITHUB_REPO=your-username/your-repo`
3. 确保 Release 版本号高于当前版本
4. 在应用中点击"检查更新"
## 注意事项
1. **版本号格式**:建议使用语义化版本(如 `0.0.7`
2. **HTTPS**:生产环境建议使用 HTTPS
3. **超时设置**:检查更新超时 10 秒,下载超时 5 分钟
4. **自动重启**:安装完成后会自动重启应用
## 相关文档
- [UPDATE_API.md](./UPDATE_API.md) - 详细的 API 接口文档
- [Tauri 官方文档](https://tauri.app/) - Tauri 框架文档

BIN
bun.lockb

Binary file not shown.

147
docs/auth-integration.md Normal file
View File

@@ -0,0 +1,147 @@
# 认证集成说明
## 概述
本应用已实现与网页端https://cstb.upup.cool/)的邮箱注册登录功能集成。用户可以通过网页端登录/注册,然后通过 deep-link 回调到本地应用。
## 实现方案
### 1. 用户流程
1. 用户在本地应用中点击"登录"或"注册"按钮
2. 应用打开浏览器,跳转到网页端登录/注册页面
3. 用户在网页端完成登录/注册
4. 网页端重定向到 `cstb://auth` deep-link携带认证信息
5. 本地应用接收 deep-link 回调,解析并设置 session
6. 用户状态同步到本地应用
### 2. 技术实现
#### 前端Next.js + Tauri
- **认证 Store** (`src/store/auth.ts`): 管理用户状态和会话
- **认证工具** (`src/utils/auth.ts`): 处理登录/注册跳转和回调解析
- **认证 Provider** (`src/components/auth/AuthProvider.tsx`): 监听 deep-link 和认证状态变化
- **认证按钮** (`src/components/auth/AuthButton.tsx`): UI 组件,显示登录状态和用户信息
#### Deep-link 配置
- Scheme: `cstb`
- 回调 URL 格式: `cstb://auth?access_token=xxx&refresh_token=xxx``cstb://auth?code=xxx`
### 3. 网页端集成要求
网页端需要在登录/注册成功后,重定向到 deep-link URL。以下是几种实现方式
#### 方式 1: 使用 access_token 和 refresh_token推荐
```typescript
// 在登录成功后
const { data } = await supabase.auth.signInWithPassword({ email, password })
if (data.session) {
const redirectUrl = new URL('cstb://auth', window.location.href)
redirectUrl.searchParams.set('access_token', data.session.access_token)
redirectUrl.searchParams.set('refresh_token', data.session.refresh_token)
window.location.href = redirectUrl.toString()
}
```
#### 方式 2: 使用 PKCE flow更安全
```typescript
// 在登录页面初始化时
const { data } = await supabase.auth.signInWithOAuth({
provider: 'email',
options: {
redirectTo: 'cstb://auth'
}
})
// 处理回调
const { data: { session } } = await supabase.auth.getSession()
if (session) {
window.location.href = 'cstb://auth?code=' + session.access_token
}
```
#### 方式 3: 使用完整 session JSON
```typescript
// 在登录成功后
const { data } = await supabase.auth.signInWithPassword({ email, password })
if (data.session) {
const redirectUrl = new URL('cstb://auth', window.location.href)
redirectUrl.searchParams.set('session', encodeURIComponent(JSON.stringify(data.session)))
window.location.href = redirectUrl.toString()
}
```
### 4. 网页端修改示例
`https://cstb.upup.cool/auth/login``https://cstb.upup.cool/auth/signup` 页面中:
```typescript
// 检查是否有 redirect 参数
const urlParams = new URLSearchParams(window.location.search)
const redirectTo = urlParams.get('redirect') // 应该是 'cstb://auth'
// 登录成功后
const handleLogin = async (email: string, password: string) => {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
// 处理错误
return
}
if (data.session && redirectTo) {
// 重定向到 deep-link
const redirectUrl = new URL(redirectTo)
redirectUrl.searchParams.set('access_token', data.session.access_token)
redirectUrl.searchParams.set('refresh_token', data.session.refresh_token)
window.location.href = redirectUrl.toString()
} else {
// 正常网页端跳转
window.location.href = '/'
}
}
```
### 5. 环境变量
确保以下环境变量已配置:
```env
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
```
### 6. 测试
1. 启动应用
2. 点击导航栏的用户图标
3. 选择"登录"或"注册"
4. 在浏览器中完成登录/注册
5. 应用应该自动接收回调并显示登录成功提示
## 注意事项
1. **安全性**: 虽然 access_token 和 refresh_token 通过 URL 传递,但由于 deep-link 是本地协议,相对安全。但建议使用 PKCE flow 以获得更好的安全性。
2. **错误处理**: 网页端应该处理登录失败的情况,不要重定向到 deep-link。
3. **用户体验**: 可以在网页端显示"正在跳转到应用..."的提示,提升用户体验。
4. **兼容性**: 确保 deep-link 在所有目标平台上都已正确注册Windows/macOS/Linux
## 故障排查
1. **Deep-link 未触发**: 检查 `tauri.conf.json` 中 deep-link 配置是否正确
2. **Session 未保存**: 检查 Supabase client 配置,确保使用 localStorage
3. **回调参数错误**: 检查网页端传递的参数格式是否正确

View File

@@ -0,0 +1,271 @@
# 网页端集成示例代码
本文档提供网页端https://cstb.upup.cool/)实现登录/注册回调的示例代码。
## 登录页面示例
`auth/login` 页面中:
```typescript
'use client'
import { useState } from 'react'
import { createClient } from '@/utils/supabase/client'
import { useSearchParams } from 'next/navigation'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const searchParams = useSearchParams()
const redirectTo = searchParams.get('redirect') // 应该是 'cstb://auth'
const supabase = createClient()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
alert(`登录失败: ${error.message}`)
setLoading(false)
return
}
if (data.session) {
// 如果有 redirect 参数,说明是从应用跳转过来的
if (redirectTo) {
// 构建 deep-link URL
const redirectUrl = new URL(redirectTo)
redirectUrl.searchParams.set('access_token', data.session.access_token)
redirectUrl.searchParams.set('refresh_token', data.session.refresh_token)
// 重定向到应用
window.location.href = redirectUrl.toString()
} else {
// 正常网页端跳转
window.location.href = '/'
}
}
} catch (error) {
console.error('Login error:', error)
alert('登录时发生错误')
setLoading(false)
}
}
return (
<form onSubmit={handleLogin}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="邮箱"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="密码"
required
/>
<button type="submit" disabled={loading}>
{loading ? '登录中...' : '登录'}
</button>
</form>
)
}
```
## 注册页面示例
`auth/signup` 页面中:
```typescript
'use client'
import { useState } from 'react'
import { createClient } from '@/utils/supabase/client'
import { useSearchParams } from 'next/navigation'
export default function SignupPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false)
const searchParams = useSearchParams()
const redirectTo = searchParams.get('redirect') // 应该是 'cstb://auth'
const supabase = createClient()
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault()
if (password !== confirmPassword) {
alert('两次输入的密码不一致')
return
}
setLoading(true)
try {
const { data, error } = await supabase.auth.signUp({
email,
password,
})
if (error) {
alert(`注册失败: ${error.message}`)
setLoading(false)
return
}
if (data.session) {
// 如果有 redirect 参数,说明是从应用跳转过来的
if (redirectTo) {
// 构建 deep-link URL
const redirectUrl = new URL(redirectTo)
redirectUrl.searchParams.set('access_token', data.session.access_token)
redirectUrl.searchParams.set('refresh_token', data.session.refresh_token)
// 重定向到应用
window.location.href = redirectUrl.toString()
} else {
// 正常网页端跳转
alert('注册成功!请检查邮箱验证链接。')
window.location.href = '/'
}
} else {
// 需要邮箱验证
alert('注册成功!请检查邮箱中的验证链接。')
if (!redirectTo) {
window.location.href = '/'
}
}
} catch (error) {
console.error('Signup error:', error)
alert('注册时发生错误')
setLoading(false)
}
}
return (
<form onSubmit={handleSignup}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="邮箱"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="密码"
required
/>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="确认密码"
required
/>
<button type="submit" disabled={loading}>
{loading ? '注册中...' : '注册'}
</button>
</form>
)
}
```
## 使用 OAuth 提供商的示例
如果使用第三方登录(如 Google、GitHub 等):
```typescript
const handleOAuthLogin = async (provider: 'google' | 'github') => {
const { data, error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: redirectTo || `${window.location.origin}/auth/callback`,
},
})
if (error) {
alert(`登录失败: ${error.message}`)
return
}
// OAuth 会重定向到回调页面,在回调页面中处理
}
```
## 回调页面处理
如果需要处理 OAuth 回调:
```typescript
// auth/callback/page.tsx
'use client'
import { useEffect } from 'react'
import { createClient } from '@/utils/supabase/client'
import { useSearchParams } from 'next/navigation'
export default function CallbackPage() {
const searchParams = useSearchParams()
const redirectTo = searchParams.get('redirect') // 从应用跳转时的原始 redirect
useEffect(() => {
const handleCallback = async () => {
const supabase = createClient()
const { data: { session }, error } = await supabase.auth.getSession()
if (error) {
console.error('Error getting session:', error)
return
}
if (session) {
// 如果是从应用跳转过来的,重定向回应用
if (redirectTo) {
const redirectUrl = new URL(redirectTo)
redirectUrl.searchParams.set('access_token', session.access_token)
redirectUrl.searchParams.set('refresh_token', session.refresh_token)
window.location.href = redirectUrl.toString()
} else {
// 正常网页端跳转
window.location.href = '/'
}
}
}
void handleCallback()
}, [redirectTo])
return <div>...</div>
}
```
## 注意事项
1. **安全性**: 确保只在 HTTPS 环境下使用,避免 token 泄露
2. **错误处理**: 始终处理登录/注册失败的情况
3. **用户体验**: 在重定向前显示加载状态
4. **验证**: 如果启用了邮箱验证,需要处理未验证的情况
## 测试步骤
1. 在应用中使用 `https://cstb.upup.cool/auth/login?redirect=cstb://auth` 打开登录页面
2. 完成登录后,应该自动跳转回应用
3. 检查应用是否成功接收并设置了 session

1
next-env.d.ts vendored
View File

@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,6 +1,6 @@
{
"name": "cstb-next",
"version": "0.0.1",
"version": "0.0.6",
"private": true,
"author": {
"name": "Purp1e",
@@ -12,68 +12,74 @@
"tauri": "tauri",
"build": "tauri build",
"build-fast": "tauri build -b nsis -- --profile dev",
"build-fast-prod": "tauri build -b nsis -- --profile fast-release",
"dev": "tauri dev",
"lint": "next lint",
"fix": "next lint --fix"
"fix": "next lint --fix",
"gen": "node scripts/generate-latest-json.js --base-url https://github.com/plsgo/cstb/releases --rename",
"gen:debug": "node scripts/generate-latest-json.js --base-url https://github.com/plsgo/cstb/releases --target debug --rename",
"gen:fast-release": "node scripts/generate-latest-json.js --base-url https://github.com/plsgo/cstb/releases --target fast-release --rename"
},
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
"@heroui/react": "^2.7.5",
"@formkit/auto-animate": "^0.8.4",
"@heroui/react": "^2.8.5",
"@icon-park/react": "^1.4.2",
"@reactuses/core": "6.0.1",
"@supabase/ssr": "0.6.1",
"@tauri-apps/api": "2.4.0",
"@tauri-apps/plugin-autostart": "^2.2.0",
"@tauri-apps/plugin-cli": "~2",
"@tauri-apps/plugin-clipboard-manager": "2.2.2",
"@tauri-apps/plugin-deep-link": "~2.2.0",
"@tauri-apps/plugin-dialog": "~2.2.0",
"@tauri-apps/plugin-fs": "2.2.0",
"@tauri-apps/plugin-global-shortcut": "2.2.0",
"@tauri-apps/plugin-http": "2.4.2",
"@tauri-apps/plugin-notification": "2.2.2",
"@tauri-apps/plugin-os": "2.2.1",
"@tauri-apps/plugin-process": "2.2.0",
"@tauri-apps/plugin-shell": "2.2.0",
"@tauri-apps/plugin-store": "^2.2.0",
"@tauri-store/valtio": "2.1.1",
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-cli": "^2.4.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-deep-link": "^2.4.5",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.4.4",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-http": "^2.5.4",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.3",
"@tauri-apps/plugin-store": "^2.4.1",
"@tauri-apps/plugin-updater": "^2.9.0",
"@tauri-store/valtio": "^3.2.0",
"@types/throttle-debounce": "^5.0.2",
"ahooks": "^3.8.4",
"framer-motion": "^12.5.0",
"next": "15.2.3",
"ahooks": "^3.9.6",
"framer-motion": "^12.23.24",
"next": "16.0.1",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"swr": "^2.3.3",
"swr": "^2.3.6",
"tauri-plugin-system-info-api": "^2.0.10"
},
"devDependencies": {
"@tauri-apps/cli": "^2.4.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@tailwindcss/postcss": "^4.1.17",
"@tauri-apps/cli": "^2.9.4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^29.5.14",
"@types/node": "^22.13.13",
"@types/node": "^22.19.1",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@typescript-eslint/eslint-plugin": "^8.28.0",
"@typescript-eslint/parser": "^8.28.0",
"autoprefixer": "^10.4.21",
"@typescript-eslint/eslint-plugin": "^8.47.0",
"@typescript-eslint/parser": "^8.47.0",
"autoprefixer": "^10.4.22",
"clsx": "^2.1.1",
"cross-env": "^7.0.3",
"cssnano": "^7.0.6",
"cssnano": "^7.1.2",
"eslint": "9.23.0",
"eslint-config-next": "15.2.3",
"lint-staged": "^15.5.0",
"postcss": "^8.5.3",
"postcss-import": "^16.1.0",
"postcss-nesting": "^13.0.1",
"lint-staged": "^15.5.2",
"postcss": "^8.5.6",
"postcss-import": "^16.1.1",
"postcss-nesting": "^13.0.2",
"tailwind-merge": "3.0.2",
"tailwindcss": "3.4.17",
"typescript": "^5.8.2"
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3"
},
"browserslist": {
"production": [

View File

@@ -1,9 +1,6 @@
module.exports = {
plugins: {
"postcss-import": {},
"tailwindcss/nesting": "postcss-nesting",
tailwindcss: {},
autoprefixer: {},
"@tailwindcss/postcss": {},
...(process.env.NODE_ENV === "production" ? { cssnano: {} } : {}),
},
}

93
scripts/README.md Normal file
View File

@@ -0,0 +1,93 @@
# 生成 latest.json 脚本使用说明
## 概述
`generate-latest-json.js` 脚本用于生成 Tauri 更新器所需的 `latest.json` 文件。该文件包含版本信息、更新说明、下载链接和签名信息。
## 使用方法
### 基本用法
```bash
npm run generate-latest -- --base-url https://your-server.com/releases
```
### 完整示例
```bash
npm run generate-latest -- --base-url https://your-server.com/releases --version 0.0.6-beta.6 --notes "修复了已知问题"
```
### 参数说明
- `--base-url` (必需): 更新文件的基 URL例如 `https://your-server.com/releases`
- `--version` (可选): 版本号,如果不提供,将从 `tauri.conf.json` 读取
- `--notes` (可选): 更新说明,支持 Markdown 格式
## 工作流程
1. **构建应用**: 首先确保已经构建了应用
```bash
npm run build
```
2. **生成 latest.json**: 运行脚本生成 `latest.json` 文件
```bash
npm run generate-latest -- --base-url https://your-server.com/releases
```
3. **上传文件**: 将以下文件上传到服务器:
- 安装包文件(如 `.exe`, `.dmg`, `.AppImage`
- 签名文件(`.sig`
- `latest.json` 文件
## 文件结构
生成的 `latest.json` 文件格式:
```json
{
"version": "0.0.6-beta.6",
"notes": "版本 0.0.6-beta.6 更新",
"pub_date": "2025-01-15T10:00:00.000Z",
"platforms": {
"windows-x86_64": {
"url": "https://your-server.com/releases/CS工具箱_0.0.6-beta.6_x64-setup.exe",
"signature": "签名内容..."
}
}
}
```
## 注意事项
1. **构建产物**: 脚本会自动查找构建产物目录中的文件,确保已经完成构建
2. **签名文件**: 如果存在 `.sig` 文件,脚本会自动读取并包含在 `latest.json` 中
3. **版本匹配**: 脚本会根据版本号匹配文件,确保文件名包含版本号
4. **平台支持**: 脚本支持 Windows、macOS 和 Linux 平台
## 故障排查
### 未找到构建产物
如果脚本提示"未找到任何构建产物"
1. 确保已经运行 `npm run build` 完成构建
2. 检查 `src-tauri/target/release/bundle` 目录是否存在
3. 确认构建产物文件名包含版本号
### 签名文件缺失
如果签名文件缺失:
1. 确保设置了 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 环境变量
2. 确保 `tauri.conf.json` 中 `createUpdaterArtifacts` 设置为 `true`
3. 重新构建应用
### 上传到服务器
将以下文件上传到服务器:
- 安装包文件
- 签名文件(`.sig`
- `latest.json` 文件
确保服务器上的 URL 路径与 `--base-url` 参数匹配。

View File

@@ -0,0 +1,318 @@
#!/usr/bin/env node
/**
* 生成 Tauri 更新器的 latest.json 文件
*
* 使用方法:
* node scripts/generate-latest-json.js [options]
*
* 选项:
* --base-url <url> 更新文件的基 URL必需
* --version <version> 版本号(可选,默认从 tauri.conf.json 读取)
* --notes <notes> 更新说明(可选)
* --target <target> 构建类型(可选,默认 release可选值debug、release、fast-release
* --rename 在生成 latest.json 之前,将文件名中的 "CS工具箱" 改为 "CS_Toolbox"
*/
const fs = require('fs');
const path = require('path');
// 解析命令行参数
const args = process.argv.slice(2);
const getArg = (name, defaultValue = null) => {
const index = args.indexOf(name);
if (index !== -1 && args[index + 1]) {
return args[index + 1];
}
return defaultValue;
};
// 读取配置
const tauriConfigPath = path.join(__dirname, '../src-tauri/tauri.conf.json');
const tauriConfig = JSON.parse(fs.readFileSync(tauriConfigPath, 'utf-8'));
const version = getArg('--version', tauriConfig.version);
const baseUrl = getArg('--base-url');
const notes = getArg('--notes', '');
const target = getArg('--target', 'release');
const shouldRename = args.includes('--rename');
// 验证 target 参数
const validTargets = ['debug', 'release', 'fast-release'];
if (!validTargets.includes(target)) {
console.error(`错误: --target 必须是以下值之一: ${validTargets.join(', ')}`);
process.exit(1);
}
if (!baseUrl) {
console.error('错误: 必须提供 --base-url 参数');
console.error('示例: node scripts/generate-latest-json.js --base-url https://your-server.com/releases');
process.exit(1);
}
// 如果需要重命名,先执行重命名脚本
if (shouldRename) {
console.log('\n执行文件重命名...');
const { execSync } = require('child_process');
try {
execSync(`node scripts/rename-build-artifacts.js --target ${target}`, {
stdio: 'inherit',
cwd: path.join(__dirname, '..')
});
console.log('✓ 文件重命名完成\n');
} catch (err) {
console.error('✗ 文件重命名失败:', err.message);
process.exit(1);
}
}
// 确保 baseUrl 不以 / 结尾
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
// 检测是否是 GitHub releases URL
const githubReleasesMatch = cleanBaseUrl.match(/^https?:\/\/github\.com\/([^\/]+)\/([^\/]+)\/releases$/i);
let githubOwner = null;
let githubRepo = null;
if (githubReleasesMatch) {
githubOwner = githubReleasesMatch[1];
githubRepo = githubReleasesMatch[2];
console.log(`检测到 GitHub releases: ${githubOwner}/${githubRepo}`);
}
// 生成文件 URL
function generateFileUrl(filename) {
// 如果使用了 --rename文件名应该已经是 CS_Toolbox 了
// 但为了兼容,我们检查一下是否需要替换
let finalFilename = filename;
if (shouldRename && filename.includes('CS工具箱')) {
finalFilename = filename.replace(/CS工具箱/g, 'CS_Toolbox');
}
if (githubOwner && githubRepo) {
// GitHub releases 下载 URL 格式: https://github.com/{owner}/{repo}/releases/download/{tag}/{filename}
const tag = version.startsWith('v') ? version : `v${version}`;
return `https://github.com/${githubOwner}/${githubRepo}/releases/download/${tag}/${finalFilename}`;
}
// 普通 URL
return `${cleanBaseUrl}/${finalFilename}`;
}
// 根据 target 确定构建产物目录
const bundleDir = path.join(__dirname, '../src-tauri/target', target, 'bundle');
console.log(`使用构建类型: ${target}`);
console.log(`构建产物目录: ${bundleDir}`);
const platforms = {
'windows-x86_64': {
dir: path.join(bundleDir, 'nsis'),
extensions: ['.exe'],
pattern: /windows|win/i
},
'darwin-x86_64': {
dir: path.join(bundleDir, 'macos'),
extensions: ['.dmg', '.app.tar.gz'],
pattern: /darwin|macos|mac/i
},
'darwin-aarch64': {
dir: path.join(bundleDir, 'macos'),
extensions: ['.dmg', '.app.tar.gz'],
pattern: /darwin|macos|mac|aarch64|arm64/i
},
'linux-x86_64': {
dir: path.join(bundleDir, 'appimage'),
extensions: ['.AppImage', '.deb', '.rpm'],
pattern: /linux/i
}
};
// 递归查找文件
function findFilesRecursive(dir, version, extensions, maxDepth = 2, currentDepth = 0) {
if (!fs.existsSync(dir) || currentDepth > maxDepth) {
return null;
}
try {
const items = fs.readdirSync(dir);
const versionPattern = version.replace(/\./g, '\\.').replace(/-/g, '[-.]');
const regex = new RegExp(versionPattern, 'i');
// 先尝试精确匹配版本
for (const item of items) {
const itemPath = path.join(dir, item);
const stat = fs.statSync(itemPath);
if (stat.isFile()) {
if (!regex.test(item)) continue;
const ext = path.extname(item);
if (extensions.includes(ext)) {
const sigPath = itemPath + '.sig';
return {
file: item,
url: generateFileUrl(item),
signature: fs.existsSync(sigPath) ? fs.readFileSync(sigPath, 'utf-8').trim() : null
};
}
} else if (stat.isDirectory() && currentDepth < maxDepth) {
const result = findFilesRecursive(itemPath, version, extensions, maxDepth, currentDepth + 1);
if (result) {
return result;
}
}
}
// 如果精确匹配失败,尝试查找最新版本的文件(仅在同一目录下)
if (currentDepth === 0) {
const matchingFiles = [];
for (const item of items) {
const itemPath = path.join(dir, item);
const stat = fs.statSync(itemPath);
if (stat.isFile()) {
const ext = path.extname(item);
if (extensions.includes(ext)) {
matchingFiles.push({
file: item,
path: itemPath,
mtime: stat.mtime
});
}
}
}
if (matchingFiles.length > 0) {
// 按修改时间排序,选择最新的文件
matchingFiles.sort((a, b) => b.mtime - a.mtime);
const latest = matchingFiles[0];
const sigPath = latest.path + '.sig';
return {
file: latest.file,
url: generateFileUrl(latest.file),
signature: fs.existsSync(sigPath) ? fs.readFileSync(sigPath, 'utf-8').trim() : null
};
}
}
} catch (err) {
// 忽略错误,继续查找
}
return null;
}
// 查找文件(兼容旧接口)
function findFiles(dir, version, extensions) {
return findFilesRecursive(dir, version, extensions);
}
// 生成 platforms 对象
const platformsData = {};
for (const [platform, config] of Object.entries(platforms)) {
console.log(`\n查找平台 ${platform}:`);
console.log(` 目录: ${config.dir}`);
console.log(` 扩展名: ${config.extensions.join(', ')}`);
// 检查目录是否存在
if (!fs.existsSync(config.dir)) {
console.warn(` 警告: 目录不存在: ${config.dir}`);
continue;
}
const result = findFiles(config.dir, version, config.extensions);
if (result) {
// 如果使用了 --rename文件名应该已经是 CS_Toolbox 了
// 但如果仍然包含 CS工具箱说明重命名可能失败了尝试手动处理
let fileName = result.file;
if (shouldRename && fileName.includes('CS工具箱')) {
console.warn(` 警告: 文件 ${fileName} 仍包含中文,尝试查找重命名后的文件...`);
const renamedFile = fileName.replace(/CS工具箱/g, 'CS_Toolbox');
const renamedPath = path.join(config.dir, renamedFile);
if (fs.existsSync(renamedPath)) {
fileName = renamedFile;
result.file = renamedFile;
result.url = generateFileUrl(renamedFile);
// 检查重命名后的签名文件
const renamedSigPath = renamedPath + '.sig';
if (fs.existsSync(renamedSigPath)) {
result.signature = fs.readFileSync(renamedSigPath, 'utf-8').trim();
}
}
}
console.log(` ✓ 找到文件: ${fileName}`);
platformsData[platform] = {
url: result.url,
signature: result.signature
};
} else {
console.warn(` ✗ 未找到匹配版本 ${version} 的文件`);
// 列出目录中的所有文件以便调试
try {
const files = fs.readdirSync(config.dir);
const exeFiles = files.filter(f => {
const ext = path.extname(f);
return config.extensions.includes(ext);
});
if (exeFiles.length > 0) {
console.log(` 目录中的文件: ${exeFiles.join(', ')}`);
}
} catch (err) {
// 忽略错误
}
}
}
// 如果没有找到任何平台文件,尝试查找所有文件
if (Object.keys(platformsData).length === 0) {
console.warn('\n警告: 未找到任何构建产物,将生成空的 platforms 对象');
console.warn('请确保已经构建了应用: npm run build');
console.warn(`构建产物应该在: ${bundleDir}`);
}
// 生成 latest.json
const latestJson = {
version: version,
notes: notes || `版本 ${version} 更新`,
pub_date: new Date().toISOString(),
platforms: Object.keys(platformsData).length > 0 ? platformsData : undefined
};
// 如果只有一个平台,也可以使用简化的格式
if (Object.keys(platformsData).length === 1) {
const platform = Object.keys(platformsData)[0];
latestJson.download_url = platformsData[platform].url;
latestJson.signature = platformsData[platform].signature;
}
// 输出文件到对应的 bundle 目录
const outputDir = bundleDir;
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// 确保 nsis 子目录存在
const nsisOutputDir = path.join(outputDir, "nsis");
if (!fs.existsSync(nsisOutputDir)) {
fs.mkdirSync(nsisOutputDir, { recursive: true });
}
const outputPath = path.join(nsisOutputDir, 'latest.json');
fs.writeFileSync(outputPath, JSON.stringify(latestJson, null, 2), 'utf-8');
console.log('✓ 成功生成 latest.json');
console.log(` 构建类型: ${target}`);
console.log(` 文件位置: ${outputPath}`);
console.log(` 版本: ${version}`);
console.log(` 平台数量: ${Object.keys(platformsData).length}`);
if (Object.keys(platformsData).length > 0) {
console.log(` 平台: ${Object.keys(platformsData).join(', ')}`);
// 显示签名信息
for (const [platform, data] of Object.entries(platformsData)) {
if (data.signature) {
console.log(` ${platform}: 已找到签名文件`);
} else {
console.log(` ${platform}: 未找到签名文件`);
}
}
}
console.log('\n文件内容:');
console.log(JSON.stringify(latestJson, null, 2));

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env node
/**
* 批量重命名构建产物,将文件名中的 "CS工具箱" 改为 "CS_Toolbox"
* 用于解决 GitHub releases 不支持中文文件名的问题
*
* 使用方法:
* node scripts/rename-build-artifacts.js [options]
*
* 选项:
* --target <target> 构建类型(可选,默认 release可选值debug、release、fast-release
* --dry-run 仅显示将要重命名的文件,不实际执行
*/
const fs = require('fs');
const path = require('path');
// 解析命令行参数
const args = process.argv.slice(2);
const getArg = (name, defaultValue = null) => {
const index = args.indexOf(name);
if (index !== -1 && args[index + 1]) {
return args[index + 1];
}
return defaultValue;
};
const target = getArg('--target', 'release');
const dryRun = args.includes('--dry-run');
// 验证 target 参数
const validTargets = ['debug', 'release', 'fast-release'];
if (!validTargets.includes(target)) {
console.error(`错误: --target 必须是以下值之一: ${validTargets.join(', ')}`);
process.exit(1);
}
// 根据 target 确定构建产物目录
const bundleDir = path.join(__dirname, '../src-tauri/target', target, 'bundle');
console.log(`使用构建类型: ${target}`);
console.log(`构建产物目录: ${bundleDir}`);
console.log(dryRun ? '(仅预览模式,不会实际重命名)' : '');
if (!fs.existsSync(bundleDir)) {
console.error(`错误: 构建产物目录不存在: ${bundleDir}`);
console.error('请先构建应用');
process.exit(1);
}
// 需要重命名的文件扩展名
const extensions = ['.exe', '.dmg', '.AppImage', '.deb', '.rpm', '.app.tar.gz', '.sig'];
// 递归查找并重命名文件
function renameFilesRecursive(dir, maxDepth = 3, currentDepth = 0) {
if (!fs.existsSync(dir) || currentDepth > maxDepth) {
return [];
}
const renamedFiles = [];
try {
const items = fs.readdirSync(dir);
for (const item of items) {
const itemPath = path.join(dir, item);
const stat = fs.statSync(itemPath);
if (stat.isFile()) {
// 检查文件名是否包含 "CS工具箱"
if (item.includes('CS工具箱')) {
const newName = item.replace(/CS工具箱/g, 'CS_Toolbox');
const newPath = path.join(dir, newName);
// 检查新文件名是否已存在
if (fs.existsSync(newPath) && itemPath !== newPath) {
console.warn(` 警告: 目标文件已存在,跳过: ${newName}`);
continue;
}
renamedFiles.push({
oldPath: itemPath,
newPath: newPath,
oldName: item,
newName: newName
});
}
} else if (stat.isDirectory() && currentDepth < maxDepth) {
// 递归查找子目录
const subRenamed = renameFilesRecursive(itemPath, maxDepth, currentDepth + 1);
renamedFiles.push(...subRenamed);
}
}
} catch (err) {
console.error(`读取目录时出错 ${dir}:`, err.message);
}
return renamedFiles;
}
// 查找所有需要重命名的文件
console.log('\n查找需要重命名的文件...');
const filesToRename = renameFilesRecursive(bundleDir);
if (filesToRename.length === 0) {
console.log('✓ 未找到需要重命名的文件');
process.exit(0);
}
// 显示将要重命名的文件
console.log(`\n找到 ${filesToRename.length} 个文件需要重命名:\n`);
filesToRename.forEach(({ oldName, newName }) => {
console.log(` ${oldName}`);
console.log(`${newName}\n`);
});
if (dryRun) {
console.log('(预览模式,未实际执行重命名)');
process.exit(0);
}
// 执行重命名
console.log('执行重命名...');
let successCount = 0;
let errorCount = 0;
for (const { oldPath, newPath, oldName, newName } of filesToRename) {
try {
fs.renameSync(oldPath, newPath);
console.log(`${oldName}${newName}`);
successCount++;
} catch (err) {
console.error(`✗ 重命名失败 ${oldName}:`, err.message);
errorCount++;
}
}
console.log(`\n完成: 成功 ${successCount} 个,失败 ${errorCount}`);
if (errorCount > 0) {
process.exit(1);
}

View File

@@ -1,3 +1,3 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/target/

3720
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "CS工具箱"
version = "0.0.1"
version = "0.0.6"
description = "A Tauri App"
authors = ["Purp1e"]
license = ""
@@ -21,37 +21,50 @@ strip = true # Remove debug symbols
opt-level = 0 # 关闭优化
debug = true # 保留调试信息
[profile.fast-release]
inherits = "release"
lto = false # 关闭链接时优化,加快构建速度
codegen-units = 16 # 增加并行编译单元,加快构建速度
strip = false # 不剥离调试符号,加快构建速度
opt-level = 2 # 使用适中的优化级别(比 release 的 "s" 更快)
[build-dependencies]
tauri-build = { version = "2.1.0", features = [] }
tauri-build = { version = "2.5.1", features = [] }
[dependencies]
log = "0.4.26"
log = "0.4.28"
base64 = "0.22.1"
walkdir = "2.5.0"
serde_json = "1.0.140"
regex = "1.11.1"
serde = { version = "1.0.219", features = ["derive"] }
reqwest = { version = "0.12.15", features = ["blocking"] }
tauri = { version = "2.4.0", features = [ "macos-private-api",
serde_json = "1.0.145"
regex = "1.12.2"
serde = { version = "1.0.228", features = ["derive"] }
reqwest = { version = "0.12.24", features = ["json", "stream", "blocking"] }
futures-util = "0.3.30"
tauri = { version = "2.9.2", features = [ "macos-private-api",
"tray-icon"
] }
window-vibrancy = "0.6.0"
tauri-plugin-process = "2.2.0"
tauri-plugin-fs = "2.2.0"
tauri-plugin-dialog = "2.2.0"
tauri-plugin-os = "2.2.1"
tauri-plugin-clipboard-manager = "2.2.2"
tauri-plugin-shell = "2.2.0"
tauri-plugin-http = "2.4.2"
tauri-plugin-notification = "2.2.2"
tauri-plugin-valtio = "2.1.1"
tauri-plugin-store = "2.2.0"
window-vibrancy = "0.7.1"
tauri-plugin-process = "2.3.1"
tauri-plugin-fs = "2.4.4"
tauri-plugin-dialog = "2.4.2"
tauri-plugin-os = "2.3.2"
tauri-plugin-clipboard-manager = "2.3.2"
tauri-plugin-shell = "2.3.3"
tauri-plugin-http = "2.5.4"
tauri-plugin-notification = "2.3.3"
tauri-plugin-valtio = "3.2.0"
tauri-plugin-store = "2.4.1"
tauri-plugin-system-info = "2.0.9"
tauri-plugin-theme = "2.1.3"
tauri-plugin-autostart = "2.2.0"
tauri-plugin-single-instance = { version = "2.2.2", features = ["deep-link"] }
tauri-plugin-deep-link = "2.2.0"
anyhow = "1.0.97"
tauri-plugin-autostart = "2.5.1"
tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] }
tauri-plugin-deep-link = "2.4.5"
anyhow = "1.0.100"
notify = "8.2.0"
dirs = "6.0.0"
tokio = { version = "1.40", features = ["process"] }
gfxinfo = "0.1.2"
url = "2.5"
md5 = "0.7.0"
[target.'cfg(windows)'.dependencies] # Windows Only
winreg = "0.55.0"
@@ -64,6 +77,7 @@ default = [ "custom-protocol" ]
custom-protocol = [ "tauri/custom-protocol" ]
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-cli = "2"
tauri-plugin-global-shortcut = "2.2.0"
tauri-plugin-single-instance = "2.2.2"
tauri-plugin-cli = "2.4.1"
tauri-plugin-global-shortcut = "2.3.1"
tauri-plugin-single-instance = "2.3.6"
tauri-plugin-updater = "2"

View File

@@ -10,7 +10,6 @@
],
"permissions": [
"global-shortcut:default",
"theme:default",
"store:default",
"store:allow-set",
"store:allow-get-store",
@@ -28,6 +27,11 @@
"autostart:default",
"autostart:allow-enable",
"autostart:allow-disable",
"cli:default"
"cli:default",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"fs:allow-resource-read-recursive",
"dialog:allow-save",
"updater:default"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"desktop-capability":{"identifier":"desktop-capability","description":"","local":true,"windows":["main"],"permissions":["global-shortcut:default","theme:default","store:default","store:allow-set","store:allow-get-store","store:allow-has","store:allow-delete","store:allow-clear","store:allow-values","store:allow-save","store:allow-load","store:allow-reset","store:allow-entries","deep-link:default","deep-link:allow-register","deep-link:allow-get-current","autostart:default","autostart:allow-enable","autostart:allow-disable","cli:default"],"platforms":["macOS","windows","linux"]},"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main"],"permissions":["core:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-remove","fs:allow-rename","fs:allow-exists","core:window:allow-create","core:window:allow-center","core:window:allow-request-user-attention","core:window:allow-set-resizable","core:window:allow-set-maximizable","core:window:allow-set-minimizable","core:window:allow-set-closable","core:window:allow-set-title","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-minimize","core:window:allow-unminimize","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-set-decorations","core:window:allow-set-always-on-top","core:window:allow-set-content-protected","core:window:allow-set-size","core:window:allow-set-min-size","core:window:allow-set-max-size","core:window:allow-set-position","core:window:allow-set-fullscreen","core:window:allow-set-focus","core:window:allow-set-icon","core:window:allow-set-skip-taskbar","core:window:allow-set-cursor-grab","core:window:allow-set-cursor-visible","core:window:allow-set-cursor-icon","core:window:allow-set-cursor-position","core:window:allow-set-ignore-cursor-events","core:window:allow-start-dragging","core:webview:allow-print","shell:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","http:default","notification:default","global-shortcut:allow-is-registered","global-shortcut:allow-register","global-shortcut:allow-register-all","global-shortcut:allow-unregister","global-shortcut:allow-unregister-all","os:allow-platform","os:allow-version","os:allow-os-type","os:allow-family","os:allow-arch","os:allow-exe-extension","os:allow-locale","os:allow-hostname","process:allow-restart","process:allow-exit","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","core:app:allow-app-show","core:app:allow-app-hide","core:app:allow-set-app-theme","process:default","fs:default","dialog:default","os:default","clipboard-manager:default"]},"system-info":{"identifier":"system-info","description":"","local":true,"windows":["*"],"permissions":["system-info:allow-all"]},"valtio":{"identifier":"valtio","description":"","local":true,"windows":["*"],"permissions":["valtio:default","core:event:default"]}}
{"desktop-capability":{"identifier":"desktop-capability","description":"","local":true,"windows":["main"],"permissions":["global-shortcut:default","store:default","store:allow-set","store:allow-get-store","store:allow-has","store:allow-delete","store:allow-clear","store:allow-values","store:allow-save","store:allow-load","store:allow-reset","store:allow-entries","deep-link:default","deep-link:allow-register","deep-link:allow-get-current","autostart:default","autostart:allow-enable","autostart:allow-disable","cli:default","fs:allow-read-text-file","fs:allow-write-text-file","fs:allow-resource-read-recursive","dialog:allow-save","updater:default"],"platforms":["macOS","windows","linux"]},"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main"],"permissions":["core:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-remove","fs:allow-rename","fs:allow-exists","core:window:allow-create","core:window:allow-center","core:window:allow-request-user-attention","core:window:allow-set-resizable","core:window:allow-set-maximizable","core:window:allow-set-minimizable","core:window:allow-set-closable","core:window:allow-set-title","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-minimize","core:window:allow-unminimize","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-set-decorations","core:window:allow-set-always-on-top","core:window:allow-set-content-protected","core:window:allow-set-size","core:window:allow-set-min-size","core:window:allow-set-max-size","core:window:allow-set-position","core:window:allow-set-fullscreen","core:window:allow-set-focus","core:window:allow-set-icon","core:window:allow-set-skip-taskbar","core:window:allow-set-cursor-grab","core:window:allow-set-cursor-visible","core:window:allow-set-cursor-icon","core:window:allow-set-cursor-position","core:window:allow-set-ignore-cursor-events","core:window:allow-start-dragging","core:webview:allow-print","shell:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","http:default","notification:default","global-shortcut:allow-is-registered","global-shortcut:allow-register","global-shortcut:allow-register-all","global-shortcut:allow-unregister","global-shortcut:allow-unregister-all","os:allow-platform","os:allow-version","os:allow-os-type","os:allow-family","os:allow-arch","os:allow-exe-extension","os:allow-locale","os:allow-hostname","process:allow-restart","process:allow-exit","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","core:app:allow-app-show","core:app:allow-app-hide","core:app:allow-set-app-theme","process:default","fs:default","dialog:default","os:default","clipboard-manager:default"]},"system-info":{"identifier":"system-info","description":"","local":true,"windows":["*"],"permissions":["system-info:allow-all"]},"valtio":{"identifier":"valtio","description":"","local":true,"windows":["*"],"permissions":["valtio:default","core:event:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -4,16 +4,18 @@
)]
use tauri::Manager;
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_cli::CliExt;
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_store::StoreExt;
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_cli::CliExt;
use tauri_plugin_autostart::MacosLauncher;
// Window Vibrancy
#[cfg(target_os = "windows")]
use window_vibrancy::apply_acrylic;
#[cfg(target_os = "macos")]
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
use std::thread;
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -38,19 +40,23 @@ fn on_button_clicked() -> String {
}
fn main() {
let mut ctx = tauri::generate_context!();
// 获取应用上下文
let ctx = tauri::generate_context!();
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
let _ = app
.get_webview_window("main")
.expect("no main window")
.set_focus();
}))
// 手动构建 AppConfig 目录路径
let config_dir = dirs::config_dir().expect("无法获取配置目录");
let app_name = ctx.config().identifier.as_str();
let store_dir = config_dir.join(app_name).join("cstb");
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
let window = app.get_webview_window("main").expect("no main window");
window.show().expect("no main window, can't show");
window.set_focus().expect("no main window, can't set focus")
}))
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_valtio::init())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_theme::init(ctx.config_mut()))
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
@@ -66,24 +72,46 @@ fn main() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_system_info::init())
.plugin(tauri_plugin_cli::init())
// .plugin(tauri_plugin_store::Builder::default().build())
// .plugin(tauri_plugin_updater::Builder::new().build())
.setup(|app| {
// .plugin(tauri_plugin_valtio::init())
.plugin(tauri_plugin_valtio::Builder::new().path(&store_dir).build());
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
}
builder.setup(move |app| {
// Get Window
let window = app.get_webview_window("main").unwrap();
let store = app.store("cstb.json")?;
// 获取boolean类型的hidden值Err时设置为False
let hidden: bool = store.get("hidden").and_then(|v| v.as_bool()).unwrap_or(false);
let store = app.store("cstb.json")?;
// 获取boolean类型的hidden值Err时设置为False
let hidden: bool = store
.get("hidden")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Vibrant Window
// Vibrant Window - 使用更优雅的错误处理和延迟应用
#[cfg(target_os = "macos")]
apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, Some(10.0))
.expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS");
{
if let Err(e) =
apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, Some(10.0))
{
eprintln!("Failed to apply vibrancy effect: {:?}", e);
}
}
#[cfg(target_os = "windows")]
apply_acrylic(&window, None)
.expect("Unsupported platform! 'apply_acrylic' is only supported on Windows");
{
// 延迟应用 acrylic 效果,确保窗口完全初始化
let window_handle = window.clone();
thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
if let Err(e) = apply_acrylic(&window_handle, None) {
eprintln!("Failed to apply acrylic effect: {:?}", e);
}
});
}
// apply_blur(&window, Some((18, 18, 18, 0)))
// .expect("Unsupported platform! 'apply_blur' is only supported on Windows");
@@ -106,7 +134,10 @@ fn main() {
// `subcommand` is `Option<Box<SubcommandMatches>>` where `SubcommandMatches` is a struct with { name, matches }.
Ok(matches) => {
println!("{:?}", matches);
if matches.args.contains_key("hidden") && matches.args["hidden"].value == true && hidden {
if matches.args.contains_key("hidden")
&& matches.args["hidden"].value == true
&& hidden
{
window.hide().unwrap();
} else {
window.show().unwrap();
@@ -121,6 +152,7 @@ fn main() {
cmds::greet,
cmds::launch_game,
cmds::kill_game,
cmds::check_process_running,
cmds::kill_steam,
cmds::get_steam_path,
cmds::get_cs_path,
@@ -129,7 +161,25 @@ fn main() {
cmds::set_powerplan,
cmds::get_steam_users,
cmds::set_auto_login_user,
cmds::start_watch_loginusers,
cmds::start_watch_cs2_video,
cmds::stop_watch_cs2_video,
cmds::get_cs2_video_config,
cmds::set_cs2_video_config,
cmds::check_path,
cmds::check_steam_dir_valid,
cmds::analyze_replay,
cmds::get_console_log_path,
cmds::read_vprof_report,
cmds::check_app_update,
cmds::download_app_update,
cmds::cancel_download_update,
cmds::install_app_update,
cmds::get_computer_info,
cmds::get_gpu_info,
cmds::get_memory_info,
cmds::get_monitor_info,
cmds::get_motherboard_info,
on_button_clicked
])
.run(ctx)

View File

@@ -3,6 +3,7 @@ pub mod id;
pub mod path;
pub mod reg;
pub mod user;
pub mod watch;
// common steam utils
use anyhow::Result;
@@ -24,11 +25,11 @@ pub fn launch_game(
let mut opt = launch_option.replace("\n", " ");
if server == "perfectworld" {
opt = opt.replace("-worldwide", "") + " -perfectworld";
} else if server == "worldwide" {
opt = opt.replace("-perfectworld", "") + " -worldwide";
}
opt = match server {
"perfectworld" => opt.replace("-worldwide", "") + " -perfectworld",
"worldwide" => opt.replace("-perfectworld", "") + " -worldwide",
_ => opt,
};
let opts = format!("-applaunch 730 {}", opt);
let opts_split = opts.split_whitespace().collect::<Vec<&str>>();

View File

@@ -14,11 +14,9 @@ pub fn get_steam_users() -> Result<String, String> {
}
mod tests {
use super::*;
#[test]
fn test_get_steam_users() {
let result = get_steam_users();
let result = super::get_steam_users();
assert!(result.is_ok() || result.is_err());
println!("{}", result.unwrap());
}

View File

@@ -0,0 +1,148 @@
use anyhow::Result;
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use tauri::{AppHandle, Emitter};
// 全局 watcher 存储,用于管理文件监听器的生命周期
static WATCHER: OnceLock<Mutex<Option<RecommendedWatcher>>> = OnceLock::new();
static CS2_VIDEO_WATCHER: OnceLock<Mutex<Option<RecommendedWatcher>>> = OnceLock::new();
/// 启动监听 loginusers.vdf 文件的变化
pub fn start_watch_loginusers(app: AppHandle, steam_dir: String) -> Result<()> {
// 停止之前的监听
stop_watch_loginusers();
let loginusers_path = Path::new(&steam_dir).join("config/loginusers.vdf");
let config_dir = Path::new(&steam_dir).join("config");
// 如果 config 目录不存在,不进行监听
if !config_dir.exists() {
log::warn!("config 目录不存在,跳过监听: {:?}", config_dir);
return Ok(());
}
let app_clone = app.clone();
let mut watcher = RecommendedWatcher::new(
move |result: Result<Event, notify::Error>| {
match result {
Ok(event) => {
// 检查是否是 loginusers.vdf 文件的变化
if let EventKind::Modify(_) | EventKind::Create(_) = event.kind {
for path in &event.paths {
if path.ends_with("loginusers.vdf") {
log::info!("检测到 loginusers.vdf 文件变化: {:?}", path);
// 延迟一小段时间,确保文件写入完成
std::thread::sleep(std::time::Duration::from_millis(100));
// 发送事件到前端
if let Err(e) = app_clone.emit("steam://loginusers_changed", ()) {
log::error!("发送 loginusers 变化事件失败: {}", e);
}
break;
}
}
}
}
Err(e) => {
log::error!("文件监听错误: {}", e);
}
}
},
Config::default(),
)?;
// 监听 config 目录(而不是单个文件),因为某些文件系统可能不会直接监听单个文件
watcher.watch(&config_dir, RecursiveMode::NonRecursive)?;
log::info!("开始监听 loginusers.vdf 文件: {:?}", loginusers_path);
// 保存 watcher 到全局变量
let watcher_store = WATCHER.get_or_init(|| Mutex::new(None));
*watcher_store.lock().unwrap() = Some(watcher);
Ok(())
}
/// 停止监听 loginusers.vdf 文件
pub fn stop_watch_loginusers() {
if let Some(watcher_store) = WATCHER.get() {
if let Ok(mut watcher_guard) = watcher_store.lock() {
if let Some(watcher) = watcher_guard.take() {
drop(watcher);
log::info!("已停止监听 loginusers.vdf 文件");
}
}
}
}
/// 启动监听 cs2_video.txt 文件的变化
pub fn start_watch_cs2_video(app: AppHandle, steam_dir: String, steam_id32: u32) -> Result<()> {
// 停止之前的监听
stop_watch_cs2_video();
let cfg_dir = Path::new(&steam_dir)
.join("userdata")
.join(steam_id32.to_string())
.join("730")
.join("local")
.join("cfg");
// 如果 cfg 目录不存在,不进行监听
if !cfg_dir.exists() {
log::warn!("cs2_video.txt 配置目录不存在,跳过监听: {:?}", cfg_dir);
return Ok(());
}
let app_clone = app.clone();
let mut watcher = RecommendedWatcher::new(
move |result: Result<Event, notify::Error>| {
match result {
Ok(event) => {
// 检查是否是 cs2_video.txt 文件的变化
if let EventKind::Modify(_) | EventKind::Create(_) = event.kind {
for path in &event.paths {
if path.ends_with("cs2_video.txt") {
log::info!("检测到 cs2_video.txt 文件变化: {:?}", path);
// 延迟一小段时间,确保文件写入完成
std::thread::sleep(std::time::Duration::from_millis(200));
// 发送事件到前端
if let Err(e) = app_clone.emit("steam://cs2_video_changed", ()) {
log::error!("发送 cs2_video 变化事件失败: {}", e);
}
break;
}
}
}
}
Err(e) => {
log::error!("文件监听错误: {}", e);
}
}
},
Config::default(),
)?;
// 监听 cfg 目录(而不是单个文件),因为某些文件系统可能不会直接监听单个文件
watcher.watch(&cfg_dir, RecursiveMode::NonRecursive)?;
log::info!("开始监听 cs2_video.txt 文件: {:?}", cfg_dir.join("cs2_video.txt"));
// 保存 watcher 到全局变量
let watcher_store = CS2_VIDEO_WATCHER.get_or_init(|| Mutex::new(None));
*watcher_store.lock().unwrap() = Some(watcher);
Ok(())
}
/// 停止监听 cs2_video.txt 文件
pub fn stop_watch_cs2_video() {
if let Some(watcher_store) = CS2_VIDEO_WATCHER.get() {
if let Ok(mut watcher_guard) = watcher_store.lock() {
if let Some(watcher) = watcher_guard.take() {
drop(watcher);
log::info!("已停止监听 cs2_video.txt 文件");
}
}
}
}

View File

@@ -1,10 +1,13 @@
use std::os::windows::process::CommandExt;
use std::fs;
use std::process::Command;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
// const DETACHED_PROCESS: u32 = 0x00000008;
pub fn kill(name: &str) -> String {
#[cfg(windows)]
Command::new("taskkill")
.args(&["/IM", name, "/F"])
.creation_flags(CREATE_NO_WINDOW)
@@ -15,10 +18,14 @@ pub fn kill(name: &str) -> String {
}
pub fn run_steam() -> std::io::Result<std::process::Output> {
Command::new("cmd")
#[cfg(target_os = "windows")]
return Command::new("cmd")
.args(&["/C", "start", "steam://run"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.output();
#[cfg(target_os = "macos")]
Command::new("open").args(&["-a", "Steam"]).output()
}
pub fn get_exe_path(name: &str) -> Result<String, std::io::Error> {
@@ -29,12 +36,26 @@ pub fn get_exe_path(name: &str) -> Result<String, std::io::Error> {
// ----
// 进程路径
let command = format!("Get-Process {} | Select-Object path", name);
let args = command.split_whitespace().collect::<Vec<&str>>();
#[cfg(windows)]
let output = Command::new("powershell.exe")
.args(&args)
.args(&[
"-NoProfile",
"-WindowStyle",
"Hidden",
"-Command",
&command,
])
.creation_flags(CREATE_NO_WINDOW)
.output()?;
#[cfg(target_os = "macos")]
let output = Command::new("osascript")
.args(&[
"-e",
&format!("tell application \"{}\" to get path to me", name),
])
.output()?;
let out = String::from_utf8_lossy(&output.stdout).to_string();
if out.contains("Path") {
@@ -54,6 +75,9 @@ pub fn get_exe_path(name: &str) -> Result<String, std::io::Error> {
pub fn open_path(path: &str) -> Result<(), std::io::Error> {
// path中所有/ 转换为 \
let path = path.replace("/", "\\");
fs::create_dir_all(&path)?;
#[cfg(windows)]
Command::new("cmd.exe")
.args(["/c", "start", "", &path])
.creation_flags(CREATE_NO_WINDOW)
@@ -62,28 +86,96 @@ pub fn open_path(path: &str) -> Result<(), std::io::Error> {
Ok(())
}
pub fn check_process_running(name: &str) -> bool {
// 使用tasklist命令检查进程是否存在
#[cfg(windows)]
{
let output = Command::new("tasklist")
.args(&["/FI", &format!("IMAGENAME eq {}", name)])
.creation_flags(CREATE_NO_WINDOW)
.output();
if let Ok(output) = output {
let stdout = String::from_utf8_lossy(&output.stdout);
// 检查输出中是否包含进程名(排除表头)
stdout.contains(name) && stdout.contains("exe")
} else {
false
}
}
#[cfg(not(windows))]
{
// 对于非Windows系统可以使用ps命令
let output = Command::new("pgrep")
.arg("-f")
.arg(name)
.output();
if let Ok(output) = output {
!output.stdout.is_empty()
} else {
false
}
}
}
// 异步版本的进程检测函数
pub async fn check_process_running_async(name: &str) -> bool {
use tokio::process::Command;
// 使用tasklist命令检查进程是否存在
#[cfg(windows)]
{
let mut cmd = Command::new("tasklist");
cmd.args(&["/FI", &format!("IMAGENAME eq {}", name)]);
cmd.creation_flags(CREATE_NO_WINDOW);
match cmd.output().await {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
// 检查输出中是否包含进程名(排除表头)
stdout.contains(name) && stdout.contains("exe")
}
Err(_) => false,
}
}
#[cfg(not(windows))]
{
// 对于非Windows系统可以使用pgrep命令
let mut cmd = Command::new("pgrep");
cmd.arg("-f");
cmd.arg(name);
match cmd.output().await {
Ok(output) => !output.stdout.is_empty(),
Err(_) => false,
}
}
}
mod tests {
use super::*;
#[test]
fn test_open_path() {
let path = "D:\\Programs\\Steam";
println!("test open path: {}", path);
open_path(path).unwrap();
super::open_path(path).unwrap();
let path = "D:\\Programs\\Steam\\steamapps\\common\\Counter-Strike Global Offensive\\game\\bin\\win64";
println!("test open path: {}", path);
open_path(path).unwrap();
super::open_path(path).unwrap();
let path = "%appdata%/Wmpvp/demo";
println!("test open path: {}", path);
open_path(path).unwrap()
super::open_path(path).unwrap()
}
#[test]
fn test_get_exe_path() {
let path = get_exe_path("steam").expect("failed");
let path = super::get_exe_path("steam").expect("failed");
println!("test get steam path: {}", path);
get_exe_path("not_running").expect("failed");
super::get_exe_path("not_running").expect("failed");
}
}

View File

@@ -1,3 +1,4 @@
pub mod common;
pub mod macros;
pub mod powerplan;
// pub mod updater; // 已迁移到官方 tauri-plugin-updater

View File

@@ -39,6 +39,7 @@ impl PowerPlan {
.get(&mode)
.ok_or("Invalid power plan number (expect from 1 to 4)")?;
#[cfg(target_os = "windows")]
let output = Command::new("powercfg")
.arg("/S")
.arg(guid)
@@ -46,6 +47,14 @@ impl PowerPlan {
.output()
.map_err(|e| format!("Failed to execute powercfg command: {}", e))?;
#[cfg(target_os = "macos")]
let output = Command::new("pmset")
.arg("-g")
.arg("plan")
.output()
.map_err(|e| format!("Failed to execute pmset command: {}", e))?;
if !output.status.success() {
return Err(format!(
"Powercfg command failed: {}",
@@ -57,12 +66,20 @@ impl PowerPlan {
}
pub fn get(&self) -> Result<i32, String> {
#[cfg(windows)]
let output = Command::new("powercfg")
.arg("/L")
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("Failed to execute powercfg command: {}", e))?;
#[cfg(target_os = "macos")]
let output = Command::new("pmset")
.arg("-g")
.arg("plan")
.output()
.map_err(|e| format!("Failed to execute pmset command: {}", e))?;
let output_str = String::from_utf8_lossy(&output.stdout);
let re = regex::Regex::new(r"GUID:\s+(\S+)\s+\(\S+\)\s+\*")
.map_err(|e| format!("Failed to compile regex: {}", e))?;

View File

@@ -1,11 +1,18 @@
use tauri::{
menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem},
menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Emitter, Listener, Manager, Runtime,
};
use serde::{Deserialize, Serialize};
use crate::tool::powerplan::PowerPlanMode;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LaunchOption {
option: String,
name: String,
}
pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
// 托盘菜单项目
let separator = &PredefinedMenuItem::separator(app).unwrap();
@@ -47,13 +54,8 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
)?;
let current_launch_option = MenuItem::with_id(
app,
"current_launch_option",
"启动项档位",
true,
None::<&str>,
)?;
// 创建启动项子菜单(初始为空,后续会动态更新)
let launch_option_submenu = Submenu::with_id(app, "launch_option_submenu", "启动项: 游戏", true)?;
// 创建托盘菜单
let menu = Menu::with_items(
@@ -64,7 +66,7 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
&power_plan_balanced,
&power_plan_powersave,
separator,
&current_launch_option,
&launch_option_submenu,
launch_ww_i,
launch_pw_i,
separator,
@@ -114,10 +116,58 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
}
});
let _ = app.listen("tray://get_current_launch_option", move |event| {
let payload = event.payload();
if payload != "" {
let _ = current_launch_option.set_text("启动项档位 ".to_string() + payload);
// 监听启动项列表更新事件
let launch_option_submenu_clone = launch_option_submenu.clone();
let _ = app.listen("tray://update_launch_options", move |event| {
if let Ok(data) = serde_json::from_str::<serde_json::Value>(event.payload()) {
if let (Some(options), Some(current_index)) = (
data.get("options").and_then(|v| v.as_array()),
data.get("currentIndex").and_then(|v| v.as_u64()),
) {
let current_index = current_index as usize;
// 获取当前启动项名称
let current_name = options
.get(current_index)
.and_then(|opt| opt.get("name"))
.and_then(|n| n.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{}", current_index + 1));
// 更新子菜单标题
let _ = launch_option_submenu_clone.set_text(format!("启动项: {}", current_name));
// 清空现有子菜单项 - 先收集所有项目,然后移除
if let Ok(items) = launch_option_submenu_clone.items() {
let items_to_remove: Vec<_> = items.iter().collect();
for item in items_to_remove {
let _ = launch_option_submenu_clone.remove(item);
}
}
// 创建新的子菜单项
for (index, option) in options.iter().enumerate() {
if let Some(name) = option.get("name").and_then(|n| n.as_str()) {
let display_name = if name.is_empty() {
format!("{}", index + 1)
} else {
name.to_string()
};
let item_id = format!("launch_option_{}", index);
let app_handle = launch_option_submenu_clone.app_handle();
if let Ok(item) = CheckMenuItem::with_id(
app_handle,
&item_id,
&display_name,
true,
index == current_index,
None::<&str>,
) {
let _ = launch_option_submenu_clone.append(&item);
}
}
}
}
}
});
@@ -166,6 +216,12 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
let _ = app.emit("tray://set_powerplan", PowerPlanMode::PowerSaving as i32);
// let _ = power_plan_powersave.set_checked(true);
}
id if id.starts_with("launch_option_") => {
// 提取索引
if let Ok(index) = id.replace("launch_option_", "").parse::<usize>() {
let _ = app.emit("tray://set_launch_index", index);
}
}
_ => {}
})
.on_tray_icon_event(|tray, event| {

View File

@@ -1,2 +1,2 @@
pub mod parse;
pub mod preset;
pub mod preset;

View File

@@ -1,11 +1,12 @@
pub fn to_json(vdf_data: &str) -> String {
let linebreak = match std::env::consts::OS {
"macos" => "\r",
"macos" => "\n", //"\r",
"windows" => "\n",
"linux" => "\n",
_ => "\n",
};
// NOTE: 这样会跳过顶层{}
let startpoint = vdf_data.find('{').unwrap_or(0);
let vdf_data = &vdf_data[startpoint..];
@@ -31,49 +32,107 @@ pub fn to_json(vdf_data: &str) -> String {
json_data.push_str(&line);
}
// let json_str = json_data
json_data = json_data
.replace(",}", "}")
.trim_start_matches(": ")
.trim_end_matches(',')
.to_string();
// json_data = format!("{{{}}}", json_str);
json_data
return json_data;
}
pub fn to_vdf(json_data: &str) -> String {
let json_value: serde_json::Value = serde_json::from_str(json_data).unwrap();
let mut vdf_data = String::new();
build_vdf(&json_value, &mut vdf_data, 0);
vdf_data
}
fn build_vdf(json_value: &serde_json::Value, vdf_data: &mut String, indent_level: usize) {
match json_value {
serde_json::Value::Object(obj) => {
for (key, value) in obj {
vdf_data.push_str(&"\t".repeat(indent_level));
vdf_data.push_str(&format!("\"{}\"\n", key));
vdf_data.push_str(&"\t".repeat(indent_level));
vdf_data.push_str("{\n");
build_vdf(value, vdf_data, indent_level + 1);
vdf_data.push_str(&"\t".repeat(indent_level));
vdf_data.push_str("}\n");
}
}
serde_json::Value::String(s) => {
vdf_data.push_str(&"\t".repeat(indent_level));
vdf_data.push_str(&format!("\"{}\"\t\t\"{}\"\n", s, s));
}
_ => {
vdf_data.push_str(&"\t".repeat(indent_level));
vdf_data.push_str(&format!("\"{}\"\t\t\"{}\"\n", json_value, json_value));
}
}
}
mod tests {
use super::*;
static VDF_DATA: &str = r#""users"
{
"76561198315078806"
{
"AccountName" "_jerry_dota2"
"PersonaName" "Rop紫已黑化"
"RememberPassword" "1"
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AllowAutoLogin" "1"
"MostRecent" "1"
"Timestamp" "1742706884"
}
"76561198107125441"
{
"AccountName" "_im_ai_"
"PersonaName" "Buongiorno"
"RememberPassword" "1"
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AllowAutoLogin" "1"
"MostRecent" "0"
"Timestamp" "1739093763"
}
}
"#;
static JSON_DATA: &str = r#"{
"users": {
"76561198315078806": {
"AccountName": "_jerry_dota2",
"PersonaName": "Rop紫已黑化",
"RememberPassword": "1",
"WantsOfflineMode": "0",
"SkipOfflineModeWarning": "0",
"AllowAutoLogin": "1",
"MostRecent": "1",
"Timestamp": "1742706884"
},
"76561198107125441": {
"AccountName": "_im_ai_",
"PersonaName": "Buongiorno",
"RememberPassword": "1",
"WantsOfflineMode": "0",
"SkipOfflineModeWarning": "0",
"AllowAutoLogin": "1",
"MostRecent": "0",
"Timestamp": "1739093763"
}
}
}"#;
#[test]
fn test_to_json() {
let vdf_data = "\"users\"
{
\"76561198315078806\"
{
\"AccountName\" \"_jerry_dota2\"
\"PersonaName\" \"Rop紫已黑化\"
\"RememberPassword\" \"1\"
\"WantsOfflineMode\" \"0\"
\"SkipOfflineModeWarning\" \"0\"
\"AllowAutoLogin\" \"1\"
\"MostRecent\" \"1\"
\"Timestamp\" \"1742706884\"
}
\"76561198107125441\"
{
\"AccountName\" \"_im_ai_\"
\"PersonaName\" \"Buongiorno\"
\"RememberPassword\" \"1\"
\"WantsOfflineMode\" \"0\"
\"SkipOfflineModeWarning\" \"0\"
\"AllowAutoLogin\" \"1\"
\"MostRecent\" \"0\"
\"Timestamp\" \"1739093763\"
}
}
";
// let expected_json = r#"{"key1": "value1","key2": "value2","subkey": {"key3": "value3"}}"#;
let json_data = to_json(vdf_data);
let json_data = super::to_json(VDF_DATA);
println!("{}", json_data);
// 解析json
let json_value: serde_json::Value = serde_json::from_str(&json_data).unwrap();
@@ -81,4 +140,12 @@ mod tests {
println!("{}", json_value)
// assert_eq!(to_json(vdf_data), expected_json);
}
#[test]
fn test_to_vdf() {
// let json_data = r#"{"key1": "value1","key2": "value2","subkey": {"key3": "value3"}}"#;
let vdf_data = super::to_vdf(JSON_DATA);
println!("{}", vdf_data);
}
}

View File

@@ -1,12 +1,15 @@
use anyhow::Result;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use tauri_plugin_http::reqwest::blocking::get;
use tauri_plugin_http::reqwest::
blocking::get;
use walkdir::WalkDir;
use crate::steam;
@@ -44,6 +47,81 @@ pub struct LocalUser {
avatar_key: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct VideoConfig {
version: String,
vendor_id: String,
device_id: String,
cpu_level: String,
gpu_mem_level: String,
gpu_level: String,
knowndevice: String,
defaultres: String,
defaultresheight: String,
refreshrate_numerator: String,
refreshrate_denominator: String,
fullscreen: String,
coop_fullscreen: String,
nowindowborder: String,
mat_vsync: String,
fullscreen_min_on_focus_loss: String,
high_dpi: String,
auto_config: String,
shaderquality: String,
r_texturefilteringquality: String,
msaa_samples: String,
r_csgo_cmaa_enable: String,
videocfg_shadow_quality: String,
videocfg_dynamic_shadows: String,
videocfg_texture_detail: String,
videocfg_particle_detail: String,
videocfg_ao_detail: String,
videocfg_hdr_detail: String,
videocfg_fsr_detail: String,
monitor_index: String,
r_low_latency: String,
aspectratiomode: String,
}
impl Default for VideoConfig {
fn default() -> Self {
VideoConfig {
version: "15".to_string(),
vendor_id: "0".to_string(),
device_id: "0".to_string(),
cpu_level: "3".to_string(),
gpu_mem_level: "3".to_string(),
gpu_level: "3".to_string(),
knowndevice: "0".to_string(),
defaultres: "1920".to_string(),
defaultresheight: "1080".to_string(),
refreshrate_numerator: "144".to_string(),
refreshrate_denominator: "1".to_string(),
fullscreen: "1".to_string(),
coop_fullscreen: "0".to_string(),
nowindowborder: "1".to_string(),
mat_vsync: "0".to_string(),
fullscreen_min_on_focus_loss: "1".to_string(),
high_dpi: "0".to_string(),
auto_config: "2".to_string(),
shaderquality: "0".to_string(),
r_texturefilteringquality: "3".to_string(),
msaa_samples: "2".to_string(),
r_csgo_cmaa_enable: "0".to_string(),
videocfg_shadow_quality: "0".to_string(),
videocfg_dynamic_shadows: "1".to_string(),
videocfg_texture_detail: "1".to_string(),
videocfg_particle_detail: "0".to_string(),
videocfg_ao_detail: "0".to_string(),
videocfg_hdr_detail: "3".to_string(),
videocfg_fsr_detail: "0".to_string(),
monitor_index: "0".to_string(),
r_low_latency: "1".to_string(),
aspectratiomode: "0".to_string(),
}
}
}
pub fn parse_login_users(steam_dir: &str) -> Result<Vec<LoginUser>> {
let t_path = Path::new(steam_dir).join("config/loginusers.vdf");
if !t_path.exists() {
@@ -56,7 +134,10 @@ pub fn parse_login_users(steam_dir: &str) -> Result<Vec<LoginUser>> {
let mut users = Vec::new();
for (k, v) in kv {
let props = v.as_object().unwrap();
let props = match v.as_object() {
Some(p) => p,
None => continue, // 跳过非对象类型的值
};
let avatar = if let Some(img) = read_avatar(&steam_dir, &k) {
img
@@ -64,7 +145,11 @@ pub fn parse_login_users(steam_dir: &str) -> Result<Vec<LoginUser>> {
String::new()
};
let id64 = k.parse::<u64>()?;
// 跳过无法解析为 u64 的键
let id64 = match k.parse::<u64>() {
Ok(id) => id,
Err(_) => continue,
};
let user = LoginUser {
steam_id32: steam::id::id64_to_32(id64),
@@ -138,7 +223,11 @@ pub fn parse_local_users(steam_dir: &str) -> Result<Vec<LocalUser>> {
// 只处理目录
if entry.file_type().is_dir() {
let id = path.file_name().unwrap().to_str().unwrap();
// 安全获取文件名
let id = match path.file_name().and_then(|n| n.to_str()) {
Some(id_str) => id_str,
None => continue, // 跳过无法获取文件名的路径
};
// 检查 localconfig.vdf 文件是否存在
let local_config_path = path.join("config/localconfig.vdf");
@@ -146,18 +235,26 @@ pub fn parse_local_users(steam_dir: &str) -> Result<Vec<LocalUser>> {
continue;
}
// 读取并解析 localconfig.vdf 文件
let data = fs::read_to_string(local_config_path)?;
// 读取并解析 localconfig.vdf 文件,如果失败则跳过
let data = match fs::read_to_string(&local_config_path) {
Ok(d) => d,
Err(_) => continue, // 跳过无法读取的文件
};
let json_data = super::parse::to_json(&data);
let kv: HashMap<String, Value> = serde_json::from_str(&json_data)?;
let kv = match serde_json::from_str::<HashMap<String, Value>>(&json_data) {
Ok(kv) => kv,
Err(_) => continue, // 跳过无法解析的 JSON
};
// 剥离顶层 UserLocalConfigStore
// let kv = kv.get("UserLocalConfigStore").and_then(|v| v.as_object()).unwrap();
// 获取 friends 节点
let friends = kv.get("friends").and_then(|v| v.as_object());
if friends.is_none() {
continue;
}
let friends = friends.unwrap();
let friends = match kv.get("friends").and_then(|v| v.as_object()) {
Some(f) => f,
None => continue,
};
// 获取 PersonaName
let persona_name = friends
@@ -175,9 +272,15 @@ pub fn parse_local_users(steam_dir: &str) -> Result<Vec<LocalUser>> {
.unwrap_or("")
.to_string();
// 安全解析 ID如果失败则跳过
let steam_id32 = match id.parse::<u32>() {
Ok(id) => id,
Err(_) => continue, // 跳过无法解析为 u32 的 ID
};
// 创建 LocalUser 并加入列表
local_users.push(LocalUser {
steam_id32: id.parse::<u32>().unwrap(),
steam_id32,
persona_name,
avatar_key,
});
@@ -270,6 +373,184 @@ fn read_avatar(steam_dir: &str, steam_id64: &str) -> Option<String> {
}
}
pub fn get_cs2_video(file_path: &str) -> Result<VideoConfig> {
// TODO: no kv
let data = fs::read_to_string(file_path)?;
let json_data = super::parse::to_json(&data);
let kv: HashMap<String, String> = serde_json::from_str(&json_data)?;
let video_config = VideoConfig {
version: kv.get("Version").unwrap_or(&"".to_string()).to_string(),
vendor_id: kv.get("VendorID").unwrap_or(&"".to_string()).to_string(),
device_id: kv.get("DeviceID").unwrap_or(&"".to_string()).to_string(),
cpu_level: kv
.get("setting.cpu_level")
.unwrap_or(&"".to_string())
.to_string(),
gpu_mem_level: kv
.get("setting.gpu_mem_level")
.unwrap_or(&"".to_string())
.to_string(),
gpu_level: kv
.get("setting.gpu_level")
.unwrap_or(&"".to_string())
.to_string(),
knowndevice: kv
.get("setting.knowndevice")
.unwrap_or(&"".to_string())
.to_string(),
defaultres: kv
.get("setting.defaultres")
.unwrap_or(&"".to_string())
.to_string(),
defaultresheight: kv
.get("setting.defaultresheight")
.unwrap_or(&"".to_string())
.to_string(),
refreshrate_numerator: kv
.get("setting.refreshrate_numerator")
.unwrap_or(&"".to_string())
.to_string(),
refreshrate_denominator: kv
.get("setting.refreshrate_denominator")
.unwrap_or(&"".to_string())
.to_string(),
fullscreen: kv
.get("setting.fullscreen")
.unwrap_or(&"".to_string())
.to_string(),
coop_fullscreen: kv
.get("setting.coop_fullscreen")
.unwrap_or(&"".to_string())
.to_string(),
nowindowborder: kv
.get("setting.nowindowborder")
.unwrap_or(&"".to_string())
.to_string(),
mat_vsync: kv
.get("setting.mat_vsync")
.unwrap_or(&"".to_string())
.to_string(),
fullscreen_min_on_focus_loss: kv
.get("setting.fullscreen_min_on_focus_loss")
.unwrap_or(&"".to_string())
.to_string(),
high_dpi: kv
.get("setting.high_dpi")
.unwrap_or(&"".to_string())
.to_string(),
auto_config: kv.get("AutoConfig").unwrap_or(&"".to_string()).to_string(),
shaderquality: kv
.get("setting.shaderquality")
.unwrap_or(&"".to_string())
.to_string(),
r_texturefilteringquality: kv
.get("setting.r_texturefilteringquality")
.unwrap_or(&"".to_string())
.to_string(),
msaa_samples: kv
.get("setting.msaa_samples")
.unwrap_or(&"".to_string())
.to_string(),
r_csgo_cmaa_enable: kv
.get("setting.r_csgo_cmaa_enable")
.unwrap_or(&"".to_string())
.to_string(),
videocfg_shadow_quality: kv
.get("setting.videocfg_shadow_quality")
.unwrap_or(&"".to_string())
.to_string(),
videocfg_dynamic_shadows: kv
.get("setting.videocfg_dynamic_shadows")
.unwrap_or(&"".to_string())
.to_string(),
videocfg_texture_detail: kv
.get("setting.videocfg_texture_detail")
.unwrap_or(&"".to_string())
.to_string(),
videocfg_particle_detail: kv
.get("setting.videocfg_particle_detail")
.unwrap_or(&"".to_string())
.to_string(),
videocfg_ao_detail: kv
.get("setting.videocfg_ao_detail")
.unwrap_or(&"".to_string())
.to_string(),
videocfg_hdr_detail: kv
.get("setting.videocfg_hdr_detail")
.unwrap_or(&"".to_string())
.to_string(),
videocfg_fsr_detail: kv
.get("setting.videocfg_fsr_detail")
.unwrap_or(&"".to_string())
.to_string(),
monitor_index: kv
.get("setting.monitor_index")
.unwrap_or(&"".to_string())
.to_string(),
r_low_latency: kv
.get("setting.r_low_latency")
.unwrap_or(&"".to_string())
.to_string(),
aspectratiomode: kv
.get("setting.aspectratiomode")
.unwrap_or(&"".to_string())
.to_string(),
};
Ok(video_config)
}
pub fn set_cs2_video(file_path: &str, data: VideoConfig) -> Result<()> {
// 读取文件内容
let file_content = fs::read_to_string(file_path)?;
// 定义正则表达式匹配模式
let re = Regex::new(r#""(setting\.\w+)"\s+"-?\d+""#).unwrap();
// 替换字段值
let updated_content = re.replace_all(&file_content, |caps: &regex::Captures| {
let key = &caps[1]; // 捕获的键名
let value = match key {
"Version" => &data.version,
"VendorID" => &data.vendor_id,
"DeviceID" => &data.device_id,
"setting.cpu_level" => &data.cpu_level,
"setting.gpu_mem_level" => &data.gpu_mem_level,
"setting.gpu_level" => &data.gpu_level,
"setting.knowndevice" => &data.knowndevice,
"setting.defaultres" => &data.defaultres,
"setting.defaultresheight" => &data.defaultresheight,
"setting.refreshrate_numerator" => &data.refreshrate_numerator,
"setting.refreshrate_denominator" => &data.refreshrate_denominator,
"setting.fullscreen" => &data.fullscreen,
"setting.coop_fullscreen" => &data.coop_fullscreen,
"setting.nowindowborder" => &data.nowindowborder,
"setting.mat_vsync" => &data.mat_vsync,
"setting.fullscreen_min_on_focus_loss" => &data.fullscreen_min_on_focus_loss,
"setting.high_dpi" => &data.high_dpi,
"AutoConfig" => &data.auto_config,
"setting.shaderquality" => &data.shaderquality,
"setting.r_texturefilteringquality" => &data.r_texturefilteringquality,
"setting.msaa_samples" => &data.msaa_samples,
"setting.r_csgo_cmaa_enable" => &data.r_csgo_cmaa_enable,
"setting.videocfg_shadow_quality" => &data.videocfg_shadow_quality,
"setting.videocfg_dynamic_shadows" => &data.videocfg_dynamic_shadows,
"setting.videocfg_texture_detail" => &data.videocfg_texture_detail,
"setting.videocfg_particle_detail" => &data.videocfg_particle_detail,
"setting.videocfg_ao_detail" => &data.videocfg_ao_detail,
"setting.videocfg_hdr_detail" => &data.videocfg_hdr_detail,
"setting.videocfg_fsr_detail" => &data.videocfg_fsr_detail,
"setting.monitor_index" => &data.monitor_index,
"setting.r_low_latency" => &data.r_low_latency,
"setting.aspectratiomode" => &data.aspectratiomode,
_ => "", // 默认情况
};
format!(r#""{}" "{}""#, key, value)
});
fs::write(file_path, updated_content.as_ref())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -279,4 +560,25 @@ mod tests {
let users = get_users("D:\\Programs\\Steam").unwrap();
println!("{:?}", users);
}
#[test]
fn test_get_cs2_video() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let file_path = format!("{}/src/vdf/tests/cs2_video.txt", manifest_dir);
let video_config = get_cs2_video(&file_path).unwrap();
println!("{:?}", video_config);
}
#[test]
fn test_set_cs2_video() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let file_path = format!("{}/temp/cs2_video.txt", manifest_dir);
fs::copy(
format!("{}/src/vdf/tests/cs2_video.txt", manifest_dir),
file_path.clone(),
)
.unwrap();
let video_config = VideoConfig::default();
set_cs2_video(&file_path, video_config).unwrap();
}
}

View File

@@ -12,6 +12,10 @@
"copyright": "",
"targets": "all",
"externalBin": [],
"resources": [
"resources/csda.exe"
],
"createUpdaterArtifacts": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -22,7 +26,14 @@
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
"timestampUrl": "",
"nsis": {
"languages": [
"SimpChinese",
"English",
"TradChinese"
]
}
},
"longDescription": "",
"macOS": {
@@ -32,7 +43,6 @@
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"linux": {
"deb": {
@@ -42,7 +52,7 @@
},
"productName": "CS工具箱",
"mainBinaryName": "cstb",
"version": "0.0.5-beta.4",
"version": "0.0.7-beta.1",
"identifier": "upup.cool",
"plugins": {
"deep-link": {
@@ -60,6 +70,14 @@
"description": "hidden on start"
}
]
},
"updater": {
"active": true,
"endpoints": [
"https://gh-info.okk.cool/repos/plsgo/cstb/releases/latest/pre/tauri"
],
"dialog": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU3QUY5MUU5OTc3N0FCODkKUldTSnEzZVg2Wkd2NTRlVDBxVWNoYkNxZ1c1TlVJT0QwYkFOcFVPUnRQTGlmTVdRcVRRRUdlMUoK"
}
},
"app": {
@@ -80,7 +98,7 @@
"theme": null,
"hiddenTitle": true,
"titleBarStyle": "Transparent",
"visible": false
"visible": false
}
],
"security": {

View File

@@ -5,7 +5,11 @@ import { Chip, Code, Skeleton } from "@heroui/react"
import useSWR from "swr"
export default function Page() {
return <CfgxList />
return (
<section className="flex flex-col gap-4 overflow-hidden">
<CfgxList />
</section>
)
}
function CfgxList() {

View File

@@ -0,0 +1,126 @@
"use client"
import { useState } from "react"
import { MarkdownRender } from "@/components/markdown"
import { Card, CardBody, CardHeader, CardIcon, CardTool } from "@/components/window/Card"
import { createClient } from "@/utils/supabase/client"
import { NewspaperFolding } from "@icon-park/react"
import useSWR from "swr"
import { Chip, Skeleton, Tabs, Tab } from "@heroui/react"
import { Key } from "@react-types/shared"
export default function Page() {
const [selectedKey, setSelectedKey] = useState<Key>("stable")
const showTestVersions = selectedKey === "test"
return (
<section className="flex flex-col gap-4 overflow-hidden">
<Card className="overflow-hidden">
<CardHeader>
<CardIcon>
<NewspaperFolding />
</CardIcon>
<CardTool>
<Tabs
selectedKey={selectedKey}
onSelectionChange={setSelectedKey}
size="sm"
radius="full"
classNames={{
base: "min-w-0",
tabList: "gap-0 p-0",
tab: "min-w-0 px-3",
tabContent: "text-xs",
}}
>
<Tab key="stable" title="正式版" />
<Tab key="test" title="测试版" />
</Tabs>
</CardTool>
</CardHeader>
<CardBody className="overflow-y-hidden">
<ReleaseNotes showTestVersions={showTestVersions} />
</CardBody>
</Card>
</section>
)
}
const ReleaseNotes = ({ showTestVersions }: { showTestVersions: boolean }) => {
const noticeFetcher = async () => {
const supabase = createClient()
let query = supabase
.from("ReleaseNote")
.select("version, content, created_at, stable")
if (!showTestVersions) {
query = query.eq("stable", true)
}
const { data /* , error */ } = await query
.order("created_at", { ascending: false })
.range(0, 10)
return data
}
const { data: releases /* , error */, isLoading } = useSWR(
`/api/release-notes?test=${showTestVersions}`,
noticeFetcher
)
return (
<ul
className="grid h-full grid-cols-1 gap-2 overflow-y-auto rounded-lg grid-flow-dense lg:grid-cols-2 xl:grid-cols-3 pb-9 hide-scrollbar"
>
{isLoading ? (
<>
<li><Skeleton className="h-32 rounded-lg"></Skeleton></li>
<li><Skeleton className="h-32 rounded-lg"></Skeleton></li>
<li><Skeleton className="h-32 rounded-lg"></Skeleton></li>
</>
) : (
releases?.map((release) => {
const isStable = release.stable === true
return (
<li key={release.version}>
{/* <Link href={`/releases/${release.version}`} className="w-full"> */}
<Card
className="w-full h-full pt-3 transition bg-white/60 text-zinc-900 dark:bg-white/5 dark:text-white"
>
<CardHeader>
<div className="flex items-center justify-between w-full gap-2">
<h3 className="text-2xl font-semibold">CS工具箱 {release.version}</h3>
<Chip
size="sm"
variant="bordered"
className={
isStable
? "border-blue-400 text-blue-600 dark:border-blue-500 dark:text-blue-400"
: "border-orange-400 text-orange-600 dark:border-orange-500 dark:text-orange-400"
}
>
{isStable ? "正式版" : "测试版"}
</Chip>
</div>
<span className="flex items-center gap-3">
<Chip size="sm" className="bg-zinc-200 dark:bg-white/10">
{release.created_at ? new Date(release.created_at as string).toLocaleString() : "未知时间"}
</Chip>
</span>
</CardHeader>
<CardBody className="gap-3">
<div className="">
<MarkdownRender>{release.content || "无内容"}</MarkdownRender>
</div>
</CardBody>
</Card>
{/* </Link> */}
</li>
)
})
)}
</ul>
)
}

View File

@@ -1,76 +1,426 @@
"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 } from "@heroui/react"
import { Chip, Skeleton } from "@heroui/react"
import { Refresh, SettingConfig } from "@icon-park/react"
// import { version } from "@tauri-apps/plugin-os"
import { useHardwareStore } from "@/store/hardware"
import { useEffect, useState } from "react"
import { type AllSystemInfo, allSysInfo } from "tauri-plugin-system-info-api"
export default function Page() {
return (
<Card className="h-full">
<CardHeader>
<CardIcon type="menu">
<SettingConfig />
</CardIcon>
<CardTool>
{/* <ToolButton>
<UploadOne />
云同步
</ToolButton> */}
<ToolButton>
<Refresh />
</ToolButton>
</CardTool>
</CardHeader>
<section className="flex flex-col gap-4 overflow-hidden rounded-lg">
<div className="flex flex-col h-full gap-4 overflow-hidden hide-scrollbar">
<Card className="overflow-hidden">
<CardHeader>
<CardIcon type="menu">
<SettingConfig />
</CardIcon>
<CardTool>
{/* <ToolButton>
<UploadOne />
云同步
</ToolButton> */}
<HardwareInfo />
</CardTool>
</CardHeader>
<CardBody>
<HardwareInfo />
</CardBody>
</Card>
<CardBody>
<HardwareInfoContent />
</CardBody>
</Card>
</div>
</section>
)
}
function HardwareInfo() {
const [allSysData, setAllSysData] = useState<AllSystemInfo>()
// const [memInfo, setMemInfo] = useState("")
// const [staticData, setStaticData] = useState("")
// const [cpuData, setCpuData] = useState("")
// const [batteryData, setBatteryData] = useState("")
useEffect(() => {
const fetchData = async () => {
const sys = await allSysInfo()
console.log(sys)
setAllSysData(sys)
// console.log(await memoryInfo())
// console.log(await staticInfo())
// console.log(await cpuInfo())
// console.log(await batteries())
}
void fetchData()
}, [])
const { refreshHardwareInfo } = useHardwareStore()
const [isRefreshing, setIsRefreshing] = useState(false)
return (
<div className="flex flex-col gap-1.5">
<Chip>CPU型号 {allSysData?.cpus[0]?.brand}</Chip>
<Chip>线 {allSysData?.cpu_count}</Chip>
<Chip>
{allSysData?.name} {allSysData?.os_version}
</Chip>
<Chip>
{allSysData?.total_memory &&
`${(allSysData.total_memory / 1024 / 1024 / 1024).toFixed(0)}GB`}
</Chip>
<ToolButton
onClick={async () => {
setIsRefreshing(true)
try {
await refreshHardwareInfo()
} finally {
setIsRefreshing(false)
}
}}
disabled={isRefreshing}
>
<Refresh />
</ToolButton>
)
}
function HardwareInfoContent() {
const { state, fetchHardwareInfo } = useHardwareStore()
const [isLoading, setIsLoading] = useState(!state.allSysData)
useEffect(() => {
// 如果数据不存在,则加载数据
if (!state.allSysData) {
setIsLoading(true)
void fetchHardwareInfo().finally(() => {
setIsLoading(false)
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // 只在组件挂载时执行一次state 是响应式的,不需要作为依赖
const allSysData = state.allSysData
const computerInfo = state.computerInfo || {}
const gpuInfo = state.gpuInfo
const memoryInfo = state.memoryInfo || []
const monitorInfo = state.monitorInfo || []
const motherboardInfo = state.motherboardInfo
const formatBytes = (bytes?: number) => {
if (!bytes) return "未知"
const gb = bytes / 1024 / 1024 / 1024
if (gb >= 1) {
return `${gb.toFixed(2)}GB`
}
const mb = bytes / 1024 / 1024
return `${mb.toFixed(2)}MB`
}
// 格式化系统信息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
// 如果正在加载,显示 Skeleton 骨架屏
if (isLoading || !allSysData) {
return (
<div className="flex flex-col gap-4">
{/* 系统信息 Skeleton */}
<div className="flex flex-col gap-2">
<Skeleton className="w-24 h-5 rounded" />
<div className="flex flex-wrap gap-2">
<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="w-20 h-5 rounded" />
<div className="flex flex-wrap gap-2">
<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="w-16 h-5 rounded" />
<div className="flex flex-wrap gap-2">
<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="w-16 h-5 rounded" />
<div className="flex flex-wrap gap-2">
<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>
)
}
return (
<div className="flex flex-col gap-4">
{/* 系统信息 */}
<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> {formatSystemInfo()}
</Chip>
{computerInfo.CsName && (
<Chip>
<span className="font-medium"></span> {computerInfo.CsName}
</Chip>
)}
{!computerInfo.CsName && allSysData?.hostname && (
<Chip>
<span className="font-medium"></span> {allSysData.hostname}
</Chip>
)}
</div>
</div>
{/* CPU 信息 */}
<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> {allSysData?.cpus?.[0]?.brand || "未知"}
</Chip>
{averageCpuFrequency !== null && (
<Chip>
<span className="font-medium"></span> {averageCpuFrequency.toFixed(2)} GHz
</Chip>
)}
<Chip>
<span className="font-medium"></span> {allSysData?.cpu_count || "未知"}
</Chip>
</div>
</div>
{/* 内存信息 */}
{allSysData?.total_memory && (
<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> {formatBytes(allSysData.total_memory)}
</Chip>
{allSysData.used_memory !== undefined && (
<Chip>
<span className="font-medium"></span> {formatBytes(allSysData.used_memory)}
{memoryUsagePercent !== null && (
<span className="ml-1">({memoryUsagePercent}%)</span>
)}
</Chip>
)}
</div>
{/* 每个内存条的详细信息 */}
{memoryInfo.length > 0 && (
<div className="flex flex-col gap-1.5">
{memoryInfo.map((mem, index) => (
<div key={index} className="flex flex-wrap gap-2">
{mem.capacity && (
<Chip>
<span className="font-medium"></span> {formatBytes(mem.capacity)}
</Chip>
)}
{mem.manufacturer && (
<Chip>
<span className="font-medium"></span> {mem.manufacturer}
</Chip>
)}
{mem.speed && (
<Chip>
<span className="font-medium"></span> {mem.speed} MHz
{mem.default_speed && (
<span className="ml-1">({mem.default_speed} MHz)</span>
)}
</Chip>
)}
</div>
))}
</div>
)}
</div>
)}
{/* GPU 信息 */}
{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("显卡")
)
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 || "未知"}
{gpu.temperature !== undefined && (
<span className="ml-1">
({gpu.temperature}°C{gpu.max !== undefined ? ` / ${gpu.max}°C` : ""})
</span>
)}
</Chip>
))}
</div>
</div>
)
})()
)}
{/* 主板信息 */}
{(motherboardInfo?.model ||
motherboardInfo?.manufacturer ||
motherboardInfo?.version ||
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">
{motherboardInfo?.model && (
<Chip>
<span className="font-medium"></span> {motherboardInfo.model}
</Chip>
)}
{motherboardInfo?.manufacturer && (
<Chip>
<span className="font-medium"></span> {motherboardInfo.manufacturer}
</Chip>
)}
</div>
<div className="flex flex-wrap gap-2">
{motherboardInfo?.version && (
<Chip>
<span className="font-medium"></span> {motherboardInfo.version}
</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">
<div className="text-sm font-medium text-foreground-600"></div>
<div className="flex flex-wrap gap-2">
{allSysData.batteries.map((battery, index) => (
<Chip key={index}>
<span className="font-medium">{index > 0 ? ` ${index + 1}` : ""}</span>
{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>
)}
</Chip>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -10,10 +10,10 @@ import SmartTransfer from "@/components/cstb/SmartTranser"
const Home = () => {
return (
<section className="flex flex-col h-full gap-4">
<section className="flex flex-col h-full gap-4 overflow-hidden">
<div className="flex flex-grow w-full gap-4">
<Notice />
<SmartTransfer />
<SmartTransfer />
</div>
<CommonDir />

View File

@@ -1,4 +1,66 @@
"use client"
import { Card, CardBody, CardHeader, CardIcon, CardTool } from "@/components/window/Card"
import { MovieBoard } from "@icon-park/react"
import { ToolButton } from "@/components/window/ToolButton"
import { invoke } from "@tauri-apps/api/core"
import path from "path"
import { useSteamStore } from "@/store/steam"
import { addToast } from "@heroui/react"
// import { Command } from "@tauri-apps/plugin-shell"
export default function Page() {
return <div>Movie</div>
const steam = useSteamStore()
const testDemo = async (demo_name: string) => {
const res = await invoke("analyze_replay", {
path: path.resolve(
steam.cs2BaseDir(),
"game",
"csgo",
"replays",
"test.dem"
),
})
console.log("test.dem", "→", res)
// const demo_path = path.resolve(
// '"',
// steam.cs2BaseDir(),
// "game",
// "csgo",
// "replays",
// demo_name,
// '"'
// )
// const command = Command.sidecar("bin/csda", [
// "-demo-path",
// "D:\\Programs\\Steam\\steamapps\\common\\Counter-Strike Global Offensive\\game\\csgo\\replays",
// "-format",
// "json",
// "-minify",
// ])
// const output = await command.execute()
// console.log("output", output)
addToast({ title: "解析成功" })
}
return (
<section className="flex flex-col gap-4 overflow-hidden">
<Card className="overflow-hidden">
<CardHeader>
<CardIcon>
<MovieBoard />
</CardIcon>
<CardTool>
<ToolButton
onClick={async () => {
await testDemo("test.dem")
}}
>
</ToolButton>
</CardTool>
</CardHeader>
<CardBody className="overflow-y-hidden"></CardBody>
</Card>
</section>
)
}

View File

@@ -1,37 +1,114 @@
"use client"
import { useEffect } from "react"
import { useAppStore } from "@/store/app"
import { Switch } from "@heroui/react"
import { Switch, Chip } from "@heroui/react"
import { UpdateChecker } from "@/components/cstb/UpdateChecker"
import { getVersion } from "@tauri-apps/api/app"
export default function Page() {
const app = useAppStore()
// 初始化版本号(如果还没有设置)
useEffect(() => {
if (typeof window !== "undefined" && (!app.state.version || app.state.version === "0.0.1")) {
void getVersion().then((version) => {
app.setVersion(version)
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 从环境变量或配置中获取更新服务器地址
const customEndpoint = process.env.NEXT_PUBLIC_UPDATE_ENDPOINT || ""
return (
<div className="flex flex-col items-start gap-3 pt-2 pb-1">
<p>{app.state.version}</p>
<p>{app.state.hasUpdate ? "有" : "无"}</p>
<p>使{app.state.useMirror ? "是" : "否"}</p>
<Switch
isSelected={app.state.autoStart}
size="sm"
onChange={(e) => app.setAutoStart(e.target.checked)}
>
{app.state.autoStart ? "开" : "关"}
</Switch>
<Switch
isSelected={app.state.startHidden}
size="sm"
onChange={(e) => app.setStartHidden(e.target.checked)}
>
{app.state.startHidden ? "开" : "关"}
</Switch>
{/* hiddenOnClose */}
<Switch
isSelected={app.state.hiddenOnClose}
size="sm"
onChange={(e) => app.setHiddenOnClose(e.target.checked)}
>
{app.state.hiddenOnClose ? "开" : "关"}
</Switch>
</div>
<section className="flex flex-col gap-4 overflow-hidden">
<div className="flex flex-col items-start gap-4 pt-2 pb-1">
<div className="space-y-2">
<div className="flex items-center gap-2">
<p className="text-sm">{app.state.version}</p>
{app.state.hasUpdate && app.state.latestVersion && (
<Chip size="sm" color="success" variant="flat">
{app.state.latestVersion}
</Chip>
)}
</div>
{/* <p className="text-sm">是否有更新:{app.state.hasUpdate ? "有" : "无"}</p> */}
{/* <p className="text-sm">是否使用镜像源:{app.state.useMirror ? "是" : "否"}</p> */}
</div>
<div className="w-full pt-4 border-t border-zinc-200 dark:border-zinc-800">
<h3 className="mb-3 text-sm font-semibold"></h3>
<div className="mb-3 space-y-3">
{/* <Switch
isSelected={app.state.useMirror}
size="sm"
onChange={(e) => app.setUseMirror(e.target.checked)}
>
使用镜像源
</Switch> */}
{/* <p className="text-xs text-zinc-500">
{app.state.useMirror
? "使用自建更新服务检查更新"
: "使用 GitHub Release 检查更新"}
</p> */}
<Switch
isSelected={app.state.includePrerelease}
size="sm"
onChange={(e) => app.setIncludePrerelease(e.target.checked)}
>
</Switch>
<p className="text-xs text-zinc-500">
{app.state.includePrerelease
? "检查更新时会包含预发布版本beta、alpha等"
: "仅检查正式版本"}
</p>
<Switch
isSelected={app.state.useCdn}
size="sm"
onChange={(e) => app.setUseCdn(e.target.checked)}
>
使 CDN
</Switch>
<p className="text-xs text-zinc-500">
{app.state.useCdn
? "加速下载,避免 GitHub 无法正常访问"
: "直接从 GitHub 下载更新文件"}
</p>
</div>
<UpdateChecker
customEndpoint={customEndpoint || undefined}
includePrerelease={app.state.includePrerelease}
useCdn={app.state.useCdn}
/>
</div>
<div className="flex flex-col w-full pt-4 space-y-3 border-t border-zinc-200 dark:border-zinc-800">
<h3 className="mb-3 text-sm font-semibold"></h3>
<Switch
isSelected={app.state.autoStart}
size="sm"
onChange={(e) => app.setAutoStart(e.target.checked)}
>
</Switch>
<Switch
isSelected={app.state.startHidden}
size="sm"
onChange={(e) => app.setStartHidden(e.target.checked)}
>
</Switch>
<Switch
isSelected={app.state.hiddenOnClose}
size="sm"
onChange={(e) => app.setHiddenOnClose(e.target.checked)}
>
</Switch>
</div>
</div>
</section>
)
}

View File

@@ -5,12 +5,14 @@ export default function Page() {
const steam = useSteamStore()
return (
<div className="flex flex-col items-start gap-3 pt-2 pb-1">
<p>Steam路径{steam.state.steamDir}</p>
<p>{steam.state.cs2Dir}</p>
<p>Steam路径有效{steam.state.steamDirValid ? "是" : "否"}</p>
<p>{steam.state.cs2DirValid ? "是" : "否"}</p>
<p>Steam账号{steam.currentUser()?.account_name || " "}</p>
</div>
<section className="flex flex-col gap-4 overflow-hidden">
<div className="flex flex-col items-start gap-3 pt-2 pb-1">
<p>Steam路{steam.state.steamDir}</p>
<p>{steam.state.cs2Dir}</p>
<p>Steam路径有{steam.state.steamDirValid ? "是" : "否"}</p>
<p>{steam.state.cs2DirValid ? "是" : "否"}</p>
<p>Steam账号{steam.currentUser()?.account_name || " "}</p>
</div>
</section>
)
}

View File

@@ -1,4 +1,8 @@
"use client"
export default function Page() {
return <>Replay</>
return (
<section className="flex flex-col gap-4 overflow-hidden">
<>Replay</>
</section>
)
}

View File

@@ -1,11 +1,15 @@
"use client"
import VideoSetting from "@/components/cstb/VideoSetting"
import { FpsTest } from "@/components/cstb/FpsTest"
export default function Page() {
return (
<section className="flex flex-col h-full gap-4">
<VideoSetting />
<section className="flex flex-col h-full gap-4 overflow-hidden">
<div className="flex flex-col h-full gap-4 overflow-y-auto rounded-lg hide-scrollbar">
<VideoSetting />
<FpsTest />
</div>
</section>
)
}

View File

@@ -10,7 +10,7 @@ export default function PreferenceLayout({
// const pathname = usePathname()
return (
<div className="flex w-full gap-3">
<div className="flex w-full h-full gap-3 overflow-hidden">
{/* <Card className="flex-grow max-w-ful">
<CardHeader>
<CardIcon

View File

@@ -1,4 +1,8 @@
"use client"
export default function Page() {
return <div>Users</div>
return (
<section className="flex flex-col gap-4 overflow-hidden">
<div>Users</div>
</section>
)
}

View File

@@ -1,6 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@config "../../tailwind.config.js";
html,
body {
@@ -21,3 +20,12 @@ a {
* {
box-sizing: border-box;
}
/* 隐藏滚动条 */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}

View File

@@ -1,11 +1,11 @@
"use client"
import { init } from "@/store"
import { useSteamStore } from "@/store/steam"
import { useToolStore } from "@/store/tool"
import { steamStore, useSteamStore } from "@/store/steam"
import { toolStore, useToolStore } from "@/store/tool"
import { addToast } from "@heroui/react"
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
import { useDebounce } from "ahooks"
import { useDebounce, useThrottleFn } from "ahooks"
import { useEffect } from "react"
import "./globals.css"
import Providers from "./providers"
@@ -19,12 +19,31 @@ export default function RootLayout({ children }: { children: React.ReactNode })
void init()
void listen<string>("tray://launch_game", async (event) => {
await invoke("launch_game", {
steamPath: `${steam.state.steamDir}/steam.exe`,
launchOption: tool.state.launchOptions[tool.state.launchIndex].option || "",
server: event.payload || "worldwide",
})
addToast({ title: "启动国服成功" })
// 验证路径
if (!steamStore.state.steamDir || !steamStore.state.steamDirValid) {
addToast({
title: "Steam 路径无效,请先配置路径",
color: "warning"
})
return
}
try {
await invoke("launch_game", {
steamPath: `${steamStore.state.steamDir}\\steam.exe`,
launchOption: toolStore.state.launchOptions[toolStore.state.launchIndex].option || "",
server: event.payload || "worldwide",
})
addToast({
title: `启动${event.payload === "worldwide" ? "国际服" : "国服"}成功`,
color: "success"
})
} catch (error) {
console.error("启动游戏失败:", error)
addToast({
title: `启动失败: ${error instanceof Error ? error.message : String(error)}`,
color: "danger"
})
}
})
void listen("tray://kill_steam", async () => {
@@ -38,14 +57,23 @@ export default function RootLayout({ children }: { children: React.ReactNode })
})
void listen<number>("tray://set_powerplan", async (event) => {
if (typeof(event.payload) === "number" && event.payload <= 0 && event.payload > 4) return
if (typeof event.payload === "number" && event.payload <= 0 && event.payload > 4) return
await invoke("set_powerplan", { plan: event.payload })
const current = await invoke<number>("get_powerplan")
tool.setPowerPlan(current)
addToast({ title: `电源计划已切换 → ${PowerPlans[current].title}` })
})
})
void listen<number>("tray://set_launch_index", async (event) => {
const index = event.payload
if (typeof index === "number" && index >= 0 && index < toolStore.state.launchOptions.length) {
tool.setLaunchIndex(index)
const optionName = toolStore.state.launchOptions[index].name || `启动项 ${index + 1}`
addToast({ title: `启动项已切换 → ${optionName}` })
}
})
}, [])
// 检测steam路径和游戏路径是否有效
const debounceSteamDir = useDebounce(steam.state.steamDir, {
@@ -73,10 +101,40 @@ export default function RootLayout({ children }: { children: React.ReactNode })
void steam.checkCs2DirValid()
}, [debounceCs2Dir])
useEffect(() => {
if (debounceSteamDirValid) {
if (debounceSteamDirValid && steam.state.steamDir) {
// 安全地获取用户列表(内部已有错误处理)
void steam.getUsers()
// 启动文件监听,添加错误处理
void invoke("start_watch_loginusers", { steamDir: steam.state.steamDir }).catch((error) => {
console.error("启动文件监听失败:", error)
})
}
}, [debounceSteamDirValid])
}, [debounceSteamDirValid, steam.state.steamDir])
// 节流获取用户列表函数3秒间隔trailing模式
const { run: throttledGetUsers } = useThrottleFn(
async () => {
if (steam.state.steamDirValid) {
await steam.getUsers()
}
},
{
wait: 3000,
leading: false,
trailing: true,
}
)
// 监听 loginusers.vdf 文件变化事件
useEffect(() => {
const unlisten = listen("steam://loginusers_changed", async () => {
// 文件变化时使用节流函数重新获取用户列表
throttledGetUsers()
})
return () => {
void unlisten.then((fn) => fn())
}
}, [steam.state.steamDirValid, throttledGetUsers])
return (
<html lang="en">

View File

@@ -17,29 +17,39 @@ export default function Page() {
className="flex flex-col items-center justify-center w-full h-screen gap-6"
data-tauri-drag-region
>
<h1 className="text-4xl font-bold tracking-wide">CS工具</h1>
<p></p>
<h1 className="text-4xl font-bold tracking-wide">CS </h1>
<p className="text-sm text-zinc-500"></p>
<div className="flex flex-col w-full gap-2 p-5 border rounded-lg bg-white/40">
<p>Steam所在文件夹</p>
<input
className="px-2 py-1 mb-2 rounded-lg"
value={steamDir}
onChange={(e) => {
setSteamDir(e.target.value)
steam.setDir(e.target.value)
}}
/>
<p>CS2所在文件夹</p>
<input
className="px-2 py-1 mb-2 rounded-lg"
value={cs2Dir}
onChange={(e) => {
setCs2Dir(e.target.value)
steam.setCsDir(e.target.value)
}}
/>
<p>64SteamID{steam.currentUser()?.steam_id64}</p>
<div className="flex flex-col w-full max-w-2xl gap-4 p-5 border rounded-lg bg-white/40 dark:bg-zinc-800/40">
<div className="space-y-2">
<p className="text-sm font-semibold">Steam </p>
<input
className="w-full px-3 py-2 rounded-lg bg-white dark:bg-zinc-700 border border-zinc-300 dark:border-zinc-600"
placeholder="请输入 Steam 安装路径"
value={steamDir}
onChange={(e) => {
setSteamDir(e.target.value)
steam.setDir(e.target.value)
}}
/>
</div>
<div className="space-y-2">
<p className="text-sm font-semibold">CS2 </p>
<input
className="w-full px-3 py-2 rounded-lg bg-white dark:bg-zinc-700 border border-zinc-300 dark:border-zinc-600"
placeholder="请输入 CS2 安装路径"
value={cs2Dir}
onChange={(e) => {
setCs2Dir(e.target.value)
steam.setCsDir(e.target.value)
}}
/>
</div>
{steam.currentUser()?.steam_id64 && (
<p className="text-xs text-zinc-500">
64 Steam ID{steam.currentUser()?.steam_id64}
</p>
)}
</div>
</div>
)

View File

@@ -4,6 +4,7 @@ import { ToastProvider } from "@heroui/toast"
import { platform } from "@tauri-apps/plugin-os"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { useEffect, useState } from "react"
import { AuthProvider } from "@/components/auth/AuthProvider"
export default function Providers({ children }: { children: React.ReactNode }) {
const [os, setOs] = useState("windows")
@@ -13,11 +14,11 @@ export default function Providers({ children }: { children: React.ReactNode }) {
return (
<HeroUIProvider
className={cn("h-full bg-zinc-200/90 dark:bg-zinc-900", os === "macos" && "rounded-lg")}
className={cn("h-full bg-zinc-100/95 dark:bg-zinc-900/95", os === "macos" && "rounded-lg")}
>
<NextThemesProvider attribute="class" defaultTheme="light">
<ToastProvider toastOffset={10} placement="top-center" toastProps={{ timeout: 3000 }} />
{children}
<AuthProvider>{children}</AuthProvider>
</NextThemesProvider>
</HeroUIProvider>
)

View File

@@ -6,7 +6,7 @@ import { useCallback, useState } from "react"
export default function Page() {
const [buttonDesc, setButtonDesc] = useState<string>(
"Waiting to be clicked. This calls 'on_button_clicked' from Rust.",
"等待点击。这将调用 Rust 中的 'on_button_clicked' 命令。",
)
const onButtonClick = () => {
invoke<string>("on_button_clicked")
@@ -14,7 +14,7 @@ export default function Page() {
setButtonDesc(value)
})
.catch(() => {
setButtonDesc("Failed to invoke Rust command 'on_button_clicked'")
setButtonDesc("调用 Rust 命令 'on_button_clicked' 失败")
})
}
@@ -27,7 +27,7 @@ export default function Page() {
<div className="flex flex-col">
<main className="flex flex-col items-center justify-center flex-1 py-8">
<h1 className="m-0 text-6xl text-center">
Welcome to{" "}
使{" "}
<a
href="https://nextjs.org"
target="_blank"
@@ -39,7 +39,7 @@ export default function Page() {
</h1>
<p className="my-12 text-2xl leading-9 text-center">
Get started by editing{" "}
{" "}
<code className="p-2 font-mono text-xl bg-gray-200 rounded-md">
src/pages/index.tsx
</code>
@@ -48,7 +48,7 @@ export default function Page() {
<div className="flex flex-wrap items-center justify-center max-w-3xl">
<CardButton
onClick={onButtonClick}
title="Tauri Invoke"
title="Tauri 调用"
description={buttonDesc}
/>
</div>

View File

@@ -0,0 +1,142 @@
"use client"
import { Button, Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Avatar, Spinner } from "@heroui/react"
import { User, Logout, Login, AddUser } from "@icon-park/react"
import { useAuthStore } from "@/store/auth"
import { openLoginPage, openSignupPage } from "@/utils/auth"
import { useDisclosure } from "@heroui/react"
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react"
export function AuthButton() {
const { state, signOut } = useAuthStore()
const { isOpen, onOpen, onOpenChange } = useDisclosure()
const handleLogin = async () => {
await openLoginPage()
}
const handleSignup = async () => {
await openSignupPage()
}
const handleSignOut = async () => {
await signOut()
onOpenChange()
}
if (state.isLoading) {
return (
<Button
isIconOnly
variant="light"
size="sm"
className="px-2 py-0 rounded transition duration-150 hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95 cursor-pointer"
>
<Spinner size="sm" />
</Button>
)
}
if (state.isAuthenticated && state.user) {
return (
<>
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button
isIconOnly
variant="light"
size="sm"
className="px-2 py-0 rounded transition duration-150 hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95 cursor-pointer [&>*]:cursor-pointer"
>
<Avatar
src={state.user.user_metadata?.avatar_url}
name={state.user.email || state.user.id}
size="sm"
className="w-6 h-6 cursor-pointer"
/>
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="用户菜单">
<DropdownItem
key="profile"
startContent={<User size={16} />}
textValue="用户信息"
>
<div className="flex flex-col">
<span className="text-sm font-medium">{state.user.email}</span>
{state.user.user_metadata?.name && (
<span className="text-xs text-zinc-500">{state.user.user_metadata.name}</span>
)}
</div>
</DropdownItem>
<DropdownItem
key="logout"
startContent={<Logout size={16} />}
color="danger"
onPress={onOpen}
>
退
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">退</ModalHeader>
<ModalBody>
<p>退</p>
</ModalBody>
<ModalFooter>
<Button color="default" variant="light" onPress={onClose}>
</Button>
<Button
color="danger"
onPress={() => {
handleSignOut()
}}
>
退
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
)
}
return (
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button
isIconOnly
variant="light"
size="sm"
className="px-2 py-0 rounded transition duration-150 hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95 cursor-pointer [&>*]:cursor-pointer"
>
<User size={16} className="cursor-pointer" />
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="登录菜单">
<DropdownItem
key="login"
startContent={<Login size={16} />}
onPress={handleLogin}
>
</DropdownItem>
<DropdownItem
key="signup"
startContent={<AddUser size={16} />}
onPress={handleSignup}
>
</DropdownItem>
</DropdownMenu>
</Dropdown>
)
}

View File

@@ -0,0 +1,76 @@
"use client"
import { onOpenUrl } from "@tauri-apps/plugin-deep-link"
import { useEffect } from "react"
import { useAuthStore } from "@/store/auth"
import { handleAuthCallback } from "@/utils/auth"
import { createClient } from "@/utils/supabase/client"
import { addToast } from "@heroui/react"
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { checkSession, setSession, setLoading } = useAuthStore()
// 初始化时检查现有会话
useEffect(() => {
void checkSession()
}, [checkSession])
// 监听 deep-link 认证回调
useEffect(() => {
const unlisten = onOpenUrl(async (urls) => {
if (urls.length === 0) return
const url = urls[0]
if (!url.startsWith("cstb://auth")) return
setLoading(true)
try {
const session = await handleAuthCallback(url)
if (session) {
setSession(session)
addToast({
title: "登录成功",
description: `欢迎回来,${session.user.email || session.user.id}`,
})
} else {
addToast({
title: "登录失败",
description: "无法验证登录信息,请重试",
severity: "danger",
})
}
} catch (error) {
console.error("Auth callback error:", error)
addToast({
title: "登录失败",
description: error instanceof Error ? error.message : "未知错误",
severity: "danger",
})
} finally {
setLoading(false)
}
})
return () => {
void unlisten.then((fn) => fn())
}
}, [setSession, setLoading])
// 监听 Supabase 认证状态变化
useEffect(() => {
const supabase = createClient()
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session)
})
return () => {
subscription.unsubscribe()
}
}, [setSession])
return <>{children}</>
}

View File

@@ -16,7 +16,7 @@ const RoundedButton = ({
return (
<button
type="button"
className="flex items-center justify-center px-3 py-1 transition rounded-full select-none min-w-fit active:scale-95 hover:bg-black/10 text-zinc-700 dark:text-zinc-100 bg-black/5 dark:bg-white/5"
className="flex items-center justify-center px-3 py-1 transition rounded-full cursor-pointer select-none min-w-fit active:scale-95 hover:bg-black/10 text-zinc-700 dark:text-zinc-100 bg-black/5 dark:bg-white/5"
{...props}
>
{children}
@@ -51,10 +51,10 @@ const CommonDir = () => {
await invoke("open_path", {
path: steam.cs2BaseDir(),
})
addToast({ title: "CS2游戏目录" })
addToast({ title: "CS2目录" })
}}
>
CS2游戏目录
CS2目录
</RoundedButton>
<RoundedButton
onClick={async () => {

View File

@@ -20,29 +20,61 @@ const FastLaunch = () => {
<div className="flex gap-2">
<Button
size="md"
onPress={() => {
void invoke("launch_game", {
steamPath: `${steam.state.steamDir}/steam.exe`,
launchOption: tool.state.launchOptions[tool.state.launchIndex].option || "",
server: "perfectworld",
})
addToast({ title: "启动国服成功" })
onPress={async () => {
// 验证路径
if (!steam.state.steamDir || !steam.state.steamDirValid) {
addToast({
title: "Steam 路径无效,请先配置路径",
color: "warning"
})
return
}
try {
await invoke("launch_game", {
steamPath: `${steam.state.steamDir}/steam.exe`,
launchOption: tool.state.launchOptions[tool.state.launchIndex].option || "",
server: "perfectworld",
})
addToast({ title: "启动国服成功", color: "success" })
} catch (error) {
console.error("启动游戏失败:", error)
addToast({
title: `启动失败: ${error instanceof Error ? error.message : String(error)}`,
color: "danger"
})
}
}}
className="px-5 font-medium py-1.5 transition bg-red-200 dark:bg-red-900/60 rounded-full select-none"
className="px-4 py-1 font-medium transition bg-red-200 rounded-full select-none dark:bg-red-900/60"
>
</Button>
<Button
size="md"
onPress={() => {
void invoke("launch_game", {
steamPath: `${steam.state.steamDir}/steam.exe`,
launchOption: tool.state.launchOptions[tool.state.launchIndex].option || "",
server: "worldwide",
})
addToast({ title: "启动国际服成功" })
onPress={async () => {
// 验证路径
if (!steam.state.steamDir || !steam.state.steamDirValid) {
addToast({
title: "Steam 路径无效,请先配置路径",
color: "warning"
})
return
}
try {
await invoke("launch_game", {
steamPath: `${steam.state.steamDir}/steam.exe`,
launchOption: tool.state.launchOptions[tool.state.launchIndex].option || "",
server: "worldwide",
})
addToast({ title: "启动国际服成功", color: "success" })
} catch (error) {
console.error("启动游戏失败:", error)
addToast({
title: `启动失败: ${error instanceof Error ? error.message : String(error)}`,
color: "danger"
})
}
}}
className="px-5 font-medium py-1.5 transition bg-orange-200 dark:bg-orange-900/60 rounded-full select-none"
className="px-4 py-1 font-medium transition bg-orange-200 rounded-full select-none dark:bg-orange-900/60"
>
</Button>

View File

@@ -19,7 +19,7 @@ const ForceQuit = () => {
await invoke("kill_steam")
addToast({ title: "已关闭Steam" })
}}
className="px-5 font-medium py-1.5 transition bg-blue-200 dark:bg-blue-900/60 rounded-full select-none"
className="px-4 py-1 font-medium transition bg-blue-200 rounded-full select-none dark:bg-blue-900/60"
>
Steam
</Button>
@@ -29,7 +29,7 @@ const ForceQuit = () => {
await invoke("kill_game")
addToast({ title: "已关闭CS2" })
}}
className="px-5 font-medium py-1.5 transition bg-orange-200 dark:bg-orange-900/60 rounded-full select-none"
className="px-4 py-1 font-medium transition bg-orange-200 rounded-full select-none dark:bg-orange-900/60"
>
CS2
</Button>

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="md"
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="md"
type="number"
placeholder="高"
value={resolutionHeight}
onValueChange={(val) => fpsTest.setResolution(resolutionWidth, val)}
isDisabled={
isResolutionGroupEnabled
? isMonitoring
: !isResolutionEnabled || isMonitoring
}
className="w-20"
/>
</div>
<Button
size="md"
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,94 @@
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>
</>
)
}

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,156 @@
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 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={40}></TableColumn>
<TableColumn width={100}></TableColumn>
<TableColumn width={60}></TableColumn>
<TableColumn width={60}>P1低帧</TableColumn>
<TableColumn width={100}>CPU</TableColumn>
<TableColumn minWidth={100}>GPU</TableColumn>
<TableColumn width={80}></TableColumn>
<TableColumn width={80}></TableColumn>
<TableColumn minWidth={80}></TableColumn>
<TableColumn width={100}></TableColumn>
<TableColumn minWidth={80}></TableColumn>
<TableColumn minWidth={80}>BIOS版本</TableColumn>
<TableColumn minWidth={40}></TableColumn>
<TableColumn width={60} 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 whitespace-nowrap">
{result.videoSetting
? `${result.videoSetting.defaultres}x${result.videoSetting.defaultresheight}`
: "N/A"}
</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-[170px]">
<Tooltip
content={result.hardwareInfo?.cpu || "N/A"}
delay={300}
placement="top"
>
<div className="truncate cursor-help">
{result.hardwareInfo?.cpu || "N/A"}
</div>
</Tooltip>
</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 whitespace-nowrap">
{result.hardwareInfo?.memorySpeed
? `${result.hardwareInfo.memorySpeed}MHz`
: "N/A"}
</TableCell>
<TableCell className="text-xs">
<div className="truncate">{result.hardwareInfo?.os || "N/A"}</div>
</TableCell>
<TableCell className="text-xs">
<Tooltip
content={result.hardwareInfo?.motherboardModel || "N/A"}
delay={500}
placement="top"
>
<div className="truncate cursor-help">
{result.hardwareInfo?.motherboardModel || "N/A"}
</div>
</Tooltip>
</TableCell>
<TableCell className="text-xs">
<div className="truncate">{result.hardwareInfo?.motherboardVersion || "N/A"}</div>
</TableCell>
<TableCell className="text-xs">
<div className="truncate">{result.hardwareInfo?.biosVersion || "N/A"}</div>
</TableCell>
<TableCell className="text-xs min-w-fit">
<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,3 @@
// 使用全局游戏状态监控,避免重复检测
export { useGlobalGameMonitor as useGameMonitor } from "@/hooks/useGlobalGameMonitor"

View File

@@ -0,0 +1,41 @@
import { useEffect } from "react"
import { useHardwareStore } from "@/store/hardware"
import type { AllSystemInfo } from "tauri-plugin-system-info-api"
import type {
GpuInfo,
ComputerInfo,
MemoryInfo,
MonitorInfo,
MotherboardInfo,
} from "@/store/hardware"
export interface HardwareInfoWithGpu {
systemInfo: AllSystemInfo | null
gpuInfo: GpuInfo | null
computerInfo: ComputerInfo | null
memoryInfo: MemoryInfo[]
monitorInfo: MonitorInfo[]
motherboardInfo: MotherboardInfo | null
}
export function useHardwareInfo() {
const { state, fetchHardwareInfo } = useHardwareStore()
useEffect(() => {
// 如果数据不存在则加载数据store 初始化时已经加载,这里只是确保)
if (!state.allSysData) {
void fetchHardwareInfo()
}
}, []) // 只在组件挂载时执行一次
// 将 store 中的数据转换为兼容的格式
return {
systemInfo: state.allSysData,
gpuInfo: state.gpuInfo,
computerInfo: state.computerInfo,
memoryInfo: state.memoryInfo,
monitorInfo: state.monitorInfo,
motherboardInfo: state.motherboardInfo,
} as HardwareInfoWithGpu
}

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,287 @@
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,
memoryManufacturer: null,
memorySpeed: null,
memoryDefaultSpeed: null,
gpu: null,
monitor: null,
monitorManufacturer: null,
monitorModel: null,
motherboardModel: null,
motherboardVersion: null,
biosVersion: 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,
memoryManufacturer: null,
memorySpeed: null,
memoryDefaultSpeed: null,
gpu: null,
monitor: null,
monitorManufacturer: null,
monitorModel: null,
motherboardModel: null,
motherboardVersion: null,
biosVersion: 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,235 @@
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)",
"内存频率(MHz)",
"主板型号",
"主板版本",
"BIOS版本",
"分辨率",
"视频设置",
"备注",
"光影质量",
"纹理过滤质量",
"多重采样抗锯齿",
"CMAA抗锯齿",
"阴影质量",
"动态阴影",
"纹理细节",
"粒子细节",
"环境光遮蔽",
"HDR细节",
"FSR细节",
]
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.hardwareInfo?.memorySpeed ? result.hardwareInfo.memorySpeed.toString() : "N/A",
`"${result.hardwareInfo?.motherboardModel || "N/A"}"`,
`"${result.hardwareInfo?.motherboardVersion || "N/A"}"`,
`"${result.hardwareInfo?.biosVersion || "N/A"}"`,
result.videoSetting
? `${result.videoSetting.defaultres}x${result.videoSetting.defaultresheight}`
: "N/A",
`"${formatVideoSettingSummary(result.videoSetting)}"`,
`"${result.note || ""}"`,
result.videoSetting?.shaderquality || "N/A",
result.videoSetting?.r_texturefilteringquality || "N/A",
result.videoSetting?.msaa_samples || "N/A",
result.videoSetting?.r_csgo_cmaa_enable || "N/A",
result.videoSetting?.videocfg_shadow_quality || "N/A",
result.videoSetting?.videocfg_dynamic_shadows || "N/A",
result.videoSetting?.videocfg_texture_detail || "N/A",
result.videoSetting?.videocfg_particle_detail || "N/A",
result.videoSetting?.videocfg_ao_detail || "N/A",
result.videoSetting?.videocfg_hdr_detail || "N/A",
result.videoSetting?.videocfg_fsr_detail || "N/A",
]
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)",
"内存频率(MHz)",
"主板型号",
"主板版本",
"BIOS版本",
"分辨率",
"视频设置",
"备注",
"光影质量",
"纹理过滤质量",
"多重采样抗锯齿",
"CMAA抗锯齿",
"阴影质量",
"动态阴影",
"纹理细节",
"粒子细节",
"环境光遮蔽",
"HDR细节",
"FSR细节",
]
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.hardwareInfo?.memorySpeed ? result.hardwareInfo.memorySpeed.toString() : "N/A",
`"${result.hardwareInfo?.motherboardModel || "N/A"}"`,
`"${result.hardwareInfo?.motherboardVersion || "N/A"}"`,
`"${result.hardwareInfo?.biosVersion || "N/A"}"`,
result.videoSetting
? `${result.videoSetting.defaultres}x${result.videoSetting.defaultresheight}`
: "N/A",
`"${formatVideoSettingSummary(result.videoSetting)}"`,
`"${result.note || ""}"`,
result.videoSetting?.shaderquality || "N/A",
result.videoSetting?.r_texturefilteringquality || "N/A",
result.videoSetting?.msaa_samples || "N/A",
result.videoSetting?.r_csgo_cmaa_enable || "N/A",
result.videoSetting?.videocfg_shadow_quality || "N/A",
result.videoSetting?.videocfg_dynamic_shadows || "N/A",
result.videoSetting?.videocfg_texture_detail || "N/A",
result.videoSetting?.videocfg_particle_detail || "N/A",
result.videoSetting?.videocfg_ao_detail || "N/A",
result.videoSetting?.videocfg_hdr_detail || "N/A",
result.videoSetting?.videocfg_fsr_detail || "N/A",
]
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(),
}
}

View File

@@ -1,13 +1,15 @@
import { useToolStore } from "@/store/tool"
import { Plus, SettingConfig, Switch } from "@icon-park/react"
import { LaunchOption as iLaunchOption, useToolStore } from "@/store/tool"
import { Badge, Close, Edit, Plus, Save, SettingConfig, Switch } from "@icon-park/react"
import { useEffect, useState } from "react"
import { Card, CardBody, CardHeader, CardIcon, CardTool } from "../window/Card"
import { ToolButton } from "../window/ToolButton"
import { Tooltip } from "@heroui/react"
import { Button, Input, Tooltip } from "@heroui/react"
import { remove } from "@tauri-apps/plugin-fs"
const LaunchOption = () => {
const tool = useToolStore()
const [launchOpt, setLaunchOpt] = useState(tool.state.launchOptions[tool.state.launchIndex] || "")
const [editMode, setEditMode] = useState(false)
useEffect(() => {
setLaunchOpt(tool.state.launchOptions[tool.state.launchIndex] || "")
@@ -19,22 +21,71 @@ const LaunchOption = () => {
<CardIcon>
<SettingConfig />
</CardIcon>
<CardTool>
{tool.state.launchOptions.map((option, index) => (
<ToolButton key={index} onClick={() => tool.setLaunchIndex(index)}>
{option.name || index + 1}
</ToolButton>
))}
<CardTool className="">
{tool.state.launchOptions.map((option, index) =>
editMode ? (
<Tooltip
key={index}
radius="sm"
size="sm"
offset={-4}
crossOffset={18}
delay={300}
closeDelay={100}
placement="top-end"
color="foreground"
className="p-0 rounded-md bg-opacity-85"
content={
<button
className="p-1 text-small"
onClick={() => {
tool.removeLaunchOption(index)
}}
>
<Close size={12} />
</button>
}
>
<Input
value={option.name}
onValueChange={(value: string) => {
tool.setLaunchOption({ ...option, name: value } as iLaunchOption, index)
}}
variant="bordered"
size="sm"
placeholder="名称"
fullWidth={false}
/>
</Tooltip>
) : (
<ToolButton key={index} onClick={() => tool.setLaunchIndex(index)} selected={index === tool.state.launchIndex}>
{option.name || index + 1}
</ToolButton>
)
)}
<ToolButton onClick={() => tool.addLaunchOption({ option: "", name: "" })}>
<Plus />
</ToolButton>
<Tooltip content="功能测试中,尚未实装" showArrow={true} delay={300}>
{/* <Tooltip content="功能测试中,尚未实装" showArrow={true} delay={300}>
<ToolButton>
<Switch />
切换模式
</ToolButton>
</Tooltip>
</Tooltip> */}
<ToolButton onClick={() => setEditMode(!editMode)}>
{editMode ? (
<>
<Save />
</>
) : (
<>
<Edit />
</>
)}
</ToolButton>
</CardTool>
</CardHeader>
<CardBody>

View File

@@ -77,6 +77,7 @@ const PowerPlan = () => {
radius="full"
fullWidth
defaultSelectedKey="0"
className="gap-0"
>
{PowerPlans.slice(1).map((item) => (
<Tab key={item.id} title={item.title}></Tab>

View File

@@ -40,7 +40,6 @@ export function Prepare() {
const init = async () => {
await steam.store.start()
await app.store.start()
if (!app.state.inited) await autoGetPaths(false)
setInited(true)
}
void init()
@@ -66,6 +65,7 @@ export function Prepare() {
setLoading(false)
}, 1200)
}
// router.push("/home")
}, [inited])
const handleSelectSteamDir = async () => {

View File

@@ -1,13 +1,41 @@
import { Refresh, User } from "@icon-park/react"
import { Refresh, User, FolderFocusOne, Login, Check, List, GridFour } from "@icon-park/react"
import { Card, CardBody, CardHeader, CardIcon, CardTool } from "../window/Card"
import { addToast, Button, Chip } from "@heroui/react"
import { addToast, Button, Chip, Tabs, Tab, Tooltip } from "@heroui/react"
import { useSteamStore } from "@/store/steam"
import { useAppStore } from "@/store/app"
import { ToolButton } from "../window/ToolButton"
import { useAutoAnimate } from "@formkit/auto-animate/react"
import { invoke } from "@tauri-apps/api/core"
import { writeText } from "@tauri-apps/plugin-clipboard-manager"
import path from "path"
import { useState, useMemo } from "react"
import type { SteamUser } from "@/types/steam"
type ViewMode = "card" | "list" | "list-large"
const SteamUsers = ({ className }: { className?: string }) => {
const steam = useSteamStore()
const app = useAppStore()
const [parent /* , enableAnimations */] = useAutoAnimate(/* optional config */)
const viewMode = app.state.steamUsersViewMode as ViewMode
const [mockUsers, setMockUsers] = useState<SteamUser[] | null>(null)
// 生成模拟用户数据
const generateMockUsers = (): SteamUser[] => {
return Array.from({ length: 200 }, (_, i) => ({
steam_id64: BigInt(76561198000000000 + i),
steam_id32: 1000000 + i,
account_name: `mockuser${i + 1}`,
persona_name: `模拟用户 ${i + 1}`,
recent: i < 3 ? 1 : 0,
avatar: "",
}))
}
// 使用模拟数据或真实数据
const displayUsers = useMemo(() => {
return mockUsers || steam.state.users
}, [mockUsers, steam.state.users])
const getUsers = async (toast?: boolean) => {
if (!steam.state.steamDirValid) {
@@ -19,62 +47,382 @@ const SteamUsers = ({ className }: { className?: string }) => {
if (toast) addToast({ title: `已获取Steam用户` })
}
const handleMockToggle = () => {
if (mockUsers) {
setMockUsers(null)
addToast({ title: "已切换回真实数据" })
} else {
setMockUsers(generateMockUsers())
addToast({ title: "已切换到模拟数据200个用户" })
}
}
// 复制到剪贴板的辅助函数
const handleCopy = async (text: string, label: string) => {
try {
await writeText(text)
addToast({ title: `已复制${label}` })
} catch (error) {
addToast({ title: "复制失败", color: "danger" })
}
}
// 渲染用户项 - 列表-大样式(当前样式)
const renderListLargeItem = (user: SteamUser, id: number, isMock: boolean) => (
<li
key={`${user.account_name}-${id}`}
className="flex gap-2 transition border rounded-lg bg-white/70 border-zinc-200/50 dark:border-white/5 dark:bg-zinc-900/50"
>
<img
src={user.avatar ? `data:image/png;base64,${user.avatar}` : "/logo_square.png"}
alt="avatar"
className="w-20 h-20 rounded-l-lg"
draggable="false"
/>
<div className="flex flex-col grow justify-center gap-2 p-0.5">
<h3
className="text-xl font-semibold cursor-pointer hover:underline"
onClick={() => handleCopy(user.persona_name, "用户名")}
>
{user.persona_name}
</h3>
<div className="flex gap-2">
<Chip
size="sm"
className="cursor-pointer bg-zinc-200 dark:bg-zinc-800 hover:opacity-80"
onClick={() => handleCopy(user.account_name, "账号名")}
>
{user.account_name}
</Chip>
<Chip
size="sm"
className="cursor-pointer bg-zinc-200 dark:bg-zinc-800 hover:opacity-80"
onClick={() => handleCopy(user.steam_id32.toString(), "Steam32位ID")}
>
{user.steam_id32}
</Chip>
<Chip
size="sm"
className="cursor-pointer bg-zinc-200 dark:bg-zinc-800 hover:opacity-80"
onClick={() => handleCopy(user.steam_id64.toString(), "Steam64位ID")}
>
{user.steam_id64.toString()}
</Chip>
{user.recent > 0 && (
<Chip size="sm" color="primary">
</Chip>
)}
</div>
</div>
<div className="flex items-end gap-2 p-2 shrink-0">
<Button
size="sm"
onPress={async () => {
if (!steam.state.steamDirValid) {
addToast({ title: "Steam路径不可用", color: "warning" })
return
}
await invoke("open_path", {
path: path.resolve(
steam.state.steamDir,
"userdata",
user.steam_id32.toString(),
"730",
"local",
"cfg"
),
})
addToast({ title: "个人CFG" })
}}
className="gap-1"
>
CFG
</Button>
<Button
size="sm"
onPress={() => steam.switchLoginUser(id)}
className="gap-1"
isDisabled={isMock}
>
</Button>
<Button
size="sm"
onPress={() => steam.selectUser(id)}
className="gap-1"
isDisabled={isMock}
>
<Check size={14} />
</Button>
</div>
</li>
)
// 渲染用户项 - 列表样式(缩小版)
const renderListItem = (user: SteamUser, id: number, isMock: boolean) => (
<li
key={`${user.account_name}-${id}`}
className="flex gap-2 transition border rounded-lg bg-white/70 border-zinc-200/50 dark:border-white/5 dark:bg-zinc-900/50"
>
<img
src={user.avatar ? `data:image/png;base64,${user.avatar}` : "/logo_square.png"}
alt="avatar"
className="rounded-l-lg w-14 h-14"
draggable="false"
/>
<div className="flex flex-col grow justify-center gap-1.5 p-1.5">
<div className="flex items-center gap-2">
<h3
className="text-base font-semibold cursor-pointer hover:underline"
onClick={() => handleCopy(user.persona_name, "用户名")}
>
{user.persona_name}
</h3>
{user.recent > 0 && (
<Chip size="sm" color="primary" className="text-[10px] px-1 h-5">
</Chip>
)}
</div>
<div className="flex gap-1.5 flex-wrap items-center">
<span
className="text-xs cursor-pointer text-zinc-600 dark:text-zinc-400 hover:underline hover:decoration-zinc-300 dark:hover:decoration-zinc-600"
onClick={() => handleCopy(user.account_name, "账号名")}
>
{user.account_name}
</span>
<span
className="text-xs cursor-pointer text-zinc-600 dark:text-zinc-400 hover:underline hover:decoration-zinc-300 dark:hover:decoration-zinc-600"
onClick={() => handleCopy(user.steam_id32.toString(), "Steam32位ID")}
>
{user.steam_id32}
</span>
<span
className="text-xs cursor-pointer text-zinc-600 dark:text-zinc-400 hover:underline hover:decoration-zinc-300 dark:hover:decoration-zinc-600"
onClick={() => handleCopy(user.steam_id64.toString(), "Steam64位ID")}
>
{user.steam_id64.toString()}
</span>
</div>
</div>
<div className="flex items-center gap-2 p-1.5 mr-2">
<Button
size="sm"
radius="full"
onPress={async () => {
if (!steam.state.steamDirValid) {
addToast({ title: "Steam路径不可用", color: "warning" })
return
}
await invoke("open_path", {
path: path.resolve(
steam.state.steamDir,
"userdata",
user.steam_id32.toString(),
"730",
"local",
"cfg"
),
})
addToast({ title: "个人CFG" })
}}
className="gap-1 px-3 h-7"
>
CFG
</Button>
<Button
size="sm"
radius="full"
onPress={() => steam.switchLoginUser(id)}
className="gap-1 px-3 h-7"
isDisabled={isMock}
>
</Button>
<Button
size="sm"
radius="full"
onPress={() => steam.selectUser(id)}
className="gap-1 px-3 h-7"
isDisabled={isMock}
>
<Check size={12} />
</Button>
</div>
</li>
)
// 渲染用户项 - 卡片样式grid布局
const renderCardItem = (user: SteamUser, id: number, isMock: boolean) => (
<li
key={`${user.account_name}-${id}`}
className="flex flex-col gap-2 p-3 transition border rounded-lg bg-white/70 border-zinc-200/50 dark:border-white/5 dark:bg-zinc-900/50 h-fit"
>
<div className="flex items-center gap-2">
<img
src={user.avatar ? `data:image/png;base64,${user.avatar}` : "/logo_square.png"}
alt="avatar"
className="w-12 h-12 rounded-lg shrink-0"
draggable="false"
/>
<div className="flex flex-col min-w-0 grow">
<div className="flex items-center gap-2">
<h3
className="text-sm font-semibold leading-relaxed truncate cursor-pointer hover:underline"
onClick={() => handleCopy(user.persona_name, "用户名")}
>
{user.persona_name}
</h3>
{user.recent > 0 && (
<Chip size="sm" color="primary" className="text-[10px] px-1 h-5 shrink-0">
</Chip>
)}
</div>
<p
className="text-xs leading-normal truncate cursor-pointer text-zinc-500 hover:underline"
onClick={() => handleCopy(user.account_name, "账号名")}
>
{user.account_name}
</p>
</div>
</div>
<div className="flex gap-1.5 flex-wrap">
<Tooltip content="Steam32位id" showArrow={true} delay={300}>
<span
className="text-xs cursor-pointer text-zinc-500 dark:text-zinc-400 hover:underline"
onClick={() => handleCopy(user.steam_id32.toString(), "Steam32位ID")}
>
{user.steam_id32}
</span>
</Tooltip>
<Tooltip content="Steam64位id" showArrow={true} delay={300}>
<span
className="text-xs cursor-pointer text-zinc-500 dark:text-zinc-400 hover:underline"
onClick={() => handleCopy(user.steam_id64.toString(), "Steam64位ID")}
>
{user.steam_id64.toString()}
</span>
</Tooltip>
</div>
<div className="flex gap-1.5 mt-auto">
<Button
size="sm"
radius="full"
onPress={async () => {
if (!steam.state.steamDirValid) {
addToast({ title: "Steam路径不可用", color: "warning" })
return
}
await invoke("open_path", {
path: path.resolve(
steam.state.steamDir,
"userdata",
user.steam_id32.toString(),
"730",
"local",
"cfg"
),
})
addToast({ title: "个人CFG" })
}}
className="flex h-7"
>
CFG
</Button>
<Button
size="sm"
radius="full"
onPress={() => steam.switchLoginUser(id)}
className="flex h-7"
isDisabled={isMock}
>
</Button>
<Button
size="sm"
radius="full"
onPress={() => steam.selectUser(id)}
className="flex h-7"
isDisabled={isMock}
>
<Check size={10} />
</Button>
</div>
</li>
)
// 根据视图模式渲染用户项
const renderUserItem = (user: SteamUser, id: number) => {
const isMock = !!mockUsers
const realIndex = mockUsers
? -1
: steam.state.users.findIndex((u) => u.account_name === user.account_name)
const index = realIndex >= 0 ? realIndex : id
switch (viewMode) {
case "card":
return renderCardItem(user, index, isMock)
case "list":
return renderListItem(user, index, isMock)
case "list-large":
default:
return renderListLargeItem(user, index, isMock)
}
}
return (
<Card /* className={cn("max-w-96", className)} */>
<Card className="flex flex-col h-full overflow-hidden">
<CardHeader>
<CardIcon>
<User /> Steam用户
</CardIcon>
<CardTool>
<Tooltip content="切换样式" showArrow={true} delay={300}>
<Tabs
selectedKey={viewMode}
onSelectionChange={(key) => app.setSteamUsersViewMode(key as ViewMode)}
size="sm"
radius="full"
classNames={{
base: "min-w-0",
tabList: "gap-0 p-0",
tab: "min-w-0 px-2",
tabContent: "text-xs",
}}
>
<Tab key="card" title={<GridFour size={14} />} />
<Tab key="list" title={<List size={14} />} />
<Tab key="list-large" title={<List size={16} />} />
</Tabs>
</Tooltip>
{/* <ToolButton onClick={handleMockToggle}>{mockUsers ? "真实" : "模拟"}</ToolButton> */}
<ToolButton onClick={() => getUsers(true)}>
<Refresh />
</ToolButton>
</CardTool>
</CardHeader>
<CardBody>
<ul className="flex flex-col gap-3 mt-1" ref={parent}>
{steam.state.users.map((user, id) => (
<li
key={user.account_name}
className="flex gap-2 transition rounded-lg bg-zinc-50 dark:bg-zinc-900"
>
<img
src={user.avatar ? `data:image/png;base64,${user.avatar}` : "/logo_square.png"}
alt="avatar"
className="w-20 h-20 rounded-l-lg"
draggable="false"
/>
<div className="flex flex-col flex-grow justify-center gap-2 p-0.5">
<h3 className="text-xl font-semibold">{user.persona_name}</h3>
<div className="flex gap-2">
<Chip size="sm" className="bg-zinc-200 dark:bg-zinc-800">
{user.account_name}
</Chip>
<Chip size="sm" className="bg-zinc-200 dark:bg-zinc-800">
{user.steam_id32}
</Chip>
<Chip size="sm" className="bg-zinc-200 dark:bg-zinc-800">
{user.steam_id64.toString()}
</Chip>
{user.recent > 0 && (
<Chip size="sm" color="primary">
</Chip>
)}
</div>
</div>
<div className="flex items-end gap-2 p-2">
<Button size="sm" onPress={() => steam.switchLoginUser(id)}>
</Button>
<Button size="sm" onPress={() => steam.selectUser(id)}>
</Button>
</div>
</li>
))}
</ul>
<CardBody className="flex-1 min-h-0 overflow-hidden">
{viewMode === "card" ? (
<ul
className="grid grid-cols-2 gap-3 mt-1 overflow-y-auto rounded-lg auto-rows-max md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 pb-11 hide-scrollbar"
ref={parent}
>
{displayUsers.map((user, id) => renderUserItem(user, id))}
</ul>
) : (
<ul
className="flex flex-col h-full gap-3 mt-1 overflow-y-auto rounded-lg pb-11 hide-scrollbar"
ref={parent}
>
{displayUsers.map((user, id) => renderUserItem(user, id))}
</ul>
)}
</CardBody>
</Card>
)

View File

@@ -0,0 +1,339 @@
"use client"
import { useState, useEffect } from "react"
import {
Button,
CircularProgress,
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
} from "@heroui/react"
import { Download, Refresh, FileText, Close, Check } from "@icon-park/react"
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
import { relaunch } from "@tauri-apps/plugin-process"
import { addToast } from "@heroui/react"
import { useAppStore, type UpdateInfo } from "@/store/app"
import { MarkdownRender } from "@/components/markdown"
interface UpdateCheckerProps {
customEndpoint?: string
includePrerelease?: boolean
useCdn?: boolean
}
export function UpdateChecker({
customEndpoint,
includePrerelease = false,
useCdn = true,
}: UpdateCheckerProps) {
const app = useAppStore()
const [checking, setChecking] = useState(false)
// 从 store 读取状态
const updateInfo = app.state.updateInfo
const downloading = app.state.downloading
const downloadProgress = app.state.downloadProgress
const downloadCompleted = app.state.downloadCompleted
const {
isOpen: isChangelogOpen,
onOpen: onChangelogOpen,
onOpenChange: onChangelogOpenChange,
} = useDisclosure()
// 监听下载进度事件
useEffect(() => {
const unlisten = listen<number>("update-download-progress", (event) => {
const progress = event.payload
app.setDownloadProgress(progress)
// 如果进度达到 100%,标记下载完成
if (progress === 100) {
app.setDownloading(false)
app.setDownloadCompleted(true)
}
})
return () => {
unlisten.then((fn) => fn())
}
}, [app.setDownloadProgress, app.setDownloading, app.setDownloadCompleted])
// 检查更新
const handleCheckUpdate = async () => {
setChecking(true)
app.setUpdateInfo(null)
app.setDownloadProgress(0)
app.setDownloading(false)
app.setDownloadCompleted(false)
try {
// 根据是否包含测试版来选择不同的 endpoint
// 如果提供了 customEndpoint优先使用否则根据 includePrerelease 动态选择
let endpoint: string | null = null
if (customEndpoint) {
// 如果提供了 customEndpoint仍然使用它用于特殊场景
endpoint = customEndpoint
} else {
// 根据 includePrerelease 选择对应的 endpoint
if (includePrerelease) {
endpoint = "https://gh-info.okk.cool/repos/plsgo/cstb/releases/latest/pre/tauri"
} else {
endpoint = "https://gh-info.okk.cool/repos/plsgo/cstb/releases/latest/tauri"
}
}
const result = await invoke<UpdateInfo | null>("check_app_update", {
endpoint: endpoint,
includePrerelease: includePrerelease,
useCdn: useCdn,
})
if (result) {
app.setUpdateInfo(result)
// 更新 store 中的更新状态和最新版本号
app.setHasUpdate(true)
app.setLatestVersion(result.version)
addToast({
title: "发现新版本",
description: `版本 ${result.version} 可用`,
color: "success",
})
} else {
// 没有更新,更新 store 状态
app.setHasUpdate(false)
app.setLatestVersion("")
app.setUpdateInfo(null)
addToast({
title: "已是最新版本",
color: "default",
})
}
} catch (error) {
addToast({
title: "检查更新失败",
description: String(error),
color: "danger",
})
} finally {
setChecking(false)
}
}
// 下载更新
const handleDownloadUpdate = async () => {
if (!updateInfo) {
return
}
app.setDownloading(true)
app.setDownloadProgress(0)
app.setDownloadCompleted(false)
try {
// 使用官方 updater 插件,传递 useCdn 参数
await invoke("download_app_update", { useCdn: useCdn })
// 下载完成,标记状态
app.setDownloadProgress(100)
app.setDownloading(false)
app.setDownloadCompleted(true)
addToast({
title: "下载完成",
description: "可以点击安装按钮进行安装",
color: "success",
})
} catch (error) {
const errorMsg = String(error)
if (errorMsg.includes("取消")) {
addToast({
title: "下载已取消",
color: "default",
})
} else {
addToast({
title: "下载失败",
description: errorMsg,
color: "danger",
})
}
app.setDownloadProgress(0)
app.setDownloading(false)
app.setDownloadCompleted(false)
}
}
// 取消下载
const handleCancelDownload = async () => {
try {
await invoke("cancel_download_update")
app.setDownloading(false)
app.setDownloadProgress(0)
app.setDownloadCompleted(false)
addToast({
title: "已取消下载",
color: "default",
})
} catch (error) {
// 静默处理错误
}
}
// 安装更新
const handleInstallUpdate = async () => {
if (!downloadCompleted) {
return
}
try {
addToast({
title: "安装已启动",
description: "应用将自动重启",
color: "success",
})
// 使用官方 updater 插件,不需要传递 installerPath
// 在 Windows 上,应用会自动退出以安装更新
await invoke("install_app_update")
// 在非 Windows 平台上,可能需要手动重启
// Windows 上会自动退出,所以这里不需要 relaunch
// 等待一小段时间确保安装程序启动
await new Promise((resolve) => setTimeout(resolve, 1000))
} catch (error) {
addToast({
title: "安装失败",
description: String(error),
color: "danger",
})
}
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Button
size="sm"
color="primary"
variant="flat"
startContent={checking ? undefined : <Refresh />}
isLoading={checking}
onPress={handleCheckUpdate}
className="w-fit"
>
{checking ? "检查中..." : "检查更新"}
</Button>
{updateInfo && (
<>
{!downloading && !downloadCompleted && (
<Button
size="sm"
color="primary"
variant="flat"
startContent={<Download />}
onPress={handleDownloadUpdate}
className="w-fit"
>
</Button>
)}
{downloading && (
<Button
size="sm"
color="danger"
variant="flat"
startContent={<Close />}
onPress={handleCancelDownload}
className="w-fit"
>
</Button>
)}
{downloadCompleted && (
<Button
size="sm"
color="primary"
variant="flat"
onPress={handleInstallUpdate}
className="w-fit"
>
</Button>
)}
<Button
size="sm"
color="default"
variant="flat"
startContent={<FileText />}
onPress={onChangelogOpen}
className="w-fit"
>
</Button>
{(downloading || downloadProgress > 0 || downloadCompleted) && (
<div className="flex items-center gap-1">
{downloadCompleted ? (
<>
<Check className="text-green-500 dark:text-green-400" size={14} />
<span className="text-xs text-green-500 dark:text-green-400"></span>
</>
) : (
<>
<CircularProgress
aria-label="下载进度"
value={downloadProgress}
color="primary"
size="sm"
showValueLabel={true}
classNames={{
value: "text-xs text-zinc-500 dark:text-zinc-400",
}}
/>
{/* <span className="text-xs text-zinc-500 dark:text-zinc-400">
{downloadProgress}%
</span> */}
</>
)}
</div>
)}
</>
)}
</div>
{/* 更新日志对话框 */}
<Modal isOpen={isChangelogOpen} onOpenChange={onChangelogOpenChange} size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<span> v{updateInfo?.version}</span>
</ModalHeader>
<ModalBody>
{updateInfo?.notes ? (
<div className="text-sm text-zinc-600 dark:text-zinc-400">
<MarkdownRender>{updateInfo.notes}</MarkdownRender>
</div>
) : (
<p className="text-sm text-zinc-500"></p>
)}
</ModalBody>
<ModalFooter>
<Button color="primary" variant="flat" onPress={onClose}>
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</div>
)
}

View File

@@ -1,178 +1,623 @@
import { CloseSmall, Down, Edit, Plus, SettingConfig, Up } from "@icon-park/react"
import { useState } from "react"
import { useEffect, useState, useCallback, useRef } from "react"
import { Card, CardBody, CardHeader, CardIcon, CardTool } from "../window/Card"
import { ToolButton } from "../window/ToolButton"
import { addToast, NumberInput, Tab, Tabs, Tooltip } from "@heroui/react"
import {
addToast,
Input,
Tab,
Tabs,
Tooltip,
Chip,
Dropdown,
DropdownTrigger,
DropdownMenu,
DropdownItem,
Button,
} from "@heroui/react"
import { motion } from "framer-motion"
import { useToolStore } from "@/store/tool"
import { useToolStore, VideoSetting as VideoConfig, VideoSettingTemplate } from "@/store/tool"
import { useSteamStore } from "@/store/steam"
import { useDebounce, useDebounceFn, useThrottleFn } from "ahooks"
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
import { useGlobalGameMonitor } from "@/hooks/useGlobalGameMonitor"
import { PRESET_RESOLUTIONS } from "./FpsTest/constants"
const VideoSetting = () => {
const [hide, setHide] = useState(false)
const [edit, setEdit] = useState(false)
const tool = useToolStore()
// const [launchOpt, setLaunchOpt] = useState(tool.state.VideoSettings[tool.state.launchIndex] || "")
const steam = useSteamStore()
// 使用全局游戏状态监控,避免重复检测
const { isGameRunning, checkGameRunning } = useGlobalGameMonitor()
// 使用 ref 存储 edit 的最新值,供 throttle 回调使用
const editRef = useRef(edit)
useEffect(() => {
editRef.current = edit
}, [edit])
// useEffect(() => {
// setLaunchOpt(tool.state.VideoSettings[tool.state.launchIndex] || "")
// }, [tool.state.launchIndex, tool.state.VideoSettings])
// 防抖的读取函数
const { run: debouncedGetVideoConfig } = useDebounceFn(
async () => {
if (steam.state.steamDirValid && steam.currentUser()) {
await tool.getVideoConfig(steam.state.steamDir, steam.currentUser()?.steam_id32 || 0)
addToast({ title: "读取成功" })
} else {
addToast({ title: "请先选择用户", color: "danger" })
}
},
{ wait: 500, leading: false, trailing: true }
)
const videoSettings = (video: VideoConfig) => {
// 判断当前全屏模式
const getFullscreenMode = () => {
if (video.fullscreen === "1") {
return "全屏"
} else if (video.coop_fullscreen === "1") {
return "全屏窗口化"
} else {
return "窗口"
}
}
// 设置对应关系
// TODO Value通过实际数值映射
const videoSettings = [
{ type: "", title: "全屏", value: "全屏", options: ["窗口", "全屏"] },
{ type: "", title: "垂直同步", value: "关闭", options: ["关闭", "开启"] },
{ type: "", title: "低延迟模式", value: "关闭", options: ["关闭", "开启"] },
{ type: "", title: "增强角色对比度", value: "禁用", options: ["禁用", "启用"] },
{ type: "", title: "CMAA2抗锯齿", value: "关闭", options: ["关闭", "开启"] },
{
type: "",
title: "多重采样抗锯齿",
value: "2X MSAA",
options: ["无", "2X MSAA", "4X MSAA", "8X MSAA"],
return [
{
type: "fullscreen",
title: "显示模式",
value: getFullscreenMode(),
options: ["窗口", "全屏", "全屏窗口化"],
mapping: (value: string) => {
// 返回一个对象,包含需要同时更新的字段
return {
: { fullscreen: "0", coop_fullscreen: "0" },
: { fullscreen: "1", coop_fullscreen: "0" },
: { fullscreen: "0", coop_fullscreen: "1" },
}[value] || { fullscreen: "0", coop_fullscreen: "0" }
},
},
{
type: "mat_vsync",
title: "垂直同步",
value: video.mat_vsync === "1" ? "开启" : "关闭",
options: ["关闭", "开启"],
mapping: (value: string) => {
return (
{
: "0",
: "1",
}[value] || "0"
)
},
},
{
type: "r_low_latency",
title: "低延迟模式",
value: video.r_low_latency === "1" ? "开启" : "关闭",
options: ["关闭", "开启"],
mapping: (value: string) => {
return (
{
: "0",
: "1",
}[value] || "0"
)
},
},
// TODO: 改选项不在 cs2_video.txt 中
// {
// type: "r_player_visible_mode",
// title: "增强角色对比度",
// value: video.r_csgo_cmaa_enable === "1" ? "启用" : "禁用",
// options: ["禁用", "启用"],
// },
{
type: "r_csgo_cmaa_enable",
title: "CMAA2抗锯齿",
value: video.r_csgo_cmaa_enable === "1" ? "开启" : "关闭",
options: ["关闭", "开启"],
mapping: (value: string) => {
return (
{
: "0",
: "1",
}[value] || "0"
)
},
},
{
type: "msaa_samples",
title: "多重采样抗锯齿",
value:
{
0: "无",
2: "2X MSAA",
4: "4X MSAA",
8: "8X MSAA",
}[parseInt(video.msaa_samples, 10)] || "无",
options: ["无", "2X MSAA", "4X MSAA", "8X MSAA"],
mapping: (value: string) => {
return (
{
: "0",
"2X MSAA": "2",
"4X MSAA": "4",
"8X MSAA": "8",
}[value] || "0"
)
},
},
{
type: "videocfg_shadow_quality",
title: "全局阴影效果",
value: ["低", "中", "高", "非常高"][parseInt(video.videocfg_shadow_quality, 10)] || "低",
options: ["低", "中", "高", "非常高"],
mapping: (value: string) => {
return (
{
: "0",
: "1",
: "2",
: "3",
}[value] || "0"
)
},
},
{
type: "videocfg_dynamic_shadows",
title: "动态阴影",
value: ["仅限日光", "全部"][parseInt(video.videocfg_dynamic_shadows, 10)] || "仅限日光",
options: ["仅限日光", "全部"],
mapping: (value: string) => {
return (
{
: "0",
: "1",
}[value] || "0"
)
},
},
{
type: "videocfg_texture_detail",
title: "模型/贴图细节",
value: ["低", "中", "高"][parseInt(video.videocfg_texture_detail, 10)] || "低",
options: ["低", "中", "高"],
mapping: (value: string) => {
return (
{
: "0",
: "1",
: "2",
}[value] || "0"
)
},
},
{
type: "r_texturefilteringquality",
title: "贴图过滤模式",
value:
["双线性", "三线性", "异向 2X", "异向 4X", "异向 8X", "异向 16X"][
parseInt(video.r_texturefilteringquality, 10)
] || "双线性",
options: ["双线性", "三线性", "异向 2X", "异向 4X", "异向 8X", "异向 16X"],
mapping: (value: string) => {
return (
{
线: "0",
线: "1",
"异向 2X": "2",
"异向 4X": "3",
"异向 8X": "4",
"异向 16X": "5",
}[value] || "0"
)
},
},
{
type: "shaderquality",
title: "光影细节",
value: video.shaderquality === "1" ? "高" : "低",
options: ["低", "高"],
mapping: (value: string) => {
return (
{
: "0",
: "1",
}[value] || "0"
)
},
},
{
type: "videocfg_particle_detail",
title: "粒子细节",
value: ["低", "中", "高", "非常高"][parseInt(video.videocfg_particle_detail, 10)] || "低",
options: ["低", "中", "高", "非常高"],
mapping: (value: string) => {
return (
{
: "0",
: "1",
: "2",
: "3",
}[value] || "低"
)
},
},
{
type: "videocfg_ao_detail",
title: "环境光遮蔽",
value: ["已禁用", "中", "高"][parseInt(video.videocfg_ao_detail, 10)] || "已禁用",
options: ["已禁用", "中", "高"],
mapping: (value: string) => {
return (
{
: "0",
: "1",
: "2",
}[value] || "已禁用"
)
},
},
{
type: "videocfg_hdr_detail",
title: "高动态范围",
value: video.videocfg_hdr_detail === "-1" ? "品质" : "性能",
options: ["性能", "品质"],
mapping: (value: string) => {
return (
{
: "3",
: "-1",
}[value] || "3"
)
},
},
{
type: "videocfg_fsr_detail",
title: "Fidelity FX 超级分辨率",
value:
["已禁用", "超高品质", "品质", "均衡", "性能"][parseInt(video.videocfg_fsr_detail, 10)] ||
"性能",
options: ["性能", "均衡", "品质", "超高品质", "已禁用"],
mapping: (value: string) => {
return (
{
: "0",
: "1",
: "2",
: "3",
: "4",
}[value] || "已禁用"
)
},
},
]
}
const [vconfig, setVconfig] = useState<VideoConfig>(tool.state.videoSetting)
useEffect(() => {
if (steam.state.steamDirValid && steam.currentUser())
void tool.getVideoConfig(steam.state.steamDir, steam.currentUser()?.steam_id32 || 0)
}, [])
const debounceCurrentUserId = useDebounce(steam.currentUser()?.steam_id32, {
wait: 500,
leading: false,
trailing: true,
maxWait: 2500,
})
// 节流重新读取配置函数2秒间隔trailing模式
const { run: throttledRefreshVideoConfig } = useThrottleFn(
async () => {
if (steam.state.steamDirValid && steam.currentUser()) {
await tool.getVideoConfig(
steam.state.steamDir,
steam.currentUser()?.steam_id32 || 0
)
// 如果不在编辑状态,更新本地状态(使用 ref 获取最新的 edit 值)
if (!editRef.current) {
setVconfig(tool.state.videoSetting)
}
addToast({ title: "检测到视频设置文件变动,已自动刷新", color: "success" })
}
},
{ type: "", title: "全局阴影效果", value: "低", options: ["低", "中", "高", "非常高"] },
{ type: "", title: "动态阴影", value: "全部", options: ["仅限日光", "全部"] },
{ type: "", title: "模型/贴图细节", value: "中", options: ["低", "中", "高"] },
{
type: "",
title: "贴图过滤模式",
value: "异向 4X",
options: ["双线性", "三线性", "异向 2X", "异向 4X", "异向 8X", "异向 16X"],
},
{ type: "", title: "光影细节", value: "低", options: ["低", "高"] },
{ type: "", title: "粒子细节", value: "低", options: ["低", "中", "高", "非常高"] },
{ type: "", title: "环境光遮蔽", value: "已禁用", options: ["已禁用", "中", "高"] },
{ type: "", title: "高动态范围", value: "性能", options: ["性能", "品质"] },
{
type: "",
title: "Fidelity FX 超级分辨率",
value: "已禁用",
options: ["性能", "均衡", "品质", "超高品质", "已禁用"],
},
]
wait: 2000,
leading: false,
trailing: true,
}
)
useEffect(() => {
if (steam.state.steamDirValid && steam.currentUser()) {
// 安全地获取视频配置(内部已有错误处理)
void tool.getVideoConfig(steam.state.steamDir, steam.currentUser()?.steam_id32 || 0)
// 启动文件监听,添加错误处理
void invoke("start_watch_cs2_video", {
steamDir: steam.state.steamDir,
steamId32: steam.currentUser()?.steam_id32 || 0,
}).catch((error) => {
console.error("启动视频配置文件监听失败:", error)
// 不显示错误提示,避免干扰用户
})
}
// 清理函数:停止后端监听
return () => {
void invoke("stop_watch_cs2_video").catch(() => {
// 忽略错误,可能监听器已经不存在
})
}
}, [debounceCurrentUserId, steam.state.steamDirValid, steam.state.steamDir, tool])
// 监听 cs2_video.txt 文件变动事件
useEffect(() => {
const unlisten = listen("steam://cs2_video_changed", () => {
// 文件变化时使用节流函数重新读取配置
throttledRefreshVideoConfig()
})
return () => {
void unlisten.then((fn) => fn())
}
}, [throttledRefreshVideoConfig])
return (
<Tooltip content="功能测试中,尚未实装" showArrow={true} delay={300}>
<Card>
<CardHeader>
<CardIcon>
<SettingConfig />
</CardIcon>
<CardTool>
{/* {tool.state.VideoSettings.map((option, index) => (
<Card>
<CardHeader>
<CardIcon>
<SettingConfig />
{isGameRunning && (
<Chip size="sm" color="warning" variant="flat" className="ml-2">
</Chip>
)}
</CardIcon>
<CardTool>
{/* {tool.state.VideoSettings.map((option, index) => (
<ToolButton key={index} onClick={() => tool.setLaunchIndex(index)}>
{index + 1}
</ToolButton>
))} */}
{edit && (
{edit && (
<>
<ToolButton onClick={() => setVconfig({ ...vconfig, ...VideoSettingTemplate.low })}>
</ToolButton>
<ToolButton
onClick={() => setVconfig({ ...vconfig, ...VideoSettingTemplate.middle })}
>
</ToolButton>
<ToolButton onClick={() => setVconfig({ ...vconfig, ...VideoSettingTemplate.high })}>
</ToolButton>
<ToolButton
onClick={() => setVconfig({ ...vconfig, ...VideoSettingTemplate.veryhigh })}
>
</ToolButton>
<ToolButton
onClick={() => setVconfig({ ...vconfig, ...VideoSettingTemplate.recommend })}
>
</ToolButton>
<Tooltip
content={isGameRunning ? "游戏运行中,无法修改视频设置" : ""}
isDisabled={!isGameRunning}
>
<div>
<ToolButton
onClick={async () => {
// 检查游戏是否运行
const gameRunning = await checkGameRunning()
if (gameRunning) {
addToast({
title: "无法应用设置",
description: "检测到游戏正在运行,请先关闭游戏后再应用设置",
color: "warning",
})
return
}
await tool.setVideoConfig(
steam.state.steamDir,
steam.currentUser()?.steam_id32 || 0,
vconfig
)
await tool.getVideoConfig(
steam.state.steamDir,
steam.currentUser()?.steam_id32 || 0
)
setEdit(false)
addToast({ title: "应用设置成功" })
}}
disabled={isGameRunning}
>
<Plus />
</ToolButton>
</div>
</Tooltip>
</>
)}
<ToolButton
onClick={async () => {
if (steam.state.steamDirValid && steam.currentUser())
await tool.getVideoConfig(
steam.state.steamDir,
steam.currentUser()?.steam_id32 || 0
)
setVconfig(tool.state.videoSetting)
setEdit(!edit)
}}
>
{edit ? (
<>
<ToolButton></ToolButton>
<ToolButton></ToolButton>
<ToolButton></ToolButton>
<ToolButton></ToolButton>
<ToolButton></ToolButton>
<ToolButton
onClick={() => {
addToast({ title: "测试中 功能完成后可应用设置到游戏" })
}}
>
<Plus />
</ToolButton>
<CloseSmall />
</>
) : (
<>
<Edit />
</>
)}
<ToolButton onClick={() => setEdit(!edit)}>
{edit ? (
<>
<CloseSmall />
</>
) : (
<>
<Edit />
</>
)}
</ToolButton>
<ToolButton onClick={() => setHide(!hide)}>
{hide ? (
<>
<Up />
</>
) : (
<>
<Down />
</>
)}
</ToolButton>
</CardTool>
</CardHeader>
{!hide && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<CardBody>
</ToolButton>
<ToolButton onClick={debouncedGetVideoConfig}></ToolButton>
<ToolButton onClick={() => setHide(!hide)}>
{hide ? (
<>
<Up />
</>
) : (
<>
<Down />
</>
)}
</ToolButton>
</CardTool>
</CardHeader>
{!hide && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<CardBody>
{edit ? (
// 编辑状态:显示完整的可编辑控件
<ul className="flex flex-wrap gap-3 mt-1">
<li className="flex flex-col gap-1.5">
<span className="ml-2"></span>
<span className="flex gap-3">
<NumberInput
aria-label="width"
value={tool.state.videoSetting.width}
onValueChange={(value) => {
tool.setVideoSetting({
...tool.state.videoSetting,
width: value,
<div className="flex items-center gap-2 ml-2">
<span></span>
<Dropdown placement="bottom-start" className="min-w-fit">
<DropdownTrigger>
<Button
size="sm"
variant="flat"
className="h-5 min-w-[50px] px-1.5 text-xs"
>
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="预设分辨率" className="">
{PRESET_RESOLUTIONS.map((preset) => (
<DropdownItem
key={preset.label}
onPress={() => {
setVconfig({
...vconfig,
defaultres: preset.width,
defaultresheight: preset.height,
})
}}
>
{preset.label}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
</div>
<span className="flex items-center gap-1">
<Input
size="sm"
type="number"
placeholder="宽"
value={vconfig.defaultres}
onValueChange={(val) => {
setVconfig({
...vconfig,
defaultres: val,
})
}}
radius="full"
step={10}
className="max-w-28"
classNames={{ inputWrapper: "h-10" }}
className="w-20"
classNames={{
inputWrapper: "h-9 px-3",
}}
/>
<NumberInput
aria-label="height"
value={tool.state.videoSetting.height}
onValueChange={(value) => {
tool.setVideoSetting({
...tool.state.videoSetting,
height: value,
<span className="text-xs text-default-400">x</span>
<Input
size="sm"
type="number"
placeholder="高"
value={vconfig.defaultresheight}
onValueChange={(val) => {
setVconfig({
...vconfig,
defaultresheight: val,
})
}}
radius="full"
step={10}
className="max-w-28"
classNames={{ inputWrapper: "h-10" }}
className="w-20"
classNames={{
inputWrapper: "h-9 px-3",
}}
/>
</span>
</li>
{videoSettings.map((vid, index) => (
{videoSettings(vconfig).map((vid, index) => (
<li className="flex flex-col gap-1.5" key={index}>
<span className="ml-2">{vid.title}</span>
<Tabs
selectedKey={vid.value}
size="md"
size="sm"
radius="full"
className="min-w-36"
fullWidth
selectedKey={vid.value}
onSelectionChange={(key) => {
if (key) {
const mappedValue = vid.mapping(key.toString())
// 如果返回的是对象(如全屏模式),需要同时更新多个字段
if (typeof mappedValue === "object" && mappedValue !== null) {
setVconfig({
...vconfig,
...mappedValue,
})
} else {
// 否则只更新单个字段
setVconfig({
...vconfig,
[vid.type]: mappedValue,
})
}
}
}}
>
{vid.options.map((opt, _) => (
<Tab key={opt} title={opt} />
<Tab key={opt} title={opt} titleValue={opt} />
))}
</Tabs>
</li>
))}
</ul>
</CardBody>
</motion.div>
)}
</Card>
</Tooltip>
) : (
// 非编辑状态:显示精简的只读信息
<div className="mt-1">
<div className="grid grid-cols-3 md:grid-cols-4 gap-2.5">
<div className="flex flex-col gap-1">
<span className="text-xs text-default-500"></span>
<span className="text-sm font-medium">
{tool.state.videoSetting.defaultres} ×{" "}
{tool.state.videoSetting.defaultresheight}
</span>
</div>
{videoSettings(tool.state.videoSetting).map((vid, index) => (
<div className="flex flex-col gap-1" key={index}>
<span className="text-xs text-default-500">{vid.title}</span>
<span className="text-sm font-medium">{vid.value}</span>
</div>
))}
</div>
</div>
)}
</CardBody>
</motion.div>
)}
</Card>
)
}

View File

@@ -1,7 +1,7 @@
// @ts-nocheck
import { Code, Link } from '@heroui/react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Code, Link } from "@heroui/react"
import Markdown from "react-markdown"
import remarkGfm from "remark-gfm"
export const components = {
a: ({ href, children }: { href: string; children: React.ReactNode }) => (
@@ -10,13 +10,23 @@ export const components = {
</Link>
),
// img: ({ src, alt }: { src: string; alt: string }) => <Image src={src} alt={alt} className="object-cover w-full h-full" />,
h1: ({ children }: { children: React.ReactNode }) => <h1 className="text-2xl font-bold mb-2.5">{children}</h1>,
h2: ({ children }: { children: React.ReactNode }) => <h2 className="text-xl font-semibold mb-2.5">{children}</h2>,
h3: ({ children }: { children: React.ReactNode }) => <h3 className="text-lg font-medium mb-2.5">{children}</h3>,
p: ({ children }: { children: React.ReactNode }) => <p className="mb-2.5 text-base">{children}</p>,
ul: ({ children }: { children: React.ReactNode }) => <ul className="list-disc pl-6 mb-2.5">{children}</ul>,
li: ({ children }: { children: React.ReactNode }) => <li className="mb-2">{children}</li>,
code: ({ children }: { children: React.ReactNode }) => <Code size="sm" >{children}</Code>,
h1: ({ children }: { children: React.ReactNode }) => (
<h1 className="text-2xl font-bold mb-2.5">{children}</h1>
),
h2: ({ children }: { children: React.ReactNode }) => (
<h2 className="text-xl font-semibold mb-2.5">{children}</h2>
),
h3: ({ children }: { children: React.ReactNode }) => (
<h3 className="text-lg font-medium mb-2.5">{children}</h3>
),
p: ({ children }: { children: React.ReactNode }) => (
<p className="mb-2.5 text-base">{children}</p>
),
ul: ({ children }: { children: React.ReactNode }) => (
<ul className="pl-6 mt-2 mb-3 list-disc">{children}</ul>
),
li: ({ children }: { children: React.ReactNode }) => <li className="mb-1.5 mt-1">{children}</li>,
code: ({ children }: { children: React.ReactNode }) => <Code size="sm">{children}</Code>,
}
export function MarkdownRender({ children }: { children: React.ReactNode }) {

View File

@@ -24,7 +24,7 @@ const Card = ({ children, className, ...props }: CardProps) => {
const CardHeader = ({ children }: CardProps) => {
return (
<div className="flex items-center gap-1.5 tracking-wide select-none">
<div className="flex items-center gap-1.5 tracking-wide select-none shrink-0">
{children}
</div>
)
@@ -34,7 +34,7 @@ const CardIcon = ({ children, type, className, ...rest }: CardProps) => {
return (
<div
className={cn(
"flex gap-1.5 items-center font-semibold",
"flex gap-1.5 items-center font-semibold flex-shrink-0",
type === "menu" &&
"transition cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 px-2 py-1 rounded-md active:scale-95",
className,
@@ -46,9 +46,9 @@ const CardIcon = ({ children, type, className, ...rest }: CardProps) => {
)
}
const CardTool = ({ children }: CardProps) => {
const CardTool = ({ children, className }: CardProps) => {
return (
<div className="flex items-center justify-end flex-grow gap-2">
<div className={cn("flex items-center justify-end flex-grow gap-2", className)}>
{children}
</div>
)

View File

@@ -1,5 +1,5 @@
"use client"
import { setTheme as setTauriTheme } from "@/hooks/tauri/theme"
import { setTheme as setTauriTheme } from "@/hooks/tauri/theme" //'@tauri-apps/api/app'
import { useAppStore } from "@/store/app"
import { useToolStore } from "@/store/tool"
import { addToast, Button, Link, Tooltip, useDisclosure } from "@heroui/react"
@@ -21,6 +21,7 @@ import { saveAllNow } from "@tauri-store/valtio"
import { useSteamStore } from "@/store/steam"
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react"
import { window } from "@tauri-apps/api"
import { AuthButton } from "@/components/auth/AuthButton"
const Nav = () => {
const { theme, setTheme } = useTheme()
@@ -60,61 +61,63 @@ const Nav = () => {
{pathname !== "/" && (
<button
type="button"
className="px-2 py-0 transition duration-150 rounded hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
className="px-2 py-0 transition duration-150 rounded cursor-pointer hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
onClick={() => {
app.setInited(false)
if (pathname !== "/") router.push("/")
}}
>
<RocketOne size={16} />
<RocketOne size={16} className="cursor-pointer" />
</button>
)}
</Tooltip>
<Tooltip content="深色模式" showArrow={true} delay={300}>
<button
type="button"
className="px-2 py-0 transition duration-150 rounded hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
className="px-2 py-0 transition duration-150 rounded cursor-pointer hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
onClick={() => (theme === "light" ? setAppTheme("dark") : setAppTheme("light"))}
>
{theme === "light" ? <SunOne size={16} /> : <Moon size={16} />}
{theme === "light" ? <SunOne size={16} className="cursor-pointer" /> : <Moon size={16} className="cursor-pointer" />}
</button>
</Tooltip>
<Tooltip content="反馈" showArrow={true} delay={300}>
<Link
href="https://docs.qq.com/form/page/DZU1ieW9SQkxWU1RF"
target="_blank"
className="px-2 py-0 text-black transition duration-150 rounded dark:text-white hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
className="px-2 py-0 text-black transition duration-150 rounded cursor-pointer dark:text-white hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
>
<button type="button">
<Communication size={16} />
<button type="button" className="cursor-pointer">
<Communication size={16} className="cursor-pointer" />
</button>
</Link>
</Tooltip>
{/* <AuthButtonWrapper /> */}
<ResetModal />
{/* { platform() === "windows" && ( */}
<>
<button
type="button"
className="px-2 py-0 transition duration-150 rounded hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
className="px-2 py-0 transition duration-150 rounded cursor-pointer hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
onClick={minimize}
>
<Minus size={16} />
<Minus size={16} className="cursor-pointer" />
</button>
<button
type="button"
className="px-2 py-0 transition duration-150 rounded hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
className="px-2 py-0 transition duration-150 rounded cursor-pointer hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
onClick={toggleMaximize}
>
<Square size={16} />
<Square size={16} className="cursor-pointer" />
</button>
<button
type="button"
className="px-2 py-0 transition duration-150 rounded hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
className="px-2 py-0 transition duration-150 rounded cursor-pointer hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
onClick={close}
>
<Close size={16} />
<Close size={16} className="cursor-pointer" />
</button>
</>
{/* )} */}
@@ -146,10 +149,10 @@ function ResetModal() {
<Tooltip content="重置设置" showArrow={true} delay={300}>
<button
type="button"
className="px-2 py-0 transition duration-150 rounded hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
className="px-2 py-0 transition duration-150 rounded cursor-pointer hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
onClick={onOpen}
>
<Refresh size={16} />
<Refresh size={16} className="cursor-pointer" />
</button>
</Tooltip>
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
@@ -182,4 +185,8 @@ function ResetModal() {
)
}
function AuthButtonWrapper() {
return <AuthButton />
}
export default Nav

View File

@@ -1,6 +1,6 @@
"use client"
import { cn, user } from "@heroui/react"
import { Home, MonitorOne, Movie, Setting, Terminal, Toolkit } from "@icon-park/react"
import { cn, Tooltip } from "@heroui/react"
import { Home, MonitorOne, Movie, NewspaperFolding, Setting, Terminal, Toolkit } from "@icon-park/react"
import { usePathname, useRouter } from "next/navigation"
import type { ReactNode } from "react"
import { getVersion } from "@tauri-apps/api/app"
@@ -9,6 +9,17 @@ import { getVersion } from "@tauri-apps/api/app"
import { useAppStore } from "@/store/app"
import { useSteamStore } from "@/store/steam"
// 路由到页面名称的映射
const routeNames: Record<string, string> = {
"/home": "首页",
"/dynamic": "动态",
"/tool": "工具",
"/console": "控制台",
"/gear": "硬件外设",
"/movie": "录像",
"/preference": "偏好设置",
}
interface SideButtonProps {
route: string
className?: string
@@ -23,26 +34,29 @@ const SideButton = ({
}: SideButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const router = useRouter()
const path = usePathname()
const pageName = routeNames[route] || route
return (
<button
type="button"
onClick={() => router.push(route || "/")}
className={cn(
className,
"p-2.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition relative active:scale-90",
path.startsWith(route) && "bg-black/5 dark:bg-white/5"
)}
{...rest}
>
{children}
<div
<Tooltip content={pageName} showArrow={true} delay={300} placement="right">
<button
type="button"
onClick={() => router.push(route || "/")}
className={cn(
path.startsWith(route) && "opacity-100",
"transition-opacity duration-300 opacity-0 h-3.5 w-0.5 absolute left-0.5 bg-purple-500 rounded-full top-1/2 -translate-y-1/2"
className,
"p-2.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition relative active:scale-90 cursor-pointer [&>*]:cursor-pointer",
path.startsWith(route) && "bg-black/5 dark:bg-white/5"
)}
/>
</button>
{...rest}
>
{children}
<div
className={cn(
path.startsWith(route) && "opacity-100",
"transition-opacity duration-300 opacity-0 h-3.5 w-0.5 absolute left-0.5 bg-purple-500 rounded-full top-1/2 -translate-y-1/2"
)}
/>
</button>
</Tooltip>
)
}
@@ -90,12 +104,15 @@ const SideBar = () => {
</section>
<section
className="flex flex-col items-center justify-center h-full gap-5"
className="flex flex-col items-center justify-center h-full gap-5 pt-5"
data-tauri-drag-region
>
<SideButton route="/home">
<Home size={24} />
</SideButton>
<SideButton route="/dynamic">
<NewspaperFolding size={24} />
</SideButton>
<SideButton route="/tool">
<Toolkit size={24} />
</SideButton>
@@ -115,7 +132,7 @@ const SideBar = () => {
<div className="mx-auto text-sm text-center text-zinc-500" data-tauri-drag-region>
<p></p>
<p className="py-1 text-sm text-zinc-600">{app.state.version}</p>
<p className="py-1 text-xs text-zinc-600">{app.state.version}</p>
</div>
</div>
)

View File

@@ -1,13 +1,26 @@
import { cn } from "@heroui/react"
import type { ReactNode } from "react"
interface ToolButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children?: ReactNode
className?: string
selected?: boolean
}
export const ToolButton = ({ children, ...rest }: ToolButtonProps) => {
export const ToolButton = ({ children, className, selected, disabled, ...rest }: ToolButtonProps) => {
return (
<button
type="button"
className="flex gap-0.5 active:scale-95 items-center min-w-7 justify-center px-2 py-1.5 bg-black/5 transition hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 rounded-md text-sm leading-none"
disabled={disabled}
className={cn(
"flex shrink-0 gap-0.5 items-center min-w-7 justify-center px-2 py-1.5 bg-black/5 transition rounded-md text-sm leading-none",
disabled
? "opacity-50 cursor-not-allowed"
: "active:scale-95 cursor-pointer hover:bg-black/10 dark:hover:bg-white/10",
"dark:bg-white/5",
className,
selected &&
"bg-purple-500/40 hover:bg-purple-500/20 text-purple-900 dark:text-purple-100 drop-shadow-sm dark:bg-purple-500/40 dark:hover:bg-purple-500/20"
)}
{...rest}
>
{children}

View File

@@ -0,0 +1,65 @@
import { useEffect, useRef } from "react"
import { useToolStore } from "@/store/tool"
// 全局检测间隔(毫秒)- 增加到5秒以减少性能影响
const CHECK_INTERVAL = 5000
// 全局检测状态管理
let globalInterval: NodeJS.Timeout | null = null
let subscriberCount = 0
/**
* 全局游戏状态监控 Hook
* 多个组件可以共享同一个检测循环,避免重复检测
*
* @param enabled 是否启用检测(默认 true
* @returns 游戏运行状态和手动检测函数
*/
export function useGlobalGameMonitor(enabled: boolean = true) {
const tool = useToolStore()
const isGameRunning = tool.state.isGameRunning
const checkGameRunning = tool.checkGameRunning
const enabledRef = useRef(enabled)
// 更新 enabled 引用
useEffect(() => {
enabledRef.current = enabled
}, [enabled])
useEffect(() => {
if (!enabled) {
return
}
// 增加订阅者计数
subscriberCount++
// 如果这是第一个订阅者,启动全局检测循环
if (subscriberCount === 1) {
// 立即检测一次
void checkGameRunning()
// 启动定期检测
globalInterval = setInterval(() => {
void checkGameRunning()
}, CHECK_INTERVAL)
}
// 清理函数:减少订阅者计数
return () => {
subscriberCount--
// 如果没有订阅者了,停止检测循环
if (subscriberCount === 0 && globalInterval) {
clearInterval(globalInterval)
globalInterval = null
}
}
}, [enabled, checkGameRunning])
return {
isGameRunning,
checkGameRunning,
}
}

View File

@@ -4,17 +4,33 @@ import { DEFAULT_STORE_CONFIG } from "./config"
import { enable, disable } from "@tauri-apps/plugin-autostart"
import { LazyStore } from '@tauri-apps/plugin-store';
interface UpdateInfo {
version: string
notes?: string
download_url: string
}
const defaultValue = {
version: "0.0.1",
hasUpdate: false,
latestVersion: "", // 最新版本号
updateInfo: null as UpdateInfo | null, // 更新信息
downloading: false, // 是否正在下载
downloadProgress: 0, // 下载进度 0-100
downloadCompleted: false, // 下载是否完成
inited: false,
notice: "",
useMirror: true,
useMirror: true, // 默认使用镜像源CDN 加速)
includePrerelease: false, // 默认不包含预发布版本
useCdn: true, // 默认使用 CDN 加速下载
autoStart: false,
startHidden: false,
hiddenOnClose: false,
steamUsersViewMode: "list-large" as "card" | "list" | "list-large",
}
export type { UpdateInfo }
export const appStore = store("app", { ...defaultValue }, DEFAULT_STORE_CONFIG)
export const useAppStore = () => {
@@ -26,17 +42,25 @@ export const useAppStore = () => {
store: appStore,
setVersion,
setHasUpdate,
setLatestVersion,
setUpdateInfo,
setDownloading,
setDownloadProgress,
setDownloadCompleted,
setInited,
setNotice,
setUseMirror,
setIncludePrerelease,
setUseCdn,
setAutoStart,
setStartHidden,
setHiddenOnClose,
setSteamUsersViewMode,
resetAppStore,
}
}
const launchStore = new LazyStore('cstb.json', { autoSave: true });
const launchStore = new LazyStore('cstb.json', { autoSave: true, defaults: defaultValue });
if (typeof window !== 'undefined') void launchStore.save()
const setVersion = (version: string) => {
@@ -45,6 +69,21 @@ const setVersion = (version: string) => {
const setHasUpdate = (hasUpdate: boolean) => {
appStore.state.hasUpdate = hasUpdate
}
const setLatestVersion = (latestVersion: string) => {
appStore.state.latestVersion = latestVersion
}
const setUpdateInfo = (updateInfo: UpdateInfo | null) => {
appStore.state.updateInfo = updateInfo
}
const setDownloading = (downloading: boolean) => {
appStore.state.downloading = downloading
}
const setDownloadProgress = (downloadProgress: number) => {
appStore.state.downloadProgress = downloadProgress
}
const setDownloadCompleted = (downloadCompleted: boolean) => {
appStore.state.downloadCompleted = downloadCompleted
}
const setInited = (inited: boolean) => {
appStore.state.inited = inited
}
@@ -54,6 +93,12 @@ const setNotice = (notice: string) => {
const setUseMirror = (useMirror: boolean) => {
appStore.state.useMirror = useMirror
}
const setIncludePrerelease = (includePrerelease: boolean) => {
appStore.state.includePrerelease = includePrerelease
}
const setUseCdn = (useCdn: boolean) => {
appStore.state.useCdn = useCdn
}
const setAutoStart = (autoStart: boolean) => {
if (autoStart) {
@@ -75,13 +120,25 @@ const setHiddenOnClose = (hiddenOnClose: boolean) => {
appStore.state.hiddenOnClose = hiddenOnClose;
}
const setSteamUsersViewMode = (viewMode: "card" | "list" | "list-large") => {
appStore.state.steamUsersViewMode = viewMode
}
const resetAppStore = () => {
setVersion(defaultValue.version)
setHasUpdate(defaultValue.hasUpdate)
setLatestVersion(defaultValue.latestVersion)
setUpdateInfo(defaultValue.updateInfo)
setDownloading(defaultValue.downloading)
setDownloadProgress(defaultValue.downloadProgress)
setDownloadCompleted(defaultValue.downloadCompleted)
setInited(defaultValue.inited)
setNotice(defaultValue.notice)
setUseMirror(defaultValue.useMirror)
setIncludePrerelease(defaultValue.includePrerelease)
setUseCdn(defaultValue.useCdn)
setAutoStart(defaultValue.autoStart)
void setStartHidden(defaultValue.startHidden)
setHiddenOnClose(defaultValue.hiddenOnClose)
setSteamUsersViewMode(defaultValue.steamUsersViewMode)
}

76
src/store/auth.ts Normal file
View File

@@ -0,0 +1,76 @@
import { store } from "@tauri-store/valtio"
import { useSnapshot } from "valtio"
import { DEFAULT_STORE_CONFIG } from "./config"
import { createClient } from "@/utils/supabase/client"
import type { User, Session } from "@supabase/supabase-js"
interface AuthState {
user: User | null
session: Session | null
isLoading: boolean
isAuthenticated: boolean
}
const defaultValue: AuthState = {
user: null,
session: null,
isLoading: true,
isAuthenticated: false,
}
export const authStore = store("auth", { ...defaultValue }, DEFAULT_STORE_CONFIG)
export const useAuthStore = () => {
void authStore.start
const state = useSnapshot(authStore.state)
return {
state,
store: authStore,
setUser,
setSession,
setLoading,
signOut,
checkSession,
}
}
const setUser = (user: User | null) => {
authStore.state.user = user
authStore.state.isAuthenticated = !!user
}
const setSession = (session: Session | null) => {
authStore.state.session = session
authStore.state.user = session?.user ?? null
authStore.state.isAuthenticated = !!session
}
const setLoading = (isLoading: boolean) => {
authStore.state.isLoading = isLoading
}
const signOut = async () => {
const supabase = createClient()
await supabase.auth.signOut()
authStore.state.user = null
authStore.state.session = null
authStore.state.isAuthenticated = false
}
const checkSession = async () => {
setLoading(true)
try {
const supabase = createClient()
const {
data: { session },
} = await supabase.auth.getSession()
setSession(session)
} catch (error) {
console.error("Error checking session:", error)
setSession(null)
} finally {
setLoading(false)
}
}

166
src/store/fps_test.ts Normal file
View File

@@ -0,0 +1,166 @@
import { store } from "@tauri-store/valtio"
import { useSnapshot } from "valtio"
import { DEFAULT_STORE_CONFIG } from "./config"
import type { AllSystemInfo } from "tauri-plugin-system-info-api"
import type { VideoSetting } from "./tool"
export interface FpsTestResult {
id: string // 唯一标识符,使用时间戳
testTime: string // 测试时间MM/DD HH:mm:ss
testDate: string // 测试日期ISO 格式,用于排序)
mapName: string // 测试地图名称
mapLabel: string // 测试地图标签
avg: number | null // 平均帧数
p1: number | null // P1 帧数最低1%帧数)
rawResult: string // 原始测试结果
videoSetting: VideoSetting | null // 画面设置参数
hardwareInfo: {
cpu: string | null
cpuCount: number | null
os: string | null
memory: number | null // GB
memoryManufacturer: string | null
memorySpeed: number | null // MHz实际频率 ConfiguredClockSpeed
memoryDefaultSpeed: number | null // MHz默认频率 Speed如果存在
gpu: string | null
monitor: string | null
monitorManufacturer: string | null
monitorModel: string | null
motherboardModel: string | null // 合并后的制造商和型号
motherboardVersion: string | null
biosVersion: string | null
} | null // 硬件信息
note?: string // 备注(可选,用于向后兼容)
}
export interface ResolutionGroupItem {
width: string
height: string
label: string
}
const defaultValue = {
results: [] as FpsTestResult[],
// FpsTest配置数据
config: {
batchTestCount: 1, // 批量测试次数
isResolutionGroupEnabled: false, // 是否启用分辨率组
resolutionGroup: [] as ResolutionGroupItem[], // 分辨率组列表
testNote: "", // 测试备注
customLaunchOption: "", // 自定义启动项
isResolutionEnabled: true, // 是否启用分辨率和全屏设置
resolutionWidth: "", // 分辨率宽度
resolutionHeight: "", // 分辨率高度
isFullscreen: true, // 全屏模式
},
}
export const fpsTestStore = store(
"fps_test",
{ ...defaultValue },
DEFAULT_STORE_CONFIG,
)
export const useFpsTestStore = () => {
void fpsTestStore.start
const state = useSnapshot(fpsTestStore.state)
return {
state,
store: fpsTestStore,
addResult,
removeResult,
clearResults,
updateNote,
// 配置相关方法
setBatchTestCount,
setIsResolutionGroupEnabled,
setResolutionGroup,
addResolutionToGroup,
removeResolutionFromGroup,
setTestNote,
setCustomLaunchOption,
setIsResolutionEnabled,
setResolution,
setIsFullscreen,
}
}
const addResult = (result: FpsTestResult) => {
fpsTestStore.state.results = [result, ...fpsTestStore.state.results]
// 限制最多保存100条记录
if (fpsTestStore.state.results.length > 100) {
fpsTestStore.state.results = fpsTestStore.state.results.slice(0, 100)
}
}
const removeResult = (id: string) => {
fpsTestStore.state.results = fpsTestStore.state.results.filter(
(r) => r.id !== id,
)
}
const clearResults = () => {
fpsTestStore.state.results = []
}
const updateNote = (id: string, note: string) => {
const result = fpsTestStore.state.results.find((r) => r.id === id)
if (result) {
result.note = note
}
}
// 配置相关方法
const setBatchTestCount = (count: number) => {
fpsTestStore.state.config.batchTestCount = count
}
const setIsResolutionGroupEnabled = (enabled: boolean) => {
fpsTestStore.state.config.isResolutionGroupEnabled = enabled
}
const setResolutionGroup = (group: ResolutionGroupItem[]) => {
fpsTestStore.state.config.resolutionGroup = group
}
const addResolutionToGroup = (resolution: ResolutionGroupItem) => {
// 检查是否已存在相同分辨率
const exists = fpsTestStore.state.config.resolutionGroup.some(
(r) => r.width === resolution.width && r.height === resolution.height
)
if (!exists) {
fpsTestStore.state.config.resolutionGroup = [
...fpsTestStore.state.config.resolutionGroup,
resolution,
]
}
}
const removeResolutionFromGroup = (index: number) => {
fpsTestStore.state.config.resolutionGroup = fpsTestStore.state.config.resolutionGroup.filter(
(_, i) => i !== index
)
}
const setTestNote = (note: string) => {
fpsTestStore.state.config.testNote = note
}
const setCustomLaunchOption = (option: string) => {
fpsTestStore.state.config.customLaunchOption = option
}
const setIsResolutionEnabled = (enabled: boolean) => {
fpsTestStore.state.config.isResolutionEnabled = enabled
}
const setResolution = (width: string, height: string) => {
fpsTestStore.state.config.resolutionWidth = width
fpsTestStore.state.config.resolutionHeight = height
}
const setIsFullscreen = (isFullscreen: boolean) => {
fpsTestStore.state.config.isFullscreen = isFullscreen
}

153
src/store/hardware.ts Normal file
View File

@@ -0,0 +1,153 @@
import { store } from "@tauri-store/valtio"
import { useSnapshot } from "valtio"
import { DEFAULT_STORE_CONFIG } from "./config"
import { allSysInfo, type AllSystemInfo } from "tauri-plugin-system-info-api"
import { invoke } from "@tauri-apps/api/core"
export interface ComputerInfo {
OsName?: string
OSDisplayVersion?: string
BiosSMBIOSBIOSVersion?: string
CsManufacturer?: string
CsName?: string
ReleaseId?: string
}
export interface GpuInfo {
vendor: string
model: string
family: string
device_id: string
total_vram: number
used_vram: number
load_pct: number
temperature: number
}
export interface MemoryInfo {
capacity?: number // 容量(字节)
manufacturer?: string
speed?: number // MHz实际频率 ConfiguredClockSpeed
default_speed?: number // MHz默认频率 Speed如果存在
}
export interface MonitorInfo {
manufacturer?: string
model?: string
name?: string
refresh_rate?: number // Hz
resolution_width?: number
resolution_height?: number
}
export interface MotherboardInfo {
manufacturer?: string // 制造商
model?: string // 型号
version?: string
}
export interface HardwareData {
allSysData: AllSystemInfo | null
computerInfo: ComputerInfo
gpuInfo: GpuInfo | null
memoryInfo: MemoryInfo[]
monitorInfo: MonitorInfo[]
motherboardInfo: MotherboardInfo | null
lastUpdated: number // 最后更新时间戳
}
const defaultValue: HardwareData = {
allSysData: null,
computerInfo: {},
gpuInfo: null,
memoryInfo: [],
monitorInfo: [],
motherboardInfo: null,
lastUpdated: 0,
}
// 硬件信息 fetcher
const hardwareInfoFetcher = async (): Promise<HardwareData> => {
// 并行获取系统信息、PowerShell 信息、GPU 信息、内存信息、显示器信息和主板信息
const [sys, computerInfoData, gpuInfoData, memoryInfoData, monitorInfoData, motherboardInfoData] =
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[]
}),
invoke<MotherboardInfo>("get_motherboard_info").catch((error) => {
console.error("获取主板信息失败:", error)
return { manufacturer: undefined, model: undefined, version: undefined } as MotherboardInfo
}),
])
return {
allSysData: sys,
computerInfo: computerInfoData,
gpuInfo: gpuInfoData,
memoryInfo: memoryInfoData,
monitorInfo: monitorInfoData,
motherboardInfo: motherboardInfoData,
lastUpdated: Date.now(),
}
}
export const hardwareStore = store("hardware", { ...defaultValue }, DEFAULT_STORE_CONFIG)
// 检查数据是否过期30分钟
const isDataStale = (lastUpdated: number): boolean => {
const thirtyMinutes = 30 * 60 * 1000
return Date.now() - lastUpdated > thirtyMinutes
}
// 获取硬件信息(如果数据过期或不存在则重新获取)
export const fetchHardwareInfo = async (force = false): Promise<void> => {
// 如果数据存在且未过期,且不是强制刷新,则直接返回
if (!force && hardwareStore.state.allSysData && !isDataStale(hardwareStore.state.lastUpdated)) {
return
}
try {
const data = await hardwareInfoFetcher()
hardwareStore.state.allSysData = data.allSysData
hardwareStore.state.computerInfo = data.computerInfo
hardwareStore.state.gpuInfo = data.gpuInfo
hardwareStore.state.memoryInfo = data.memoryInfo
hardwareStore.state.monitorInfo = data.monitorInfo
hardwareStore.state.motherboardInfo = data.motherboardInfo
hardwareStore.state.lastUpdated = data.lastUpdated
} catch (error) {
console.error("获取硬件信息失败:", error)
}
}
// 强制刷新硬件信息
export const refreshHardwareInfo = async (): Promise<void> => {
await fetchHardwareInfo(true)
}
export const useHardwareStore = () => {
void hardwareStore.start
const state = useSnapshot(hardwareStore.state)
return {
state,
store: hardwareStore,
fetchHardwareInfo,
refreshHardwareInfo,
}
}

View File

@@ -1,14 +1,17 @@
import { appConfigDir } from "@tauri-apps/api/path"
import { setStoreCollectionPath } from "@tauri-store/valtio"
import { appStore } from "./app"
import { authStore } from "./auth"
import { steamStore } from "./steam"
import { toolStore } from "./tool"
import path from "path"
import { fpsTestStore } from "./fps_test"
import { hardwareStore, fetchHardwareInfo } from "./hardware"
export async function init() {
await appStore.start()
await authStore.start()
await toolStore.start()
await steamStore.start()
const appConfigDirPath = await appConfigDir()
await setStoreCollectionPath(path.resolve(appConfigDirPath, "cstb"))
await fpsTestStore.start()
await hardwareStore.start()
// 初始化时自动加载硬件信息(如果数据过期或不存在)
await fetchHardwareInfo()
}

View File

@@ -74,11 +74,20 @@ const setCs2DirChecking = (checking: boolean) => {
const checkSteamDirValid = async () => {
setSteamDirChecking(true)
const pathExist = await invoke<boolean>("check_path", { path: steamStore.state.steamDir })
setSteamDirValid(pathExist)
setTimeout(() => {
setSteamDirChecking(false)
}, 500)
try {
// 使用专门的 Steam 路径验证,检查 steam.exe 或 config 目录
const isValid = await invoke<boolean>("check_steam_dir_valid", {
steamDir: steamStore.state.steamDir
})
setSteamDirValid(isValid)
} catch (error) {
console.error("验证 Steam 路径时出错:", error)
setSteamDirValid(false)
} finally {
setTimeout(() => {
setSteamDirChecking(false)
}, 500)
}
}
const checkCs2DirValid = async () => {
@@ -95,8 +104,21 @@ const currentUser = () => {
}
const getUsers = async () => {
const users = await invoke<SteamUser[]>("get_steam_users", { steamDir: steamStore.state.steamDir })
setUsers(users)
// 只有在路径有效时才尝试获取用户
if (!steamStore.state.steamDirValid || !steamStore.state.steamDir) {
return
}
try {
const users = await invoke<SteamUser[]>("get_steam_users", {
steamDir: steamStore.state.steamDir
})
setUsers(users)
} catch (error) {
console.error("获取 Steam 用户列表失败:", error)
// 如果获取失败,清空用户列表,避免显示错误数据
setUsers([])
}
}
const selectUser = (index: number) => {

View File

@@ -2,43 +2,161 @@ import { store } from "@tauri-store/valtio"
import { useSnapshot } from "valtio"
import { DEFAULT_STORE_CONFIG } from "./config"
import { emit } from "@tauri-apps/api/event"
import { send } from "process"
import { invoke } from "@tauri-apps/api/core"
import VideoSetting from "@/components/cstb/VideoSetting"
interface LaunchOption {
export interface LaunchOption {
option: string
name: string
}
export interface VideoSetting {
width: number; // 分辨率宽度
height: number; // 分辨率高度
version: string; // 版本
vendor_id: string; // 供应商ID
device_id: string; // 设备ID
cpu_level: string; // CPU等级
gpu_mem_level: string; // GPU内存等级
gpu_level: string; // GPU等级
knowndevice: string; // 已知设备
defaultres: string; // 默认分辨率宽度
defaultresheight: string; // 默认分辨率高度
refreshrate_numerator: string; // 刷新率分子
refreshrate_denominator: string; // 刷新率分母
fullscreen: string; // 全屏
vsync: string; // 垂直同步
enhanceCharacterContrast: string; // 增强角色对比度
cmaa2AntiAliasing: string; // CMAA2抗锯齿
msaaAntiAliasing: string; // 多重采样抗锯齿
globalShadowQuality: string; // 全局阴影效果
dynamicShadows: string; // 动态阴影
modelTextureDetail: string; // 模型/贴图细节
textureFilteringMode: string; // 贴图过滤模式
lightShadowDetail: string; // 光影细节
particleDetail: string; // 粒子细节
ambientOcclusion: string; // 环境光遮蔽
hdr: string; // 动态范围
fidelityFxSuperResolution: string; // Fidelity FX 超级分辨率
coop_fullscreen: string; // 合作模式全屏
nowindowborder: string; // 无窗口边框
mat_vsync: string; // 垂直同步
fullscreen_min_on_focus_loss: string; // 失去焦点时最小化全屏
high_dpi: string; // 高DPI
auto_config: string; // 自动配置
shaderquality: string; // 光影质量
r_texturefilteringquality: string; // 纹理过滤质量
msaa_samples: string; // 多重采样抗锯齿样本数
r_csgo_cmaa_enable: string; // CMAA抗锯齿启用
videocfg_shadow_quality: string; // 阴影质量
videocfg_dynamic_shadows: string; // 动态阴影
videocfg_texture_detail: string; // 纹理细节
videocfg_particle_detail: string; // 粒子细节
videocfg_ao_detail: string; // 环境光遮蔽细节
videocfg_hdr_detail: string; // 高动态范围细节
videocfg_fsr_detail: string; // FSR细节
monitor_index: string; // 显示器索引
r_low_latency: string; // 低延迟
aspectratiomode: string; // 宽高比模式
}
// 视频设置预设模版
export const VideoSettingTemplate = {
veryhigh: {
shaderquality: "1",
r_texturefilteringquality: "3",
msaa_samples: "8",
r_csgo_cmaa_enable: "0",
videocfg_shadow_quality: "3",
videocfg_dynamic_shadows: "1",
videocfg_texture_detail: "2",
videocfg_particle_detail: "3",
videocfg_ao_detail: "3",
videocfg_hdr_detail: "-1",
videocfg_fsr_detail: "0",
},
high: {
shaderquality: "1",
r_texturefilteringquality: "3",
msaa_samples: "4",
r_csgo_cmaa_enable: "0",
videocfg_shadow_quality: "2",
videocfg_dynamic_shadows: "1",
videocfg_texture_detail: "2",
videocfg_particle_detail: "2",
videocfg_ao_detail: "2",
videocfg_hdr_detail: "-1",
videocfg_fsr_detail: "0",
},
middle: {
shaderquality: "0",
r_texturefilteringquality: "1",
msaa_samples: "2",
r_csgo_cmaa_enable: "0",
videocfg_shadow_quality: "1",
videocfg_dynamic_shadows: "1",
videocfg_texture_detail: "1",
videocfg_particle_detail: "1",
videocfg_ao_detail: "0",
videocfg_fsr_detail: "2",
},
low: {
shaderquality: "0",
r_texturefilteringquality: "0",
msaa_samples: "0",
r_csgo_cmaa_enable: "0",
videocfg_shadow_quality: "0",
videocfg_dynamic_shadows: "0",
videocfg_texture_detail: "0",
videocfg_particle_detail: "0",
videocfg_ao_detail: "0",
videocfg_hdr_detail: "3",
videocfg_fsr_detail: "3",
},
recommend: {
shaderquality: "0",
r_texturefilteringquality: "3",
msaa_samples: "2",
r_csgo_cmaa_enable: "0",
videocfg_shadow_quality: "0",
videocfg_dynamic_shadows: "1",
videocfg_texture_detail: "1",
videocfg_particle_detail: "0",
videocfg_ao_detail: "0",
videocfg_hdr_detail: "3",
videocfg_fsr_detail: "0",
},
}
const defaultValue = {
launchOptions: [
{ option: "-novid -high -freq 144 -fullscreen", name: "" },
{ option: "-novid -high -w 1920 -h 1080 -freq 144 -sw -noborder", name: "" },
{ option: "-novid -high -freq 144 -fullscreen -allow_third_party_software", name: "" },
{ option: "-novid -high -freq 240 -coop_fullscreen -allow_third_party_software", name: "游戏" },
{ option: "-novid -high -w 1920 -h 1080 -freq 240 -sw -noborder -allow_third_party_software", name: "录像" },
{ option: "-novid -high -freq 240 -fullscreen -allow_third_party_software", name: "测试" },
] as LaunchOption[],
launchIndex: 0,
powerPlan: 0,
autoCloseGame: true, // 帧数测试自动关闭游戏
isGameRunning: false, // 游戏运行状态(全局共享)
videoSetting: {
width: 1920,
height: 1080
version: "15",
vendor_id: "0",
device_id: "0",
cpu_level: "3",
gpu_mem_level: "3",
gpu_level: "3",
knowndevice: "0",
defaultres: "1920",
defaultresheight: "1080",
refreshrate_numerator: "144",
refreshrate_denominator: "1",
fullscreen: "1",
coop_fullscreen: "0",
nowindowborder: "1",
mat_vsync: "0",
fullscreen_min_on_focus_loss: "1",
high_dpi: "0",
auto_config: "2",
shaderquality: "0",
r_texturefilteringquality: "3",
msaa_samples: "2",
r_csgo_cmaa_enable: "0",
videocfg_shadow_quality: "0",
videocfg_dynamic_shadows: "1",
videocfg_texture_detail: "1",
videocfg_particle_detail: "0",
videocfg_ao_detail: "0",
videocfg_hdr_detail: "3",
videocfg_fsr_detail: "0",
monitor_index: "0",
r_low_latency: "1",
aspectratiomode: "0",
} as VideoSetting,
}
@@ -65,10 +183,16 @@ export const useToolStore = () => {
setLaunchOption,
setLaunchOptions,
setLaunchIndex,
removeLaunchOption,
setPowerPlan,
setAutoCloseGame,
setVideoSetting,
getVideoConfig,
setVideoConfig,
addLaunchOption,
resetToolStore,
setIsGameRunning,
checkGameRunning,
}
}
@@ -78,10 +202,17 @@ const setLaunchOption = (option: LaunchOption, index: number) => {
option,
...toolStore.state.launchOptions.slice(index + 1),
]
// 同步更新托盘
sendCurrentLaunchOptionToTray(toolStore.state.launchIndex)
}
const setLaunchOptions = (options: LaunchOption[]) => {
toolStore.state.launchOptions = options
// 确保索引在有效范围内
if (toolStore.state.launchIndex >= options.length) {
toolStore.state.launchIndex = options.length > 0 ? options.length - 1 : 0
}
sendCurrentLaunchOptionToTray(toolStore.state.launchIndex)
}
const setLaunchIndex = (index: number) => {
@@ -89,8 +220,32 @@ const setLaunchIndex = (index: number) => {
sendCurrentLaunchOptionToTray(index)
}
const removeLaunchOption = (index: number) => {
toolStore.state.launchOptions = [
...toolStore.state.launchOptions.slice(0, index),
...toolStore.state.launchOptions.slice(index + 1),
]
// 如果删除的是当前项或当前项在删除项之后,需要调整索引
if (index <= toolStore.state.launchIndex) {
if (toolStore.state.launchIndex > 0) {
toolStore.state.launchIndex -= 1
} else {
toolStore.state.launchIndex = 0
}
}
// 确保索引在有效范围内
if (toolStore.state.launchIndex >= toolStore.state.launchOptions.length) {
toolStore.state.launchIndex = toolStore.state.launchOptions.length > 0 ? toolStore.state.launchOptions.length - 1 : 0
}
sendCurrentLaunchOptionToTray(toolStore.state.launchIndex)
}
const sendCurrentLaunchOptionToTray = (index: number) => {
void emit("tray://get_current_launch_option", toolStore.state.launchOptions[index].name || index + 1)
// 发送完整的启动项列表和当前索引到托盘
void emit("tray://update_launch_options", {
options: toolStore.state.launchOptions,
currentIndex: index,
})
}
const setPowerPlan = (plan: number) => {
toolStore.state.powerPlan = plan
@@ -100,21 +255,63 @@ const sendPowerPlanToTray = (plan: number) => {
void emit("tray://get_powerplan", plan)
}
const setAutoCloseGame = (enabled: boolean) => {
toolStore.state.autoCloseGame = enabled
}
const setVideoSetting = (setting: VideoSetting) => {
toolStore.state.videoSetting = setting
}
const getVideoConfig = async (steam_dir: string, steam_id32: number) => {
try {
const video = await invoke<VideoSetting>("get_cs2_video_config", { steamDir: steam_dir, steamId32: steam_id32 })
// console.log(video)
setVideoSetting(video)
} catch (error) {
console.error("读取视频配置失败:", error)
// 如果文件不存在或读取失败,使用默认配置或保持当前配置
// 不抛出错误,避免影响其他功能
}
}
const setVideoConfig = async (steam_dir: string, steam_id32: number, video_config: VideoSetting) => {
// console.log(video_config.videocfg_hdr_detail)
await invoke("set_cs2_video_config", { steamDir: steam_dir, steamId32: steam_id32, videoConfig: video_config })
}
const addLaunchOption = (option: LaunchOption) => {
// 限制最高10个
if (toolStore.state.launchOptions.length >= 10) {
return
}
toolStore.state.launchOptions = [...toolStore.state.launchOptions, option]
// 同步更新托盘
sendCurrentLaunchOptionToTray(toolStore.state.launchIndex)
}
const setIsGameRunning = (running: boolean) => {
toolStore.state.isGameRunning = running
}
const checkGameRunning = async (): Promise<boolean> => {
try {
const result = await invoke<boolean>("check_process_running", {
processName: "cs2.exe",
}).catch(() => false)
setIsGameRunning(result)
return result
} catch {
setIsGameRunning(false)
return false
}
}
const resetToolStore = () => {
setLaunchOptions(defaultValue.launchOptions)
setLaunchIndex(defaultValue.launchIndex)
setPowerPlan(defaultValue.powerPlan)
setAutoCloseGame(defaultValue.autoCloseGame)
setVideoSetting(defaultValue.videoSetting)
setIsGameRunning(defaultValue.isGameRunning)
}

92
src/utils/auth.ts Normal file
View File

@@ -0,0 +1,92 @@
import { open } from "@tauri-apps/plugin-shell"
import { createClient } from "@/utils/supabase/client"
import type { Session } from "@supabase/supabase-js"
/**
* 打开网页端登录页面
*/
export async function openLoginPage() {
const loginUrl = "https://cstb.upup.cool/auth/login?redirect=cstb://auth"
await open(loginUrl)
}
/**
* 打开网页端注册页面
*/
export async function openSignupPage() {
const signupUrl = "https://cstb.upup.cool/auth/signup?redirect=cstb://auth"
await open(signupUrl)
}
/**
* 处理 deep-link 回调中的认证信息
* @param url - deep-link URL格式: cstb://auth?access_token=xxx&refresh_token=xxx 或 cstb://auth?session=xxx
*/
export async function handleAuthCallback(url: string): Promise<Session | null> {
try {
const urlObj = new URL(url)
const params = new URLSearchParams(urlObj.search)
// 方式1: 从 URL 参数中获取 access_token 和 refresh_token
const accessToken = params.get("access_token")
const refreshToken = params.get("refresh_token")
if (accessToken && refreshToken) {
const supabase = createClient()
const { data, error } = await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken,
})
if (error) {
console.error("Error setting session:", error)
return null
}
return data.session
}
// 方式2: 从 session 参数中获取(如果网页端返回的是完整 session
const sessionParam = params.get("session")
if (sessionParam) {
try {
const session = JSON.parse(decodeURIComponent(sessionParam)) as Session
const supabase = createClient()
const { data, error } = await supabase.auth.setSession({
access_token: session.access_token,
refresh_token: session.refresh_token,
})
if (error) {
console.error("Error setting session:", error)
return null
}
return data.session
} catch (e) {
console.error("Error parsing session:", e)
return null
}
}
// 方式3: 如果网页端通过 code 参数返回PKCE flow
const code = params.get("code")
if (code) {
const supabase = createClient()
const { data, error } = await supabase.auth.exchangeCodeForSession(code)
if (error) {
console.error("Error exchanging code for session:", error)
return null
}
return data.session
}
return null
} catch (error) {
console.error("Error handling auth callback:", error)
return null
}
}

View File

@@ -8,7 +8,6 @@ module.exports = {
theme: {
extend: {},
},
plugins: [],
darkMode: "class",
plugins: [heroui()],
}

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. **当前状态**: 已提取基础模块,主组件重构需要继续完成

View File

@@ -16,7 +16,7 @@
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"baseUrl": ".",
"paths": {
@@ -35,7 +35,8 @@
"**/*.ts",
"**/*.tsx",
"*.js",
".next/types/**/*.ts"
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules",