Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
695d246a77 | ||
|
|
42f49d784e | ||
|
|
843fc03ddc | ||
|
|
f7efcb7fc9 | ||
|
|
388c74831e | ||
|
|
0f938f6f3e | ||
|
|
812bc64b6f | ||
|
|
4b7735575a | ||
|
|
5e663dc79e | ||
|
|
cd19faba47 | ||
|
|
4c151c3dd5 | ||
|
|
11afc6dc9e | ||
|
|
c50fedd305 | ||
|
|
6103164e37 | ||
|
|
c8d8339f30 | ||
|
|
41105d3bab | ||
|
|
e824455577 | ||
|
|
e146fbe393 | ||
|
|
e7e0bbd953 | ||
|
|
7e097cc9cc | ||
|
|
8bef96bb02 | ||
|
|
f7c9e455f7 | ||
|
|
9f29857fd3 | ||
|
|
dcac1295c6 | ||
|
|
b7f0d0b0cc | ||
|
|
ae567eece7 | ||
|
|
cce56eaf5e | ||
|
|
e774b31396 | ||
|
|
72eef189da | ||
|
|
8eeb7347a2 | ||
|
|
4c0c33382f | ||
|
|
8550887bfb | ||
|
|
ce410f7a26 | ||
|
|
c19adcc3f8 | ||
|
|
6710fe1754 | ||
|
|
41008cf13c | ||
|
|
ea0a42dc43 | ||
|
|
543c3344d1 | ||
|
|
d6ce9bd5f3 | ||
|
|
ae67e90745 | ||
|
|
67b6c74907 | ||
|
|
a5f5a2a5ff | ||
|
|
84d0cd7473 | ||
|
|
0c306b658e | ||
|
|
44fcd81643 | ||
|
|
35ecf4ce0a | ||
|
|
7012f0d9fe | ||
|
|
529ae547e0 | ||
|
|
d814b0a3fa | ||
|
|
3dc271866f | ||
|
|
c5330aec8f | ||
|
|
c67c358354 | ||
|
|
1d23c29ba8 | ||
|
|
dbedf25f0c | ||
|
|
f9ff43f698 | ||
|
|
a66100bfaf | ||
|
|
5b7f763221 | ||
|
|
4086fa88c2 | ||
|
|
b6dbbf94ec | ||
|
|
bbcaf8e0d1 | ||
|
|
faec03afb1 | ||
|
|
7ec20984c4 | ||
|
|
dabbab9f3e | ||
|
|
0e7e6dd3ba | ||
|
|
a10cf8eddf | ||
|
|
63172f12bc | ||
|
|
114def0b96 | ||
|
|
93cda8dc85 | ||
|
|
afa7355f4d | ||
|
|
e29b48b98c | ||
|
|
21cdc7c32d | ||
|
|
27439593b4 | ||
|
|
ed040aadf5 | ||
|
|
8f885a5412 | ||
|
|
ee03bf0160 | ||
|
|
e0a84a0570 | ||
|
|
66d62b970a | ||
|
|
0a78a9d056 | ||
|
|
0b65cdb129 | ||
|
|
e2d8f3effd | ||
|
|
7a99672317 |
@@ -51,6 +51,9 @@
|
||||
"@typescript-eslint/no-unsafe-member-access": "warn",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"jsx-a11y/click-events-have-key-events": "off"
|
||||
"jsx-a11y/click-events-have-key-events": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "warn",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-base-to-string": "warn"
|
||||
}
|
||||
}
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -38,4 +38,11 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
|
||||
.env
|
||||
.env.*
|
||||
.env.*
|
||||
|
||||
temp/
|
||||
src-tauri/temp/
|
||||
log/
|
||||
.tauri/
|
||||
todo/
|
||||
next-env.d.ts
|
||||
|
||||
@@ -1 +1 @@
|
||||
22
|
||||
25
|
||||
|
||||
119
.vscode/tasks.json
vendored
119
.vscode/tasks.json
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
217
README.md
217
README.md
@@ -1,216 +1,3 @@
|
||||
# Tauri + Next.js Template
|
||||
# CS工具箱
|
||||
|
||||

|
||||
|
||||
This is a [Tauri](https://tauri.app/) project template using [Next.js](https://nextjs.org/),
|
||||
bootstrapped by combining [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app)
|
||||
and [`create tauri-app`](https://tauri.app/v1/guides/getting-started/setup).
|
||||
|
||||
This template uses [`pnpm`](https://pnpm.io/) as the Node.js dependency
|
||||
manager.
|
||||
|
||||
## Template Features
|
||||
|
||||
- TypeScript frontend using Next.js React framework
|
||||
- [TailwindCSS](https://tailwindcss.com/) as a utility-first atomic CSS framework
|
||||
- The example page in this template app has been updated to use only TailwindCSS
|
||||
- While not included by default, consider using
|
||||
[React Aria components](https://react-spectrum.adobe.com/react-aria/index.html)
|
||||
and/or [HeadlessUI components](https://headlessui.com/) for completely unstyled and
|
||||
fully accessible UI components, which integrate nicely with TailwindCSS
|
||||
- Opinionated formatting and linting already setup and enabled
|
||||
- [ESLint](https://eslint.org/) for pure React + TypeScript linting, and
|
||||
[Biome](https://biomejs.dev/) for a combination of fast formatting, linting, and
|
||||
import sorting of JavaScript and TypeScript code
|
||||
- [clippy](https://github.com/rust-lang/rust-clippy) and
|
||||
[rustfmt](https://github.com/rust-lang/rustfmt) for Rust code
|
||||
- GitHub Actions to check code formatting and linting for both TypeScript and Rust
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Running development server and use Tauri window
|
||||
|
||||
After cloning for the first time, set up git pre-commit hooks:
|
||||
|
||||
```shell
|
||||
pnpm prepare
|
||||
```
|
||||
|
||||
To develop and run the frontend in a Tauri window:
|
||||
|
||||
```shell
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
This will load the Next.js frontend directly in a Tauri webview window, in addition to
|
||||
starting a development server on `localhost:3000`.
|
||||
|
||||
### Building for release
|
||||
|
||||
To export the Next.js frontend via SSG and build the Tauri application for release:
|
||||
|
||||
```shell
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Please remember to change the bundle identifier in
|
||||
`tauri.conf.json > tauri > bundle > identifier`, as the default value will yield an
|
||||
error that prevents you from building the application for release.
|
||||
|
||||
### Source structure
|
||||
|
||||
Next.js frontend source files are located in `src/` and Tauri Rust application source
|
||||
files are located in `src-tauri/`. Please consult the Next.js and Tauri documentation
|
||||
respectively for questions pertaining to either technology.
|
||||
|
||||
## Caveats
|
||||
|
||||
### Static Site Generation / Pre-rendering
|
||||
|
||||
Next.js is a great React frontend framework which supports server-side rendering (SSR)
|
||||
as well as static site generation (SSG or pre-rendering). For the purposes of creating a
|
||||
Tauri frontend, only SSG can be used since SSR requires an active Node.js server.
|
||||
|
||||
Using Next.js and SSG helps to provide a quick and performant single-page-application
|
||||
(SPA) frontend experience. More information regarding this can be found here:
|
||||
https://nextjs.org/docs/basic-features/pages#pre-rendering
|
||||
|
||||
### `next/image`
|
||||
|
||||
The [`next/image` component](https://nextjs.org/docs/basic-features/image-optimization)
|
||||
is an enhancement over the regular `<img>` HTML element with additional optimizations
|
||||
built in. However, because we are not deploying the frontend onto Vercel directly, some
|
||||
optimizations must be disabled to properly build and export the frontend via SSG.
|
||||
As such, the
|
||||
[`unoptimized` property](https://nextjs.org/docs/api-reference/next/image#unoptimized)
|
||||
is set to true for the `next/image` component in the `next.config.js` configuration.
|
||||
This will allow the image to be served as-is from source, without
|
||||
changes to its quality, size, or format.
|
||||
|
||||
### error[E0554]: `#![feature]` may not be used on the stable release channel
|
||||
|
||||
If you are getting this issue when trying to run `pnpm tauri dev`, it may be that you
|
||||
have a newer version of a Rust dependency that uses an unstable feature.
|
||||
`pnpm tauri build` should still work for production builds, but to get the dev command
|
||||
working, either downgrade the dependency or use Rust nightly via
|
||||
`rustup override set nightly`.
|
||||
|
||||
### ReferenceError: navigator is not defined
|
||||
|
||||
If you are using Tauri's `invoke` function or any OS related Tauri function from within
|
||||
JavaScript, you may encounter this error when importing the function in a global,
|
||||
non-browser context. This is due to the nature of Next.js' dev server effectively
|
||||
running a Node.js server for SSR and hot module replacement (HMR), and Node.js does not
|
||||
have a notion of `window` or `navigator`.
|
||||
|
||||
#### Solution 1 - Dependency Injection (may not always work)
|
||||
|
||||
Make sure that you are calling these functions within the browser context, e.g. within a
|
||||
React component inside a `useEffect` hook when the DOM actually exists by then. If you
|
||||
are trying to use a Tauri function in a generalized utility source file, a workaround is
|
||||
to use dependency injection for the function itself to delay the actual importing of the
|
||||
real function (see example below for more info).
|
||||
|
||||
Example using Tauri's `invoke` function:
|
||||
|
||||
`src/lib/some_tauri_functions.ts` (problematic)
|
||||
|
||||
```typescript
|
||||
// Generalized file containing all the invoke functions we need to fetch data from Rust
|
||||
import { invoke } from "@tauri-apps/api/tauri"
|
||||
|
||||
const loadFoo = (): Promise<string> => {
|
||||
return invoke<string>("invoke_handler_foo")
|
||||
}
|
||||
|
||||
const loadBar = (): Promise<string> => {
|
||||
return invoke<string>("invoke_handler_bar")
|
||||
}
|
||||
|
||||
const loadBaz = (): Promise<string> => {
|
||||
return invoke<string>("invoke_handler_baz")
|
||||
}
|
||||
|
||||
// and so on ...
|
||||
```
|
||||
|
||||
`src/lib/some_tauri_functions.ts` (fixed)
|
||||
|
||||
```typescript
|
||||
// Generalized file containing all the invoke functions we need to fetch data from Rust
|
||||
//
|
||||
// We apply the idea of dependency injection to use a supplied invoke function as a
|
||||
// function argument, rather than directly referencing the Tauri invoke function.
|
||||
// Hence, don't import invoke globally in this file.
|
||||
//
|
||||
// import { invoke } from "@tauri-apps/api/tauri" <-- remove this!
|
||||
//
|
||||
|
||||
import { InvokeArgs } from "@tauri-apps/api/tauri"
|
||||
type InvokeFunction = <T>(cmd: string, args?: InvokeArgs | undefined) => Promise<T>
|
||||
|
||||
const loadFoo = (invoke: InvokeFunction): Promise<string> => {
|
||||
return invoke<string>("invoke_handler_foo")
|
||||
}
|
||||
|
||||
const loadBar = (invoke: InvokeFunction): Promise<string> => {
|
||||
return invoke<string>("invoke_handler_bar")
|
||||
}
|
||||
|
||||
const loadBaz = (invoke: InvokeFunction): Promise<string> => {
|
||||
return invoke<string>("invoke_handler_baz")
|
||||
}
|
||||
|
||||
// and so on ...
|
||||
```
|
||||
|
||||
Then, when using `loadFoo`/`loadBar`/`loadBaz` within your React components, import the
|
||||
invoke function from `@tauri-apps/api` and pass `invoke` into the loadXXX function as
|
||||
the `InvokeFunction` argument. This should allow the actual Tauri API to be bundled
|
||||
only within the context of a React component, so it should not be loaded by Next.js upon
|
||||
initial startup until the browser has finished loading the page.
|
||||
|
||||
#### Solution 2: Wrap Tauri API behind dynamic `import()`
|
||||
|
||||
Since the Tauri API needs to read from the browser's `window` and `navigator` object,
|
||||
this data does not exist in a Node.js and hence SSR environment. One can create an
|
||||
exported function that wraps the Tauri API behind a dynamic runtime `import()` call.
|
||||
|
||||
Example: create a `src/lib/tauri.ts` to re-export `invoke`
|
||||
|
||||
```typescript
|
||||
import type { InvokeArgs } from "@tauri-apps/api/tauri"
|
||||
|
||||
const isNode = (): boolean =>
|
||||
Object.prototype.toString.call(typeof process !== "undefined" ? process : 0) ===
|
||||
"[object process]"
|
||||
|
||||
export async function invoke<T>(
|
||||
cmd: string,
|
||||
args?: InvokeArgs | undefined,
|
||||
): Promise<T> {
|
||||
if (isNode()) {
|
||||
// This shouldn't ever happen when React fully loads
|
||||
return Promise.resolve(undefined as unknown as T)
|
||||
}
|
||||
const tauriAppsApi = await import("@tauri-apps/api")
|
||||
const tauriInvoke = tauriAppsApi.invoke
|
||||
return tauriInvoke(cmd, args)
|
||||
}
|
||||
```
|
||||
|
||||
Then, instead of importing `import { invoke } from "@tauri-apps/api/tauri"`, use invoke
|
||||
from `import { invoke } from "@/lib/tauri"`.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and
|
||||
API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
And to learn more about Tauri, take a look at the following resources:
|
||||
|
||||
- [Tauri Documentation - Guides](https://tauri.app/v1/guides/) - learn about the Tauri
|
||||
toolkit.
|
||||
> CS Toolbox
|
||||
|
||||
248
UPDATE_API.md
Normal file
248
UPDATE_API.md
Normal 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
181
UPDATE_USAGE.md
Normal 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 框架文档
|
||||
147
docs/auth-integration.md
Normal file
147
docs/auth-integration.md
Normal 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. **回调参数错误**: 检查网页端传递的参数格式是否正确
|
||||
|
||||
271
docs/web-integration-example.md
Normal file
271
docs/web-integration-example.md
Normal 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
1
next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
87
package.json
87
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cstb-next",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.6",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "Purp1e",
|
||||
@@ -12,65 +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-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.0",
|
||||
"@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",
|
||||
"swr": "^2.3.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"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.11",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.27.0",
|
||||
"@typescript-eslint/parser": "^8.27.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": [
|
||||
|
||||
@@ -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
93
scripts/README.md
Normal 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` 参数匹配。
|
||||
|
||||
318
scripts/generate-latest-json.js
Normal file
318
scripts/generate-latest-json.js
Normal 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));
|
||||
|
||||
142
scripts/rename-build-artifacts.js
Normal file
142
scripts/rename-build-artifacts.js
Normal 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);
|
||||
}
|
||||
|
||||
2
src-tauri/.gitignore
vendored
2
src-tauri/.gitignore
vendored
@@ -1,3 +1,3 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/target/
|
||||
3779
src-tauri/Cargo.lock
generated
3779
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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.0"
|
||||
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,5 +77,7 @@ default = [ "custom-protocol" ]
|
||||
custom-protocol = [ "tauri/custom-protocol" ]
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
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"
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
],
|
||||
"permissions": [
|
||||
"global-shortcut:default",
|
||||
"theme:default",
|
||||
"store:default",
|
||||
"store:allow-set",
|
||||
"store:allow-get-store",
|
||||
@@ -27,6 +26,12 @@
|
||||
"deep-link:allow-get-current",
|
||||
"autostart:default",
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable"
|
||||
"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"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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"],"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
BIN
src-tauri/resources/csda.exe
Normal file
BIN
src-tauri/resources/csda.exe
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,18 @@
|
||||
)]
|
||||
|
||||
use tauri::Manager;
|
||||
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_mica;
|
||||
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};
|
||||
|
||||
@@ -36,21 +40,28 @@ 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, Some(vec![]) /* arbitrary number of args to pass to your app */))
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
MacosLauncher::LaunchAgent,
|
||||
Some(vec!["hidden"]), /* arbitrary number of args to pass to your app */
|
||||
))
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
@@ -60,9 +71,51 @@ fn main() {
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_system_info::init())
|
||||
// .plugin(tauri_plugin_store::Builder::default().build())
|
||||
// .plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.setup(|app| {
|
||||
.plugin(tauri_plugin_cli::init())
|
||||
// .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);
|
||||
|
||||
// Vibrant Window - 使用更优雅的错误处理和延迟应用
|
||||
#[cfg(target_os = "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")]
|
||||
{
|
||||
// 延迟应用 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");
|
||||
|
||||
// Deep Link
|
||||
#[cfg(desktop)]
|
||||
app.deep_link().register("cstb")?;
|
||||
@@ -74,35 +127,59 @@ fn main() {
|
||||
tray::create_tray(handle)?;
|
||||
}
|
||||
|
||||
// Get Window
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
// CLI
|
||||
match app.cli().matches() {
|
||||
// `matches` here is a Struct with { args, subcommand }.
|
||||
// `args` is `HashMap<String, ArgData>` where `ArgData` is a struct with { value, occurrences }.
|
||||
// `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
|
||||
{
|
||||
window.hide().unwrap();
|
||||
} else {
|
||||
window.show().unwrap();
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
// 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");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
apply_mica(&window, Some(false))
|
||||
.expect("Unsupported platform! 'apply_mica' is only supported on Windows");
|
||||
|
||||
// apply_blur(&window, Some((18, 18, 18, 0)))
|
||||
// .expect("Unsupported platform! 'apply_blur' is only supported on Windows");
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
cmds::greet,
|
||||
cmds::launch_game,
|
||||
cmds::kill_game,
|
||||
cmds::check_process_running,
|
||||
cmds::kill_steam,
|
||||
cmds::get_steam_path,
|
||||
cmds::get_cs_path,
|
||||
cmds::open_path,
|
||||
cmds::get_powerplan,
|
||||
cmds::set_powerplan,
|
||||
cmds::get_steam_users,
|
||||
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)
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
148
src-tauri/src/steam/watch.rs
Normal file
148
src-tauri/src/steam/watch.rs
Normal 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 文件");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,12 +18,15 @@ 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> {
|
||||
// [原理]
|
||||
@@ -30,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") {
|
||||
@@ -55,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)
|
||||
@@ -63,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,4 +42,4 @@ macro_rules! wrap_err {
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod common;
|
||||
pub mod macros;
|
||||
pub mod powerplan;
|
||||
pub mod powerplan;
|
||||
// pub mod updater; // 已迁移到官方 tauri-plugin-updater
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::process::Command;
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
// const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
|
||||
@@ -38,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)
|
||||
@@ -45,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: {}",
|
||||
@@ -56,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))?;
|
||||
|
||||
@@ -1,12 +1,175 @@
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Manager, Runtime,
|
||||
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 quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&quit_i])?;
|
||||
// 托盘菜单项目
|
||||
let separator = &PredefinedMenuItem::separator(app).unwrap();
|
||||
|
||||
let show_i = &MenuItem::with_id(app, "show", "显示主界面", true, None::<&str>)?;
|
||||
let quit_i = &MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||
|
||||
let kill_game_i = &MenuItem::with_id(app, "kill_game", "关闭CS2", true, None::<&str>)?;
|
||||
let kill_steam_i = &MenuItem::with_id(app, "kill_steam", "关闭Steam", true, None::<&str>)?;
|
||||
|
||||
let launch_ww_i = &MenuItem::with_id(app, "launch_ww", "启动国际服", true, None::<&str>)?;
|
||||
let launch_pw_i = &MenuItem::with_id(app, "launch_pw", "启动国服", true, None::<&str>)?;
|
||||
|
||||
let power_plan_extreme = CheckMenuItem::with_id(
|
||||
app,
|
||||
"power_plan_extreme",
|
||||
"卓越性能",
|
||||
true,
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let power_plan_high =
|
||||
CheckMenuItem::with_id(app, "power_plan_high", "高性能", true, false, None::<&str>)?;
|
||||
let power_plan_balanced = CheckMenuItem::with_id(
|
||||
app,
|
||||
"power_plan_balanced",
|
||||
"平衡",
|
||||
true,
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let power_plan_powersave = CheckMenuItem::with_id(
|
||||
app,
|
||||
"power_plan_powersave",
|
||||
"节能",
|
||||
true,
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
|
||||
|
||||
// 创建启动项子菜单(初始为空,后续会动态更新)
|
||||
let launch_option_submenu = Submenu::with_id(app, "launch_option_submenu", "启动项: 游戏", true)?;
|
||||
|
||||
// 创建托盘菜单
|
||||
let menu = Menu::with_items(
|
||||
app,
|
||||
&[
|
||||
&power_plan_extreme,
|
||||
&power_plan_high,
|
||||
&power_plan_balanced,
|
||||
&power_plan_powersave,
|
||||
separator,
|
||||
&launch_option_submenu,
|
||||
launch_ww_i,
|
||||
launch_pw_i,
|
||||
separator,
|
||||
kill_game_i,
|
||||
kill_steam_i,
|
||||
separator,
|
||||
show_i,
|
||||
quit_i,
|
||||
],
|
||||
)?;
|
||||
|
||||
let _ = app.listen("tray://get_powerplan", move |event| {
|
||||
if let Ok(payload) = event.payload().parse::<i32>() {
|
||||
match payload {
|
||||
x if x == PowerPlanMode::Other as i32 => {
|
||||
let _ = power_plan_powersave.set_checked(false);
|
||||
let _ = power_plan_balanced.set_checked(false);
|
||||
let _ = power_plan_high.set_checked(false);
|
||||
let _ = power_plan_extreme.set_checked(false);
|
||||
}
|
||||
x if x == PowerPlanMode::PowerSaving as i32 => {
|
||||
let _ = power_plan_powersave.set_checked(true);
|
||||
let _ = power_plan_balanced.set_checked(false);
|
||||
let _ = power_plan_high.set_checked(false);
|
||||
let _ = power_plan_extreme.set_checked(false);
|
||||
}
|
||||
x if x == PowerPlanMode::Balanced as i32 => {
|
||||
let _ = power_plan_powersave.set_checked(false);
|
||||
let _ = power_plan_balanced.set_checked(true);
|
||||
let _ = power_plan_high.set_checked(false);
|
||||
let _ = power_plan_extreme.set_checked(false);
|
||||
}
|
||||
x if x == PowerPlanMode::HighPerformance as i32 => {
|
||||
let _ = power_plan_powersave.set_checked(false);
|
||||
let _ = power_plan_balanced.set_checked(false);
|
||||
let _ = power_plan_high.set_checked(true);
|
||||
let _ = power_plan_extreme.set_checked(false);
|
||||
}
|
||||
x if x == PowerPlanMode::Extreme as i32 => {
|
||||
let _ = power_plan_powersave.set_checked(false);
|
||||
let _ = power_plan_balanced.set_checked(false);
|
||||
let _ = power_plan_high.set_checked(false);
|
||||
let _ = power_plan_extreme.set_checked(true);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 监听启动项列表更新事件
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _ = TrayIconBuilder::with_id("tray")
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
@@ -16,7 +179,49 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
// Add more events here
|
||||
"show" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"launch_ww" => {
|
||||
let _ = app.emit("tray://launch_game", "worldwide");
|
||||
}
|
||||
"launch_pw" => {
|
||||
let _ = app.emit("tray://launch_game", "perfectworld");
|
||||
}
|
||||
"kill_game" => {
|
||||
let _ = app.emit("tray://kill_game", None::<()>);
|
||||
}
|
||||
"kill_steam" => {
|
||||
let _ = app.emit("tray://kill_steam", None::<()>);
|
||||
}
|
||||
"power_plan_extreme" => {
|
||||
let _ = app.emit("tray://set_powerplan", PowerPlanMode::Extreme as i32);
|
||||
// let _ = power_plan_extreme.set_checked(true);
|
||||
}
|
||||
"power_plan_high" => {
|
||||
let _ = app.emit(
|
||||
"tray://set_powerplan",
|
||||
PowerPlanMode::HighPerformance as i32,
|
||||
);
|
||||
// let _ = power_plan_high.set_checked(true);
|
||||
}
|
||||
"power_plan_balanced" => {
|
||||
let _ = app.emit("tray://set_powerplan", PowerPlanMode::Balanced as i32);
|
||||
// let _ = power_plan_balanced.set_checked(true);
|
||||
}
|
||||
"power_plan_powersave" => {
|
||||
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| {
|
||||
@@ -37,68 +242,3 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Tray Menu
|
||||
// let quit = CustomMenuItem::new("quit".to_string(), "Quit");
|
||||
// let hide = CustomMenuItem::new("hide".to_string(), "Hide");
|
||||
// let tray_menu = SystemTrayMenu::new() // insert the menu items here
|
||||
// .add_item(hide)
|
||||
// .add_item(quit);
|
||||
// .add_native_item(SystemTrayMenuItem::Separator)
|
||||
// let toggle = MenuItemBuilder::with_id("toggle", "Toggle").build(app)?;
|
||||
// let menu = MenuBuilder::new(app).items(&[&toggle]).build()?;
|
||||
|
||||
// Setup Tray
|
||||
// let tray = tauri::tray::TrayIconBuilder::with_id("my-tray").build(app)?;
|
||||
// let _ = TrayIconBuilder::new()
|
||||
// .menu(&menu)
|
||||
// .on_menu_event(move |_, event| {
|
||||
// match event.id().as_ref() {
|
||||
// "toggle" => {
|
||||
// println!("toggle clicked");
|
||||
// }
|
||||
// _ => (),
|
||||
// }
|
||||
// // match event {
|
||||
// // SystemTrayEvent::LeftClick { position: _, size: _, .. } => {
|
||||
// // let window = app.get_window("main").unwrap();
|
||||
// // window.show().unwrap();
|
||||
// // window.set_focus().unwrap();
|
||||
|
||||
// // // thread::sleep(Duration::from_millis(100));
|
||||
// // // window.set_always_on_top(false).unwrap();
|
||||
// // println!("system tray received a left click");
|
||||
// // }
|
||||
// // SystemTrayEvent::RightClick { position: _, size: _, .. } => {
|
||||
// // // let window = app.get_window("main").unwrap();
|
||||
// // // window.hide().unwrap();
|
||||
// // println!("system tray received a right click");
|
||||
// // }
|
||||
// // SystemTrayEvent::DoubleClick { position: _, size: _, .. } => {
|
||||
// // println!("system tray received a double click");
|
||||
// // }
|
||||
// // SystemTrayEvent::MenuItemClick { id, .. } =>
|
||||
// // match id.as_str() {
|
||||
// // "quit" => {
|
||||
// // std::process::exit(0);
|
||||
// // }
|
||||
// // "hide" => {
|
||||
// // let window = app.get_window("main").unwrap();
|
||||
// // window.hide().unwrap();
|
||||
// // }
|
||||
// // _ => {}
|
||||
// // }
|
||||
// // _ => {}
|
||||
// // }
|
||||
// })
|
||||
// .on_tray_icon_event(|tray, event| {
|
||||
// if event.click_type == ClickType::Left {
|
||||
// let app = tray.app_handle();
|
||||
// if let Some(webview_window) = app.get_webview_window("main") {
|
||||
// let _ = webview_window.show();
|
||||
// let _ = webview_window.set_focus();
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// .build(app)
|
||||
// .unwrap();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: ®ex::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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.4",
|
||||
"version": "0.0.7-beta.1",
|
||||
"identifier": "upup.cool",
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
@@ -51,6 +61,23 @@
|
||||
"cstb"
|
||||
]
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"description": "CS Toolbox CLI",
|
||||
"args": [
|
||||
{
|
||||
"name": "hidden",
|
||||
"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": {
|
||||
@@ -70,7 +97,8 @@
|
||||
"transparent": true,
|
||||
"theme": null,
|
||||
"hiddenTitle": true,
|
||||
"titleBarStyle": "Transparent"
|
||||
"titleBarStyle": "Transparent",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
126
src/app/(main)/dynamic/page.tsx
Normal file
126
src/app/(main)/dynamic/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ import LaunchOption from "@/components/cstb/LaunchOption"
|
||||
import Notice from "@/components/cstb/Notice"
|
||||
import PowerPlan from "@/components/cstb/PowerPlan"
|
||||
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 />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,17 +3,11 @@ import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardIcon,
|
||||
CardTool,
|
||||
CardIcon
|
||||
} from "@/components/window/Card"
|
||||
import { ToolButton } from "@/components/window/ToolButton"
|
||||
import { cn } from "@heroui/react"
|
||||
import {
|
||||
AssemblyLine,
|
||||
HardDisk,
|
||||
SettingConfig,
|
||||
UploadOne,
|
||||
Videocamera,
|
||||
AssemblyLine, SettingConfig, Videocamera
|
||||
} from "@icon-park/react"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
// import { platform } from "@tauri-apps/plugin-os"
|
||||
@@ -49,7 +43,8 @@ export default function PreferenceLayout({
|
||||
<Videocamera /> 录像
|
||||
</CardIcon>
|
||||
|
||||
<CardTool>
|
||||
{/* TODO 完善云同步等功能 */}
|
||||
{/* <CardTool>
|
||||
<ToolButton>
|
||||
<UploadOne />
|
||||
云同步
|
||||
@@ -58,7 +53,7 @@ export default function PreferenceLayout({
|
||||
<HardDisk />
|
||||
保存
|
||||
</ToolButton>
|
||||
</CardTool>
|
||||
</CardTool> */}
|
||||
</CardHeader>
|
||||
<CardBody>{children}</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"use client"
|
||||
export default function Page() {
|
||||
return <>Replay</>
|
||||
return (
|
||||
<section className="flex flex-col gap-4 overflow-hidden">
|
||||
<>Replay</>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -1,21 +1,99 @@
|
||||
"use client"
|
||||
import { useSteamStore } from "@/store/steam"
|
||||
import { init } from "@/store"
|
||||
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, useThrottleFn } from "ahooks"
|
||||
import { useEffect } from "react"
|
||||
import "./globals.css"
|
||||
import Providers from "./providers"
|
||||
import { init } from "@/store"
|
||||
import { useDebounce } from "ahooks"
|
||||
import { PowerPlans } from "@/components/cstb/PowerPlan"
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const steam = useSteamStore()
|
||||
const tool = useToolStore()
|
||||
|
||||
useEffect(() => {
|
||||
void init()
|
||||
})
|
||||
|
||||
void listen<string>("tray://launch_game", async (event) => {
|
||||
// 验证路径
|
||||
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 () => {
|
||||
await invoke("kill_steam")
|
||||
addToast({ title: "已关闭Steam" })
|
||||
})
|
||||
|
||||
void listen("tray://kill_game", async () => {
|
||||
await invoke("kill_game")
|
||||
addToast({ title: "已关闭CS2" })
|
||||
})
|
||||
|
||||
void listen<number>("tray://set_powerplan", async (event) => {
|
||||
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 steam = useSteamStore()
|
||||
const debounceSteamDir = useDebounce(steam.state.steamDir, {wait: 500, leading: true, trailing: true, maxWait: 2500})
|
||||
const debounceCs2Dir = useDebounce(steam.state.cs2Dir, {wait: 500, leading: true, trailing: true, maxWait: 2500})
|
||||
const debounceSteamDirValid = useDebounce(steam.state.steamDirValid, {wait: 500, leading: true, trailing: true, maxWait: 2500})
|
||||
const debounceSteamDir = useDebounce(steam.state.steamDir, {
|
||||
wait: 500,
|
||||
leading: true,
|
||||
trailing: true,
|
||||
maxWait: 2500,
|
||||
})
|
||||
const debounceCs2Dir = useDebounce(steam.state.cs2Dir, {
|
||||
wait: 500,
|
||||
leading: true,
|
||||
trailing: true,
|
||||
maxWait: 2500,
|
||||
})
|
||||
const debounceSteamDirValid = useDebounce(steam.state.steamDirValid, {
|
||||
wait: 500,
|
||||
leading: true,
|
||||
trailing: true,
|
||||
maxWait: 2500,
|
||||
})
|
||||
useEffect(() => {
|
||||
void steam.checkSteamDirValid()
|
||||
}, [debounceSteamDir])
|
||||
@@ -23,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">
|
||||
|
||||
@@ -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>当前用户64位SteamID:{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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
142
src/components/auth/AuthButton.tsx
Normal file
142
src/components/auth/AuthButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
76
src/components/auth/AuthProvider.tsx
Normal file
76
src/components/auth/AuthProvider.tsx
Normal 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}</>
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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] || "",
|
||||
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] || "",
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
1261
src/components/cstb/FpsTest.tsx
Normal file
1261
src/components/cstb/FpsTest.tsx
Normal file
File diff suppressed because it is too large
Load Diff
31
src/components/cstb/FpsTest/components/BatchTestProgress.tsx
Normal file
31
src/components/cstb/FpsTest/components/BatchTestProgress.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
27
src/components/cstb/FpsTest/components/NoteCell.tsx
Normal file
27
src/components/cstb/FpsTest/components/NoteCell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
193
src/components/cstb/FpsTest/components/ResolutionConfig.tsx
Normal file
193
src/components/cstb/FpsTest/components/ResolutionConfig.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
94
src/components/cstb/FpsTest/components/TestConfigPanel.tsx
Normal file
94
src/components/cstb/FpsTest/components/TestConfigPanel.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
53
src/components/cstb/FpsTest/components/TestResultDisplay.tsx
Normal file
53
src/components/cstb/FpsTest/components/TestResultDisplay.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
156
src/components/cstb/FpsTest/components/TestResultsTable.tsx
Normal file
156
src/components/cstb/FpsTest/components/TestResultsTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
32
src/components/cstb/FpsTest/constants.ts
Normal file
32
src/components/cstb/FpsTest/constants.ts
Normal 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
|
||||
|
||||
3
src/components/cstb/FpsTest/hooks/useGameMonitor.ts
Normal file
3
src/components/cstb/FpsTest/hooks/useGameMonitor.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// 使用全局游戏状态监控,避免重复检测
|
||||
export { useGlobalGameMonitor as useGameMonitor } from "@/hooks/useGlobalGameMonitor"
|
||||
|
||||
41
src/components/cstb/FpsTest/hooks/useHardwareInfo.ts
Normal file
41
src/components/cstb/FpsTest/hooks/useHardwareInfo.ts
Normal 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
|
||||
}
|
||||
|
||||
113
src/components/cstb/FpsTest/hooks/useTestMonitor.ts
Normal file
113
src/components/cstb/FpsTest/hooks/useTestMonitor.ts
Normal 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
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
6
src/components/cstb/FpsTest/index.tsx
Normal file
6
src/components/cstb/FpsTest/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
"use client"
|
||||
// 导出重构后的FpsTest组件
|
||||
// 主组件文件已重构,使用提取的模块
|
||||
|
||||
export { FpsTest } from "../FpsTest"
|
||||
|
||||
287
src/components/cstb/FpsTest/services/resultReader.ts
Normal file
287
src/components/cstb/FpsTest/services/resultReader.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
172
src/components/cstb/FpsTest/services/testRunner.ts
Normal file
172
src/components/cstb/FpsTest/services/testRunner.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
25
src/components/cstb/FpsTest/types.ts
Normal file
25
src/components/cstb/FpsTest/types.ts
Normal 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
|
||||
}
|
||||
|
||||
235
src/components/cstb/FpsTest/utils/csv-export.ts
Normal file
235
src/components/cstb/FpsTest/utils/csv-export.ts
Normal 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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
43
src/components/cstb/FpsTest/utils/fps-metrics.ts
Normal file
43
src/components/cstb/FpsTest/utils/fps-metrics.ts
Normal 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 }
|
||||
}
|
||||
|
||||
66
src/components/cstb/FpsTest/utils/timestamp.ts
Normal file
66
src/components/cstb/FpsTest/utils/timestamp.ts
Normal 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()
|
||||
}
|
||||
|
||||
47
src/components/cstb/FpsTest/utils/vprof-parser.ts
Normal file
47
src/components/cstb/FpsTest/utils/vprof-parser.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { input, Textarea } 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,19 +21,70 @@ 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>
|
||||
<ToolButton>
|
||||
<Switch />
|
||||
切换模式
|
||||
{/* <Tooltip content="功能测试中,尚未实装" showArrow={true} delay={300}>
|
||||
<ToolButton>
|
||||
<Switch />
|
||||
切换模式
|
||||
</ToolButton>
|
||||
</Tooltip> */}
|
||||
<ToolButton onClick={() => setEditMode(!editMode)}>
|
||||
{editMode ? (
|
||||
<>
|
||||
<Save />
|
||||
保存
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Edit />
|
||||
修改
|
||||
</>
|
||||
)}
|
||||
</ToolButton>
|
||||
</CardTool>
|
||||
</CardHeader>
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
"use client"
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardIcon,
|
||||
CardTool,
|
||||
} from "@/components/window/Card"
|
||||
import { Card, CardBody, CardHeader, CardIcon, CardTool } from "@/components/window/Card"
|
||||
import { useAppStore } from "@/store/app"
|
||||
import { createClient } from "@/utils/supabase/client"
|
||||
import { Skeleton } from "@heroui/react"
|
||||
import { Refresh, VolumeNotice } from "@icon-park/react"
|
||||
import { Link, Skeleton } from "@heroui/react"
|
||||
import { Refresh, VolumeNotice, WebPage } from "@icon-park/react"
|
||||
import useSWR, { useSWRConfig } from "swr"
|
||||
import { ToolButton } from "../window/ToolButton"
|
||||
import { MarkdownRender } from "../markdown"
|
||||
|
||||
const Notice = () => {
|
||||
const { mutate } = useSWRConfig()
|
||||
@@ -23,6 +18,12 @@ const Notice = () => {
|
||||
<VolumeNotice /> 公告
|
||||
</CardIcon>
|
||||
<CardTool>
|
||||
<Link href="https://cstb.upup.cool" target="_blank" className="dark:text-white text-zinc-800" >
|
||||
<ToolButton>
|
||||
<WebPage />
|
||||
官网
|
||||
</ToolButton>
|
||||
</Link>
|
||||
<ToolButton onClick={() => mutate("/api/notice")}>
|
||||
<Refresh />
|
||||
刷新
|
||||
@@ -45,15 +46,13 @@ const NoticeBody = () => {
|
||||
.from("Notice")
|
||||
.select("created_at, content, url")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
const { data: notice /* , error */, isLoading } = useSWR(
|
||||
"/api/notice",
|
||||
noticeFetcher,
|
||||
)
|
||||
const { data: notice /* , error */, isLoading } = useSWR("/api/notice", noticeFetcher)
|
||||
|
||||
// if (error) return <>错误:{error}</>
|
||||
if (isLoading)
|
||||
@@ -64,11 +63,28 @@ const NoticeBody = () => {
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{notice?.content ||
|
||||
app.state.notice ||
|
||||
"不会真的有人要更新CSGO工具箱吧,不会吧不会吧 xswl"}
|
||||
</>
|
||||
<div className="flex flex-col h-full gap-2">
|
||||
<div className="">
|
||||
<MarkdownRender>
|
||||
{notice?.content ||
|
||||
app.state.notice ||
|
||||
"不会真的有人要更新CSGO工具箱吧,不会吧不会吧 xswl"}
|
||||
</MarkdownRender>
|
||||
</div>
|
||||
{/* {notice?.url && (
|
||||
<Button
|
||||
variant="flat"
|
||||
// color="default"
|
||||
as={Link}
|
||||
href={notice?.url}
|
||||
target="_blank"
|
||||
size="sm"
|
||||
className="bg-transparent w-fit"
|
||||
>
|
||||
传送门
|
||||
</Button>
|
||||
)} */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Key } from "@react-types/shared"
|
||||
import { useToolStore } from "@/store/tool"
|
||||
import { useEffect } from "react"
|
||||
|
||||
const PowerPlans = [
|
||||
export const PowerPlans = [
|
||||
{
|
||||
id: "0",
|
||||
title: "其他",
|
||||
@@ -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>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useSteamStore } from "@/store/steam"
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { onOpenUrl } from "@tauri-apps/plugin-deep-link"
|
||||
import { useDebounce } from "ahooks"
|
||||
import { useAppStore } from "@/store/app"
|
||||
|
||||
/**
|
||||
@@ -41,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()
|
||||
@@ -67,6 +65,7 @@ export function Prepare() {
|
||||
setLoading(false)
|
||||
}, 1200)
|
||||
}
|
||||
// router.push("/home")
|
||||
}, [inited])
|
||||
|
||||
const handleSelectSteamDir = async () => {
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import { FolderConversion, FolderPlus } from "@icon-park/react"
|
||||
import { Card, CardBody, CardHeader, CardIcon, CardTool } from "../window/Card"
|
||||
import { ToolButton } from "../window/ToolButton"
|
||||
import { Tooltip } from "@heroui/react"
|
||||
|
||||
const SmartTransfer = () => {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardIcon>
|
||||
<FolderConversion /> 智能中转
|
||||
</CardIcon>
|
||||
<CardTool>
|
||||
<ToolButton>
|
||||
<FolderPlus /> 选择文件{" "}
|
||||
</ToolButton>
|
||||
</CardTool>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex flex-col items-center justify-center w-full h-full p-5 text-lg font-medium transition rounded-lg cursor-pointer select-none bg-black/5 hover:bg-black/10">
|
||||
<p>点击或拖拽</p>
|
||||
<p>智能中转 .dem .cfg</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Tooltip content="功能测试中,尚未实装" showArrow={true} delay={300}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardIcon>
|
||||
<FolderConversion /> 智能中转
|
||||
</CardIcon>
|
||||
<CardTool>
|
||||
<ToolButton>
|
||||
<FolderPlus /> 选择文件{" "}
|
||||
</ToolButton>
|
||||
</CardTool>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex flex-col items-center justify-center w-full h-full p-5 text-lg font-medium transition rounded-lg cursor-pointer select-none bg-black/5 hover:bg-black/10">
|
||||
<p>点击或拖拽</p>
|
||||
<p>智能中转 .dem .cfg</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
339
src/components/cstb/UpdateChecker.tsx
Normal file
339
src/components/cstb/UpdateChecker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +1,376 @@
|
||||
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 } 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: "CMAA2抗锯齿", value: "关闭", options: ["关闭", "开启"] },
|
||||
{
|
||||
type: "",
|
||||
title: "多重采样抗锯齿",
|
||||
value: "2X MSAA",
|
||||
options: ["无", "CMAA2", "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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardIcon>
|
||||
<SettingConfig /> 视频设置
|
||||
{isGameRunning && (
|
||||
<Chip size="sm" color="warning" variant="flat" className="ml-2">
|
||||
游戏运行中
|
||||
</Chip>
|
||||
)}
|
||||
</CardIcon>
|
||||
<CardTool>
|
||||
{/* {tool.state.VideoSettings.map((option, index) => (
|
||||
@@ -64,22 +380,76 @@ const VideoSetting = () => {
|
||||
))} */}
|
||||
{edit && (
|
||||
<>
|
||||
<ToolButton>低</ToolButton>
|
||||
<ToolButton>中</ToolButton>
|
||||
<ToolButton>高</ToolButton>
|
||||
<ToolButton>非常高</ToolButton>
|
||||
<ToolButton>推荐</ToolButton>
|
||||
<ToolButton
|
||||
onClick={() => {
|
||||
addToast({ title: "测试中 功能完成后可应用设置到游戏" })
|
||||
}}
|
||||
>
|
||||
<Plus />
|
||||
应用
|
||||
<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={() => setEdit(!edit)}>
|
||||
<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 ? (
|
||||
<>
|
||||
<CloseSmall />
|
||||
@@ -92,6 +462,8 @@ const VideoSetting = () => {
|
||||
</>
|
||||
)}
|
||||
</ToolButton>
|
||||
|
||||
<ToolButton onClick={debouncedGetVideoConfig}>读取</ToolButton>
|
||||
<ToolButton onClick={() => setHide(!hide)}>
|
||||
{hide ? (
|
||||
<>
|
||||
@@ -115,53 +487,133 @@ const VideoSetting = () => {
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<CardBody>
|
||||
<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-1.5">
|
||||
<NumberInput
|
||||
value={tool.state.videoSetting.width}
|
||||
onValueChange={(value) => {
|
||||
tool.setVideoSetting({
|
||||
...tool.state.videoSetting,
|
||||
width: value,
|
||||
})
|
||||
}}
|
||||
radius="full"
|
||||
className="max-w-28"
|
||||
classNames={{ inputWrapper: "h-10" }}
|
||||
/>
|
||||
<NumberInput
|
||||
value={tool.state.videoSetting.height}
|
||||
onValueChange={(value) => {
|
||||
tool.setVideoSetting({
|
||||
...tool.state.videoSetting,
|
||||
height: value,
|
||||
})
|
||||
}}
|
||||
radius="full"
|
||||
className="max-w-28"
|
||||
classNames={{ inputWrapper: "h-10" }}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
{videoSettings.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"
|
||||
radius="full"
|
||||
className="min-w-36"
|
||||
fullWidth
|
||||
>
|
||||
{vid.options.map((opt, index) => (
|
||||
<Tab key={opt} title={opt} />
|
||||
))}
|
||||
</Tabs>
|
||||
{edit ? (
|
||||
// 编辑状态:显示完整的可编辑控件
|
||||
<ul className="flex flex-wrap gap-3 mt-1">
|
||||
<li className="flex flex-col gap-1.5">
|
||||
<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"
|
||||
className="w-20"
|
||||
classNames={{
|
||||
inputWrapper: "h-9 px-3",
|
||||
}}
|
||||
/>
|
||||
<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"
|
||||
className="w-20"
|
||||
classNames={{
|
||||
inputWrapper: "h-9 px-3",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{videoSettings(vconfig).map((vid, index) => (
|
||||
<li className="flex flex-col gap-1.5" key={index}>
|
||||
<span className="ml-2">{vid.title}</span>
|
||||
<Tabs
|
||||
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} titleValue={opt} />
|
||||
))}
|
||||
</Tabs>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
// 非编辑状态:显示精简的只读信息
|
||||
<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>
|
||||
)}
|
||||
|
||||
38
src/components/markdown/index.tsx
Normal file
38
src/components/markdown/index.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
// @ts-nocheck
|
||||
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 }) => (
|
||||
<Link href={href} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</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="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 }) {
|
||||
return (
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={components as any}>
|
||||
{children?.toString()}
|
||||
</Markdown>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
"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, useDisclosure } from "@heroui/react"
|
||||
import { Close, Minus, Moon, Refresh, RocketOne, Square, SunOne } from "@icon-park/react"
|
||||
import { addToast, Button, Link, Tooltip, useDisclosure } from "@heroui/react"
|
||||
import {
|
||||
Close,
|
||||
Communication,
|
||||
Minus,
|
||||
Moon,
|
||||
Refresh,
|
||||
RocketOne,
|
||||
Square,
|
||||
SunOne
|
||||
} from "@icon-park/react"
|
||||
import { type Theme, getCurrentWindow } from "@tauri-apps/api/window"
|
||||
import { /* relaunch, */ exit } from "@tauri-apps/plugin-process"
|
||||
import { useTheme } from "next-themes"
|
||||
@@ -11,6 +20,8 @@ import { usePathname, useRouter } from "next/navigation"
|
||||
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()
|
||||
@@ -19,10 +30,12 @@ const Nav = () => {
|
||||
await setTauriTheme(theme)
|
||||
}
|
||||
|
||||
const app = useAppStore()
|
||||
const close = async () => {
|
||||
// (await window.hideOnClose) ? getCurrent().hide() : exit();
|
||||
await saveAllNow()
|
||||
await exit()
|
||||
// await exit()
|
||||
if (app.state.hiddenOnClose) await window.getCurrentWindow().hide()
|
||||
else await exit()
|
||||
}
|
||||
|
||||
const minimize = async () => {
|
||||
@@ -42,30 +55,44 @@ const Nav = () => {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const app = useAppStore()
|
||||
|
||||
return (
|
||||
<nav className="absolute top-0 right-0 flex flex-row h-16 gap-0.5 p-4" data-tauri-drag-region>
|
||||
{pathname !== "/" && (
|
||||
<Tooltip content="启动页确认设置" showArrow={true} delay={300}>
|
||||
{pathname !== "/" && (
|
||||
<button
|
||||
type="button"
|
||||
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} 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-zinc-200/80 dark:hover:bg-zinc-100/10 active:scale-95"
|
||||
onClick={() => {
|
||||
app.setInited(false)
|
||||
if(pathname !== "/") router.push("/")
|
||||
}}
|
||||
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"))}
|
||||
>
|
||||
<RocketOne 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 cursor-pointer dark:text-white hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
|
||||
>
|
||||
<button type="button" className="cursor-pointer">
|
||||
<Communication size={16} className="cursor-pointer" />
|
||||
</button>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-0 transition duration-150 rounded hover:bg-zinc-200/80 dark:hover:bg-zinc-100/10 active:scale-95"
|
||||
onClick={() => (theme === "light" ? setAppTheme("dark") : setAppTheme("light"))}
|
||||
>
|
||||
{theme === "light" ? <SunOne size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
{/* <AuthButtonWrapper /> */}
|
||||
|
||||
<ResetModal />
|
||||
|
||||
@@ -73,24 +100,24 @@ const Nav = () => {
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-0 transition duration-150 rounded hover:bg-zinc-200/80 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-zinc-200/80 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-zinc-200/80 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>
|
||||
</>
|
||||
{/* )} */}
|
||||
@@ -119,13 +146,15 @@ function ResetModal() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-0 transition duration-150 rounded hover:bg-zinc-200/80 dark:hover:bg-zinc-100/10 active:scale-95"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<Refresh size={16} />
|
||||
</button>
|
||||
<Tooltip content="重置设置" showArrow={true} delay={300}>
|
||||
<button
|
||||
type="button"
|
||||
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} className="cursor-pointer" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
@@ -156,4 +185,8 @@ function ResetModal() {
|
||||
)
|
||||
}
|
||||
|
||||
function AuthButtonWrapper() {
|
||||
return <AuthButton />
|
||||
}
|
||||
|
||||
export default Nav
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,7 +69,7 @@ const Avatar = () => {
|
||||
<img
|
||||
src={
|
||||
steam.currentUser()?.avatar
|
||||
? `data:image/png;base64,${steam.currentUser()?.avatar || ''}`
|
||||
? `data:image/png;base64,${steam.currentUser()?.avatar || ""}`
|
||||
: "/logo_square.png"
|
||||
}
|
||||
alt="avatar"
|
||||
@@ -72,9 +86,10 @@ const Avatar = () => {
|
||||
const SideBar = () => {
|
||||
const app = useAppStore()
|
||||
|
||||
void getVersion().then((Value) => {
|
||||
app.setVersion(Value)
|
||||
})
|
||||
if (typeof window !== "undefined")
|
||||
void getVersion().then((Value) => {
|
||||
app.setVersion(Value)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -89,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>
|
||||
@@ -114,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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
65
src/hooks/useGlobalGameMonitor.ts
Normal file
65
src/hooks/useGlobalGameMonitor.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
import { store } from "@tauri-store/valtio"
|
||||
import { useSnapshot } from "valtio"
|
||||
import { DEFAULT_STORE_CONFIG } from "./config"
|
||||
import { enable, isEnabled, disable } from "@tauri-apps/plugin-autostart"
|
||||
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 = () => {
|
||||
@@ -23,20 +42,48 @@ 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, defaults: defaultValue });
|
||||
if (typeof window !== 'undefined') void launchStore.save()
|
||||
|
||||
const setVersion = (version: string) => {
|
||||
appStore.state.version = version
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -46,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) {
|
||||
@@ -56,11 +109,36 @@ const setAutoStart = (autoStart: boolean) => {
|
||||
appStore.state.autoStart = autoStart
|
||||
}
|
||||
|
||||
// 同步到 launchStore 使 start hidden 生效
|
||||
const setStartHidden = async (startHidden: boolean) => {
|
||||
appStore.state.startHidden = startHidden;
|
||||
await launchStore.set('hidden', startHidden);
|
||||
await launchStore.save();
|
||||
}
|
||||
|
||||
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
76
src/store/auth.ts
Normal 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
166
src/store/fps_test.ts
Normal 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
153
src/store/hardware.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,14 +104,25 @@ const currentUser = () => {
|
||||
}
|
||||
|
||||
const getUsers = async () => {
|
||||
const users = await invoke<SteamUser[]>("get_steam_users", { steamDir: steamStore.state.steamDir })
|
||||
console.log(users)
|
||||
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) => {
|
||||
const user = steamStore.state.users.at(index)
|
||||
console.log(index, user)
|
||||
if (user) {
|
||||
setUsers([
|
||||
user,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user