Compare commits

...

39 Commits

Author SHA1 Message Date
ikun0014
b91bf42981 2.1.1 2025-11-10 22:33:24 +08:00
ikun0014
dacaa1c62e update web ver 2025-11-10 22:33:22 +08:00
ikun0014
22619df037 fix: GreenLuma 2025-11-10 22:32:49 +08:00
ikun0014
840a99a1e9 2.1.0 2025-10-25 13:08:49 +08:00
ikun0014
ba3c60c775 feat: custom port 2025-10-25 13:08:40 +08:00
ikun0014
80628ad17a 更新版本 2025-10-12 17:05:27 +08:00
ikun0014
9880b90d19 2.0.9 2025-10-12 17:05:04 +08:00
ikun0014
3f4d327f6e 修复Nuitka编译未包含icon.jpg的问题
在dev.yml和release.yml的编译步骤中,新增--include-data-file参数,确保icon.jpg被正确打包进可执行文件,避免因缺失图标文件导致的运行异常。
2025-10-12 17:04:49 +08:00
ikun0014
361150b3bc 新增多语言支持并重构前端资源结构
引入英文和中文多语言支持,web 目录下静态资源和模板按语言分目录存放。新增 src/utils/i18n.py 实现国际化,main.py 增加多语言错误提示。CI/CD 工作流升级 Python 版本与 Nuitka 编译方式,提升兼容性和构建效率。
2025-10-12 16:32:36 +08:00
ikun0014
4f213f9514 Update about.html 2025-10-12 00:23:38 +08:00
ikun0014
56eab758cd 2.0.8 2025-10-11 23:31:11 +08:00
ikun0014
9c4890db37 Update about.html 2025-10-11 23:31:10 +08:00
ikun0014
ddd1cb7b6c Update oobe.html 2025-10-11 23:30:44 +08:00
ikun0014
839e922d79 2.0.7 2025-09-20 18:57:20 +08:00
ikun0014
2bd2f23838 Update version to v2.0.7 and remove license section
Bumped the displayed version in about.html from v2.0.6 to v2.0.7. Removed the commented-out project license agreement section from README.md for clarity.
2025-09-20 18:57:15 +08:00
ikun0014
9e68621e76 Remove 'verify=False' from HTTP client instantiations
Eliminated the 'verify=False' parameter from all httpx.Client and httpx.AsyncClient initializations to enforce SSL certificate verification. This improves security by ensuring HTTPS requests validate server certificates.
2025-09-20 18:56:29 +08:00
ikun0014
2a6a9421ca Refactor IP location check and update HTTP client config
Moved the IP location check logic to constants.py and set IS_CN at import time, removing the async check_ip method from OnekeyApp. Updated all httpx client instantiations to use verify=False for SSL verification. Adjusted imports and usages accordingly in web/app.py and main.py.
2025-09-20 18:55:49 +08:00
ikun0014
1a6cf47882 Refactor constants and update HTTP client config
Added type annotations to constants in constants.py and set IS_CN default to True. Updated main.py to use 'flag' instead of 'ip_flag' from API response. Removed 'verify=False' and 'proxy=None' from httpx.AsyncClient initialization in client.py for improved security and clarity.
2025-09-20 18:32:28 +08:00
ikun0014
09f94c5f11 2.0.6 2025-09-19 22:48:25 +08:00
ikun0014
5307dc0766 Update about.html 2025-09-19 22:48:24 +08:00
ikun0014
15b3caddcf Add region-based CDN selection and IP check
Introduces an IS_CN flag and logic to select Steam CDN endpoints based on region in constants.py. Adds an IP check in OnekeyApp to set the region flag at runtime. Also removes a commercial use warning from about.html and applies minor formatting changes to index.html.
2025-09-19 22:46:53 +08:00
ikun0014
eefff08159 Update GitHub token reference in release workflow
Replaces usage of GH_TOKEN with GITHUB_TOKEN in the release workflow to ensure correct authentication for GitHub Actions.
2025-08-04 19:15:37 +08:00
ikun0014
956efef428 Update version label to v2.0.5 in about page
Changed the displayed version in about.html from v2.0.4 to v2.0.5 to reflect the latest release.
2025-08-04 17:52:15 +08:00
ikun0014
56901f1b92 2.0.5 2025-08-04 17:51:56 +08:00
ikun0014
490dc096c1 Use dynamic repository in release workflow
Replaces hardcoded repository name with GitHub context variable in release.yml workflow. This improves portability and allows the workflow to work for forks or renamed repositories.
2025-08-04 17:49:38 +08:00
ikun0014
911f6f39e3 Initial project setup and source code import
Add project files including Python source code, web assets, configuration, and CI/CD workflows. Includes main application logic, web interface, supporting modules, and documentation for the Onekey Steam Depot Manifest Downloader.
2025-08-04 17:48:35 +08:00
ikun0014
343c86808d Update README.md 2025-07-24 20:56:00 +08:00
ikun0014
6a0e6e926d Merge branch 'main' of https://github.com/ikunshare/Onekey 2025-07-24 20:55:13 +08:00
ikun0014
611edaec04 rm: all 2025-07-24 20:54:36 +08:00
ikun0014
92559710f7 Update README.md 2025-07-24 20:53:28 +08:00
ikun0014
03a661f288 Update README.md 2025-07-11 23:42:12 +08:00
ikun0014
bb160f9f6d Update release.yml 2025-06-08 19:19:58 +08:00
ikun0014
e5d4ad55ee Update release.yml 2025-06-08 19:17:05 +08:00
ikun0014
7968d40491 Update release.yml 2025-06-08 19:13:14 +08:00
ikun0014
cc9778e537 Update release.yml 2025-06-08 19:11:31 +08:00
ikun0014
1f09166b49 Update release.yml 2025-06-08 19:09:01 +08:00
ikun0014
02e139e23e 1.5.1 2025-06-08 19:07:21 +08:00
ikun0014
3e336e0b65 Update __init__.py 2025-06-08 19:07:18 +08:00
ikun0014
a511ec20df Update release.yml 2025-06-08 19:06:48 +08:00
62 changed files with 11933 additions and 594 deletions

15
.github/FUNDING.yml vendored
View File

@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: ['https://afdian.com/a/ikun0014']

View File

@@ -1,25 +0,0 @@
---
name: Bug反馈
about: 反馈一个虫子(确信)
title: "[BUG]: "
labels: ''
assignees: ''
---
**触发Bug后出现了什么症状**
1.
**如何复现该Bug**
步骤:
1.
**日志**
复制在这里
**截图**
对程序的完整截图
**系统:**
- OS: Windows
- Version: 10

41
.github/workflows/dev.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: DEV Test Build
on:
workflow_dispatch:
jobs:
build:
permissions:
contents: write
runs-on: windows-latest
steps:
- name: 拉取仓库
uses: actions/checkout@v4
- name: 获取版本
shell: powershell
run: |
$version = (Get-Content package.json | ConvertFrom-Json).version
echo "PACKAGE_VERSION=$version" >> $env:GITHUB_ENV
- name: 安装Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: 安装依赖
run: |
python -m pip install --upgrade pip
pip install imageio
pip install -r requirements.txt
pip install nuitka
- name: 编译
run: |
python -m nuitka --standalone --onefile --assume-yes-for-downloads --show-memory --show-progress --onefile-tempdir-spec="%TEMP%\\onekey_%PID%_%TIME%" --windows-icon-from-ico="icon.jpg" --company-name="ikunshare" --product-name="Onekey" --file-version="${{ env.PACKAGE_VERSION }}" --product-version="${{ env.PACKAGE_VERSION }}" --file-description="Onekey Depot Manifest Downloader." --copyright="Copyright © 2025 ikunshare All Rights Reserved." --include-data-dir="web=web" --output-dir="build" --output-filename="Onekey_v${{ env.PACKAGE_VERSION }}.exe" --include-data-file="./icon.jpg=./icon.jpg" main.py
- name: 上传包
uses: actions/upload-artifact@v4
with:
name: Onekey_v${{ env.PACKAGE_VERSION }}.exe
path: build/Onekey_v${{ env.PACKAGE_VERSION }}.exe

View File

@@ -1,11 +1,6 @@
name: Build and Release
name: Build
on:
push:
branches:
- main
tags:
- "v*"
workflow_dispatch:
jobs:
@@ -13,128 +8,67 @@ jobs:
permissions:
contents: write
runs-on: windows-latest
steps:
- name: Checkout repository
- name: 拉取仓库
uses: actions/checkout@v4
- name: Get package version
id: get_version
- name: 获取版本
shell: powershell
run: |
$packageJson = Get-Content package.json | ConvertFrom-Json
$version = $packageJson.version
echo "VERSION=$version" >> $env:GITHUB_OUTPUT
echo "Package version: $version"
$version = (Get-Content package.json | ConvertFrom-Json).version
echo "PACKAGE_VERSION=$version" >> $env:GITHUB_ENV
- name: Set up Python
- name: 安装Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
architecture: "x64"
python-version: "3.11"
- name: Install dependencies
- name: 安装依赖
run: |
python -m pip install --upgrade pip
pip install imageio
pip install -r requirements.txt
pip install Pillow
pip install pyinstaller
pip install nuitka
- name: Create icon if not exists
shell: powershell
- name: 编译
run: |
if (-not (Test-Path "icon.ico")) {
Write-Host "Icon file not found, using default Windows icon"
$iconPath = "C:\Windows\System32\shell32.dll,0"
}
python -m nuitka --standalone --onefile --assume-yes-for-downloads --show-memory --show-progress --onefile-tempdir-spec="%TEMP%\\onekey_%PID%_%TIME%" --windows-icon-from-ico="icon.jpg" --company-name="ikunshare" --product-name="Onekey" --file-version="${{ env.PACKAGE_VERSION }}" --product-version="${{ env.PACKAGE_VERSION }}" --file-description="Onekey Depot Manifest Downloader." --copyright="Copyright © 2025 ikunshare All Rights Reserved." --include-data-dir="web=web" --output-dir="build" --output-filename="Onekey_v${{ env.PACKAGE_VERSION }}.exe" --include-data-file="./icon.jpg=./icon.jpg" main.py
- name: Build executable
run: |
pyinstaller --onefile `
--name "Onekey_v${{ steps.get_version.outputs.VERSION }}" `
--uac-admin `
--uac-uiaccess `
--add-data "src;src" `
--hidden-import=winreg `
--hidden-import=httpx `
--hidden-import=vdf `
--hidden-import=colorama `
--hidden-import=logzero `
--hidden-import=ujson `
--console `
main.py
- name: Compress with UPX
uses: crazy-max/ghaction-upx@v3.2.0
- name: 创建标签
uses: pkgdeps/git-tag-action@v3
with:
version: latest
files: |
./dist/*.exe
args: "-fq"
github_token: ${{ secrets.GITHUB_TOKEN }}
github_repo: ${{ github.repository }}
version: ${{ env.PACKAGE_VERSION }}
git_commit_sha: ${{ github.sha }}
git_tag_prefix: "v"
- name: Create Release
if: startsWith(github.ref, 'refs/tags/')
- name: 上传包
uses: actions/upload-artifact@v4
with:
name: Onekey_v${{ env.PACKAGE_VERSION }}.exe
path: build/Onekey_v${{ env.PACKAGE_VERSION }}.exe
- name: 发布
uses: softprops/action-gh-release@v2
with:
files: dist/Onekey_v${{ steps.get_version.outputs.VERSION }}.exe
draft: false
tag_name: v${{ env.PACKAGE_VERSION }}
files: build/Onekey_v${{ env.PACKAGE_VERSION }}.exe
prerelease: false
generate_release_notes: true
draft: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create tag and release (if not tagged)
if: github.ref == 'refs/heads/main'
run: |
git config user.name github-actions
git config user.email github-actions@github.com
$version = "${{ steps.get_version.outputs.VERSION }}"
# Check if tag exists
$tagExists = git tag -l "v$version"
if (-not $tagExists) {
git tag -a "v$version" -m "Release version $version"
git push origin "v$version"
}
- name: Upload to Gitee Release
if: success() && (startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main')
continue-on-error: true
- name: 发布到Gitee
uses: nicennnnnnnlee/action-gitee-release@v1.0.5
with:
gitee_owner: ikun0014
gitee_repo: Onekey
gitee_token: ${{ secrets.GITEE_TOKEN }}
gitee_tag_name: v${{ steps.get_version.outputs.VERSION }}
gitee_release_name: v${{ steps.get_version.outputs.VERSION }}
gitee_release_body: "Onekey version ${{ steps.get_version.outputs.VERSION }} release"
gitee_tag_name: v${{ env.PACKAGE_VERSION }}
gitee_release_name: v${{ env.PACKAGE_VERSION }}
gitee_release_body: I don't know
gitee_target_commitish: main
gitee_upload_retry_times: 3
gitee_file_name: Onekey_v${{ steps.get_version.outputs.VERSION }}.exe
gitee_file_path: dist/Onekey_v${{ steps.get_version.outputs.VERSION }}.exe
- name: Upload to Telegram Channel
if: success() && (startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main')
continue-on-error: true
shell: powershell
run: |
$version = "${{ steps.get_version.outputs.VERSION }}"
$filePath = "dist/Onekey_v${version}.exe"
if (Test-Path $filePath) {
curl.exe -F "chat_id=${{ secrets.TELEGRAM_TO }}" `
-F "thread_id=${{ secrets.TELEGRAM_THREAD }}" `
-F "document=@$filePath" `
-F "caption=🎮 **Onekey New Update** v${version}`n`n✨ Steam Game Unlocker`n💻 Windows 10/11`n`n[GitHub](https://github.com/ikunshare/Onekey)" `
-F "parse_mode=Markdown" `
"https://api.telegram.org/bot${{ secrets.TELEGRAM_TOKEN }}/sendDocument"
} else {
Write-Error "File not found: $filePath"
}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: Onekey_v${{ steps.get_version.outputs.VERSION }}
path: dist/Onekey_v${{ steps.get_version.outputs.VERSION }}.exe
retention-days: 30
gitee_file_name: Onekey_v${{ env.PACKAGE_VERSION }}.exe
gitee_file_path: build/Onekey_v${{ env.PACKAGE_VERSION }}.exe

View File

@@ -1,25 +0,0 @@
name: Sync to Gitee
on:
push:
branches:
- main
jobs:
sync:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Sync to Gitee
uses: Yikun/hub-mirror-action@master
with:
src: github/ikunshare
dst: gitee/ikun0014
dst_key: ${{ secrets.GITEE_PRIVATE_KEY }}
dst_token: ${{ secrets.GITEE_TOKEN }}
src_account_type: org
dst_account_type: user
white_list: "Onekey"
force_update: true
debug: true

11
.gitignore vendored
View File

@@ -144,8 +144,6 @@ Thumbs.db
config.json
logs/
*.exe
icon.ico
icon.jpg
# Testing
.pytest_cache/
@@ -163,4 +161,11 @@ coverage.xml
# Build artifacts
build/
dist/
*.egg-info/
*.egg-info/
# Nuitka
*.build
*.dist
*.onefile-build
.node_modules/

View File

@@ -1,4 +0,0 @@
{
"python.analysis.autoImportCompletions": true,
"python.analysis.typeCheckingMode": "off"
}

View File

@@ -11,28 +11,31 @@
</div>
## Onekey
Onekey Steam Depot Manifest Downloader
对本软件有意见的
欢迎拨打中华人民共和国公安部门报警电话110进行报警
对本软件有意见的
欢迎拨打中华人民共和国公安部门报警电话110 进行报警
## 先让我挂些人
- 沧海颐粟,早期倒卖大手子,现在不知道跑哪了,通过一点手段查到在江西
- 玩家资源站,贼喊捉贼笑传,随便改改别人软件的名字就是自己的,还去报官了
## 使用方法
去Releases处下载最新的发布并且安装好SteamTools或者GreenLuma
然后打开Onekey输入App ID即可使用
去 Releases 处下载最新的发布,并且安装好 SteamTools 或者 GreenLuma
然后打开 Onekey 输入 App ID 即可使用
## 开发
本程序使用Python编程语言开发
要求环境:
1.Python 3.10及以上
2.Windows 10及以上
3.使用Git进行版本管理
1.克隆项目到本地
本程序使用 Python 编程语言开发
要求环境:
1.Python 3.10 及以上
2.Windows 10 及以上
3.使用 Git 进行版本管理
1.克隆项目到本地
```
git clone https://github.com/ikunshare/Onekey
@@ -44,24 +47,9 @@ git clone https://github.com/ikunshare/Onekey
pip install -r requirements.txt
```
## 项目协议
本项目基于 GPL-2.0 许可证发行,以下协议是对于 GPL-2.0 原协议的补充,如有冲突,以以下协议为准。
词语约定: “使用者”指签署本协议的使用者;“版权数据”指包括但不限于图像、音频、名字等在内的他人拥有所属版权的数据。
本项目的数据来源原理是从Steam官方的CDN服务器中拉取游戏清单数据经过对数据简单地筛选与合并后进行展示因此本项目不对数据的准确性负责。
使用本项目的过程中可能会产生版权数据对于这些版权数据本项目不拥有它们的所有权为了避免造成侵权使用者务必在24 小时内清除使用本项目的过程中所产生的版权数据。
由于使用本项目产生的包括由于本协议或由于使用或无法使用本项目而引起的任何性质的任何直接、间接、特殊、偶然或结果性损害(包括但不限于因商誉损失、停工、计算机故障或故障引起的损害赔偿,或任何及所有其他商业损害或损失)由使用者负责。
本项目完全免费,且开源发布于 GitHub 面向全世界人用作对技术的学习交流,本项目不对项目内的技术可能存在违反当地法律法规的行为作保证,禁止在违反当地法律法规的情况下使用本项目,对于使用者在明知或不知当地法律法规不允许的情况下使用本项目所造成的任何违法违规行为由使用者承担,本项目不承担由此造成的任何直接、间接、特殊、偶然或结果性责任。
而且,本项目已禁止使用于商业用途,以及不得进行未经允许的二次修改,否则必须同时发布源代码。
若你使用了本项目,将代表你接受以上协议。
Steam正版平台不易请尊重版权支持正版。
本项目仅用于对技术可行性的探索及研究,不接受任何商业(包括但不限于广告等)合作。
## Star 趋势图
[![Stargazers over time](https://starchart.cc/ikunshare/Onekey.svg)](https://starchart.cc/ikunshare/Onekey)
[![Stargazers over time](https://starchart.cc/ikunshare/Onekey.svg)](https://starchart.cc/ikunshare/Onekey)
## 贡献者
@@ -70,10 +58,13 @@ pip install -r requirements.txt
</a>
## 常见问题解答FAQ
查看 [FAQ](https://ikunshare.top/d/49) 获取常见问题的解答。
## 社区和支持
加入我们的社区,参与讨论和支持:
加入我们的社区,参与讨论和支持:
- [GitHub Discussions](https://github.com/ikunshare/Onekey/discussions)
- [Telegram](https://t.me/ikunshare_qun)
- [QQ](https://qm.qq.com/q/NPRVbglteK)

172
main.py
View File

@@ -1,14 +1,164 @@
import os
import asyncio
import sys
import time
import threading
import webbrowser
from pathlib import Path
from PIL import Image
from src.config import ConfigManager
from src.utils.i18n import t
project_root = Path(__file__)
config_manager = ConfigManager()
sys.path.insert(0, str(project_root))
def hide_console() -> None:
"""隐藏控制台窗口"""
try:
import ctypes
kernel32 = ctypes.windll.kernel32
user32 = ctypes.windll.user32
console_window = kernel32.GetConsoleWindow()
if console_window:
user32.ShowWindow(console_window, 0) # SW_HIDE = 0
except Exception:
pass
def create_icon() -> Image.Image:
"""创建托盘图标"""
try:
return Image.open("./icon.jpg")
except Exception as e:
if config_manager.app_config.show_console:
print(t("error.load_icon", error=str(e)))
# 创建默认图标
return Image.new("RGBA", (32, 32), color=(103, 80, 164, 255))
def create_system_tray() -> bool:
"""创建系统托盘"""
try:
import pystray
def on_quit(icon, item):
icon.stop()
os._exit(0)
def on_open_browser(icon, item):
try:
webbrowser.open(f"http://localhost:{config_manager.app_config.port}")
except Exception:
pass
def on_show_console(icon, item):
try:
import ctypes
kernel32 = ctypes.windll.kernel32
user32 = ctypes.windll.user32
console_window = kernel32.GetConsoleWindow()
if console_window:
user32.ShowWindow(console_window, 1) # SW_NORMAL = 1
except Exception:
pass
# 创建托盘菜单
menu = pystray.Menu(
pystray.MenuItem(t("tray.open_browser"), on_open_browser),
pystray.MenuItem(t("tray.show_console"), on_show_console),
pystray.MenuItem(t("tray.exit"), on_quit),
)
# 创建托盘图标
icon = pystray.Icon("Onekey", create_icon(), menu=menu)
# 在单独的线程中运行托盘
def run_tray():
icon.run()
tray_thread = threading.Thread(target=run_tray)
tray_thread.daemon = True
tray_thread.start()
return True
except ImportError:
return False
def open_browser_delayed(port: int) -> None:
"""延迟打开浏览器"""
time.sleep(2)
try:
webbrowser.open(f"http://localhost:{port}")
if config_manager.app_config.show_console:
print(t("main.browser_opened", port=port))
except Exception:
if config_manager.app_config.show_console:
print(t("main.browser_open_failed", port=port))
def start_web_server() -> None:
"""启动Web服务器"""
from web.app import app
from uvicorn import Config
from uvicorn.server import Server
server = Server(
Config(
app, host="0.0.0.0", port=config_manager.app_config.port, log_level="error"
)
)
server.run()
def main() -> None:
"""主函数"""
try:
config = config_manager.app_config
show_console = config.show_console
if show_console:
print(t("main.starting"))
print("=" * 50)
# 处理控制台显示
if not show_console:
hide_console()
tray_created = create_system_tray()
else:
tray_created = create_system_tray()
if tray_created:
print(t("main.tray_created"))
# 启动浏览器
browser_thread = threading.Thread(
target=open_browser_delayed, args=(config.port,)
)
browser_thread.daemon = True
browser_thread.start()
# 启动Web应用
start_web_server()
except KeyboardInterrupt:
if config_manager.app_config.show_console:
print(f"\n{t('main.exit')}")
except Exception as e:
if config_manager.app_config.show_console:
print(t("main.start_error", error=str(e)))
input(t("main.press_enter"))
else:
# 在隐藏控制台模式下记录错误
error_log = Path("error.log")
with open(error_log, "w", encoding="utf-8") as f:
f.write(t("main.startup_failed", error=str(e)) + "\n")
if __name__ == "__main__":
try:
from src.main import main
asyncio.run(main())
except (asyncio.CancelledError, KeyboardInterrupt):
print("\n程序已退出")
except Exception as e:
print(f"错误:{e}")
finally:
os.system("pause")
main()

View File

@@ -1,6 +1,6 @@
{
"name": "onekey",
"version": "1.5.0",
"version": "2.1.1",
"description": "一个Steam仓库清单下载器",
"main": "index.js",
"scripts": {
@@ -14,7 +14,7 @@
"Onekey"
],
"author": "ikun0014",
"license": "GPL-2.0",
"license": "null",
"bugs": {
"url": "https://github.com/ikunshare/Onekey/issues"
},

View File

@@ -1,4 +1,9 @@
colorama==0.4.6
httpx==0.28.1
logzero==1.7.0
vdf==3.4
vdf
httpx
Pillow
pystray
uvicorn
logzero
colorama
fastapi[all]
steam[client]

View File

@@ -1,3 +0,0 @@
__version__ = "1.5.0"
__author__ = "ikun0014"
__website__ = "ikunshare.top"

View File

@@ -1,19 +1,22 @@
import json
import os
import sys
import time
import json
import winreg
from pathlib import Path
from typing import Dict, Optional
from .constants import CONFIG_FILE
from .models import AppConfig
from .utils.i18n import t
DEFAULT_CONFIG = {
"Github_Personal_Token": "",
"Custom_Steam_Path": "",
"KEY": "",
"Port": 5000,
"Debug_Mode": False,
"Logging_Files": True,
"Help": "Github Personal Token可在GitHub设置的Developer settings中生成",
"Show_Console": False,
"Custom_Steam_Path": "",
"Language": "zh",
}
@@ -32,41 +35,69 @@ class ConfigManager:
try:
with open(self.config_path, "w", encoding="utf-8") as f:
json.dump(DEFAULT_CONFIG, f, indent=2, ensure_ascii=False)
print("配置文件已生成")
print(t("config.generated"))
os.system("pause")
sys.exit(1)
except IOError as e:
print(f"配置文件创建失败: {str(e)}")
print(t("config.create_failed", error=str(e)))
os.system("pause")
sys.exit(1)
def _load_config(self) -> None:
"""加载配置文件"""
if not self.config_path.exists():
self._generate_config()
print("请填写配置文件后重新运行程序5秒后退出")
time.sleep(5)
sys.exit(1)
try:
with open(self.config_path, "r", encoding="utf-8") as f:
self._config_data = json.load(f)
self.app_config = AppConfig(
github_token=self._config_data.get("Github_Personal_Token", ""),
key=self._config_data.get("KEY", ""),
port=self._config_data.get("Port", 5000),
custom_steam_path=self._config_data.get("Custom_Steam_Path", ""),
debug_mode=self._config_data.get("Debug_Mode", False),
logging_files=self._config_data.get("Logging_Files", True),
show_console=self._config_data.get("Show_Console", True),
language=self._config_data.get("Language", "zh"),
)
self.steam_path = self._get_steam_path()
except json.JSONDecodeError:
print("配置文件损坏,正在重新生成...")
print(t("config.corrupted"))
self._generate_config()
sys.exit(1)
print(t("config.regenerated"))
self.app_config = AppConfig(
key=DEFAULT_CONFIG.get("KEY", ""),
port=DEFAULT_CONFIG.get("Port", 5000),
custom_steam_path=DEFAULT_CONFIG.get("Custom_Steam_Path", ""),
debug_mode=DEFAULT_CONFIG.get("Debug_Mode", False),
logging_files=DEFAULT_CONFIG.get("Logging_Files", True),
show_console=DEFAULT_CONFIG.get("Show_Console", True),
language=DEFAULT_CONFIG.get("Language", "zh"),
)
try:
self.steam_path = self._get_steam_path()
except Exception:
self.steam_path = None
except Exception as e:
print(f"配置加载失败: {str(e)}")
sys.exit(1)
print(t("config.load_failed", error=str(e)))
print(t("config.use_default"))
self.app_config = AppConfig(
key=DEFAULT_CONFIG.get("KEY", ""),
port=DEFAULT_CONFIG.get("Port", 5000),
custom_steam_path=DEFAULT_CONFIG.get("Custom_Steam_Path", ""),
debug_mode=DEFAULT_CONFIG.get("Debug_Mode", False),
logging_files=DEFAULT_CONFIG.get("Logging_Files", True),
show_console=DEFAULT_CONFIG.get("Show_Console", True),
language=DEFAULT_CONFIG.get("Language", "zh"),
)
try:
self.steam_path = self._get_steam_path()
except Exception:
self.steam_path = None
def _get_steam_path(self) -> Path:
def _get_steam_path(self) -> Optional[Path]:
"""获取Steam安装路径"""
try:
if self.app_config.custom_steam_path:
@@ -77,12 +108,6 @@ class ConfigManager:
) as key:
return Path(winreg.QueryValueEx(key, "SteamPath")[0])
except Exception as e:
print(f"Steam路径获取失败: {str(e)}")
sys.exit(1)
@property
def github_headers(self) -> Optional[Dict[str, str]]:
"""获取GitHub请求头"""
if self.app_config.github_token:
return {"Authorization": f"Bearer {self.app_config.github_token}"}
return None
print(t("config.steam_path_failed", error=str(e)))
print(t("config.continue_partial"))
return None

View File

@@ -1,35 +1,50 @@
"""常量定义"""
from pathlib import Path
from httpx import Client
APP_NAME = "Onekey"
BANNER = r"""
_____ __ _ _____ _ _ _____ __ __
/ _ \ | \ | | | ____| | | / / | ____| \ \ / /
| | | | | \| | | |__ | |/ / | |__ \ \/ /
| | | | | |\ | | __| | |\ \ | __| \ /
| |_| | | | \ | | |___ | | \ \ | |___ / /
\_____/ |_| \_| |_____| |_| \_\ |_____| /_/
"""
LOG_DIR: Path = Path("logs")
CONFIG_FILE: Path = Path("config.json")
REPO_LIST = [
"SteamAutoCracks/ManifestHub",
"ikun0014/ManifestHub",
"Auiowu/ManifestAutoUpdate",
"tymolu233/ManifestAutoUpdate-fix",
]
CN_CDN_LIST = [
"https://cdn.jsdmirror.com/gh/{repo}@{sha}/{path}",
"https://raw.gitmirror.com/{repo}/{sha}/{path}",
"https://raw.dgithub.xyz/{repo}/{sha}/{path}",
"https://gh.akass.cn/{repo}/{sha}/{path}",
]
def check_ip():
try:
with Client(timeout=5.0) as client:
req = client.get(
"https://mips.kugou.com/check/iscn",
)
req.raise_for_status()
body = req.json()
print("已获取IP属地")
return bool(body["flag"])
except BaseException:
print("获取IP属地失败, 默认您位于中国大陆境内")
return True
GLOBAL_CDN_LIST = ["https://raw.githubusercontent.com/{repo}/{sha}/{path}"]
GITHUB_API_BASE = "https://api.github.com"
REGION_CHECK_URL = "https://mips.kugou.com/check/iscn?&format=json"
IS_CN: bool = check_ip()
LOG_DIR = Path("logs")
CONFIG_FILE = Path("config.json")
STEAM_API_BASE: str = "https://steam.ikunshare.com/api"
STEAM_CACHE_CDN_LIST: list = (
[
"http://alibaba.cdn.steampipe.steamcontent.com",
"http://steampipe.steamcontent.tnkjmec.com",
]
if IS_CN
else [
"http://fastly.cdn.steampipe.steamcontent.com",
"http://akamai.cdn.steampipe.steamcontent.com",
"http://telus.cdn.steampipe.steamcontent.com",
"https://cache1-hkg1.steamcontent.com",
"https://cache2-hkg1.steamcontent.com",
"https://cache3-hkg1.steamcontent.com",
"https://cache4-hkg1.steamcontent.com",
"https://cache5-hkg1.steamcontent.com",
"https://cache6-hkg1.steamcontent.com",
"https://cache7-hkg1.steamcontent.com",
"https://cache8-hkg1.steamcontent.com",
"https://cache9-hkg1.steamcontent.com",
"https://cache10-hkg1.steamcontent.com",
]
)

View File

@@ -1,17 +1,11 @@
import traceback
from typing import List, Dict, Tuple
from . import __version__, __author__, __website__
from .constants import BANNER, REPO_LIST
from .constants import STEAM_API_BASE
from .config import ConfigManager
from .logger import Logger
from .models import DepotInfo
from .models import DepotInfo, ManifestInfo, SteamAppInfo, SteamAppManifestInfo
from .network.client import HttpClient
from .network.github import GitHubAPI
from .utils.region import RegionDetector
from .utils.steam import parse_key_file, parse_manifest_filename
from .tools.steamtools import SteamTools
from .tools.greenluma import GreenLuma
from .manifest_handler import ManifestHandler
from .utils.i18n import t
class OnekeyApp:
@@ -25,146 +19,203 @@ class OnekeyApp:
log_file=self.config.app_config.logging_files,
)
self.client = HttpClient()
self.github = GitHubAPI(self.client, self.config.github_headers, self.logger)
def show_banner(self):
"""显示横幅"""
self.logger.info(BANNER)
self.logger.info(
f"本程序源代码基于GPL 2.0许可证开放于Github"
)
self.logger.info(
f"作者: {__author__} | 版本: {__version__} | 官网: {__website__}"
)
self.logger.info("项目仓库: GitHub: https://github.com/ikunshare/Onekey")
self.logger.warning("ikunshare.top | 严禁倒卖")
self.logger.warning(
"提示: 请确保已安装Windows 10/11并正确配置Steam;SteamTools/GreenLuma"
)
if not self.config.app_config.github_token:
self.logger.warning("开梯子必须配置Token, 你的IP我不相信能干净到哪")
async def handle_depot_files(
self, app_id: str
) -> Tuple[List[DepotInfo], Dict[str, List[str]]]:
"""处理仓库文件"""
depot_list = []
depot_map = {}
repo_info = await self.github.get_latest_repo_info(REPO_LIST, app_id)
if not repo_info:
return depot_list, depot_map
self.logger.info(f"当前选择清单仓库: https://github.com/{repo_info.name}")
self.logger.info(f"此清单分支上次更新时间:{repo_info.last_update}")
branch_url = f"https://api.github.com/repos/{repo_info.name}/branches/{app_id}"
branch_res = await self.client.get(
branch_url, headers=self.config.github_headers
)
branch_res.raise_for_status()
tree_url = branch_res.json()["commit"]["commit"]["tree"]["url"]
tree_res = await self.client.get(tree_url)
tree_res.raise_for_status()
depot_cache = self.config.steam_path / "depotcache"
depot_cache.mkdir(exist_ok=True)
for item in tree_res.json()["tree"]:
file_path = item["path"]
if file_path.endswith(".manifest"):
save_path = depot_cache / file_path
if save_path.exists():
self.logger.warning(f"已存在清单: {save_path}")
continue
content = await self.github.fetch_file(
repo_info.name, repo_info.sha, file_path
)
save_path.write_bytes(content)
self.logger.info(f"清单下载成功: {file_path}")
depot_id, manifest_id = parse_manifest_filename(file_path)
if depot_id and manifest_id:
depot_map.setdefault(depot_id, []).append(manifest_id)
elif "key.vdf" in file_path.lower():
key_content = await self.github.fetch_file(
repo_info.name, repo_info.sha, file_path
)
depot_list.extend(parse_key_file(key_content))
for depot_id in depot_map:
depot_map[depot_id].sort(key=lambda x: int(x), reverse=True)
return depot_list, depot_map
async def run(self, app_id: str):
"""运行主程序"""
async def fetch_key(self) -> bool:
"""获取并验证卡密信息"""
try:
detector = RegionDetector(self.client, self.logger)
is_cn, country = await detector.check_cn()
self.github.is_cn = is_cn
response = await self.client._client.post(
f"{STEAM_API_BASE}/getKeyInfo",
json={"key": self.config.app_config.key},
)
body = response.json()
await self.github.check_rate_limit()
if not body["info"]:
self.logger.error(t("api.key_not_exist"))
return False
self.logger.info(f"正在处理游戏 {app_id}...")
depot_data, depot_map = await self.handle_depot_files(app_id)
key_type = body["info"]["type"]
self.logger.info(t("api.key_type", type=t(f"key_type.{key_type}")))
if not depot_data:
self.logger.error("未找到此游戏的清单")
return
if key_type != "permanent":
self.logger.info(t("api.key_expires", time=body["info"]["expiresAt"]))
return True
except Exception as e:
self.logger.error(t("api.key_info_failed", error=str(e)))
return True
print("\n请选择解锁工具:")
print("1. SteamTools")
print("2. GreenLuma")
async def fetch_app_data(
self, app_id: str, and_dlc: bool = True
) -> Tuple[SteamAppInfo, SteamAppManifestInfo]:
"""
从API获取应用数据
"""
main_app_manifests = []
dlc_manifests = []
choice = input("请输入选择 (1/2): ").strip()
try:
self.logger.info(t("api.fetching_game", app_id=app_id))
response = await self.client._client.post(
f"{STEAM_API_BASE}/getGame",
json={"appId": int(app_id), "dlc": and_dlc},
headers={"X-Api-Key": self.config.app_config.key},
)
if response.status_code == 401:
self.logger.error(t("api.invalid_key"))
return SteamAppInfo(), SteamAppManifestInfo(mainapp=[], dlcs=[])
if response.status_code != 200:
self.logger.error(t("api.request_failed", code=response.status_code))
return SteamAppInfo(), SteamAppManifestInfo(mainapp=[], dlcs=[])
data = response.json()
if not data:
self.logger.error(t("api.no_manifest"))
return SteamAppInfo(), SteamAppManifestInfo(mainapp=[], dlcs=[])
self.logger.info(t("api.game_name", name=data.get("name", "Unknown")))
self.logger.info(
t("api.depot_count", count=data.get("depotCount", "Unknown"))
)
if and_dlc:
for item in data.get("gameManifests", []):
manifest = ManifestInfo(
app_id=item["app_id"],
depot_id=item["depot_id"],
depot_key=item["depot_key"],
manifest_id=item["manifest_id"],
url=item["url"],
)
main_app_manifests.append(manifest)
for item in data.get("dlcManifests", []):
self.logger.info(
t("api.dlc_name", name=item.get("dlcName", "Unknown"))
)
self.logger.info(
t("api.dlc_appid", id=item.get("dlcId", "Unknown"))
)
for manifests in item.get("manifests", []):
manifest = ManifestInfo(
app_id=manifests["app_id"],
depot_id=manifests["depot_id"],
depot_key=manifests["depot_key"],
manifest_id=manifests["manifest_id"],
url=manifests["url"],
)
dlc_manifests.append(manifest)
else:
for item in data.get("manifests", []):
manifest = ManifestInfo(
app_id=item["app_id"],
depot_id=item["depot_id"],
depot_key=item["depot_key"],
manifest_id=item["manifest_id"],
url=item["url"],
)
main_app_manifests.append(manifest)
except Exception as e:
self.logger.error(t("api.fetch_failed", error=str(e)))
return SteamAppInfo(), SteamAppManifestInfo(mainapp=[], dlcs=[])
return SteamAppInfo(
app_id,
data.get("name", ""),
data.get("totalDLCCount", data.get("dlcCount", 0)),
data.get("depotCount", 0),
data.get("workshopDecryptionKey", "None"),
), SteamAppManifestInfo(mainapp=main_app_manifests, dlcs=dlc_manifests)
def prepare_depot_data(
self, manifests: List[ManifestInfo]
) -> Tuple[List[DepotInfo], Dict[str, List[str]]]:
"""准备仓库数据"""
depot_data = []
depot_dict = {}
for manifest in manifests:
if manifest.depot_id not in depot_dict:
depot_dict[manifest.depot_id] = {
"key": manifest.depot_key,
"manifests": [],
}
depot_dict[manifest.depot_id]["manifests"].append(manifest.manifest_id)
for depot_id, info in depot_dict.items():
depot_info = DepotInfo(
depot_id=depot_id,
decryption_key=info["key"],
manifest_ids=info["manifests"],
)
depot_data.append(depot_info)
return depot_data, depot_dict
async def run(self, app_id: str, tool_type: str, dlc: bool) -> bool:
"""
为Web版本提供的运行方法。
Args:
app_id: Steam应用ID
tool_type: 解锁工具类型 (steamtools/greenluma)
dlc: 是否包含DLC
Returns:
是否成功执行
"""
try:
if not self.config.steam_path:
self.logger.error(t("task.no_steam_path"))
return False
await self.fetch_key()
app_info, manifests = await self.fetch_app_data(app_id, dlc)
if not manifests.mainapp and not manifests.dlcs:
return False
manifest_handler = ManifestHandler(
self.client, self.logger, self.config.steam_path
)
processed_manifests = await manifest_handler.process_manifests(manifests)
if not processed_manifests:
self.logger.error(t("task.no_manifest_processed"))
return False
depot_data, _ = self.prepare_depot_data(processed_manifests)
self.logger.info(t("tool.selected", tool=tool_type))
if tool_type == "steamtools":
from .tools.steamtools import SteamTools
if choice == "1":
tool = SteamTools(self.config.steam_path)
success = await tool.setup(depot_data, app_info)
elif tool_type == "greenluma":
from .tools.greenluma import GreenLuma
version_lock = False
lock_choice = input(
"是否锁定版本(推荐在选择仓库SteamAutoCracks/ManifestHub时使用)?(y/n): "
).lower()
if lock_choice == "y":
version_lock = True
success = await tool.setup(
depot_data, app_id, depot_map=depot_map, version_lock=version_lock
)
elif choice == "2":
tool = GreenLuma(self.config.steam_path)
success = await tool.setup(depot_data, app_id)
else:
self.logger.error("无效的选择")
return
self.logger.error(t("tool.invalid_selection"))
return False
if success:
self.logger.info("游戏解锁配置成功!")
self.logger.info("重启Steam后生效")
self.logger.info(t("tool.config_success"))
self.logger.info(t("tool.restart_steam"))
return True
else:
self.logger.error("配置失败")
self.logger.error(t("tool.config_failed"))
return False
except Exception as e:
self.logger.error(f"运行错误: {traceback.format_exc()}")
self.logger.error(t("task.run_error", error=str(e)))
return False
finally:
await self.client.close()
async def main():
"""程序入口"""
app = OnekeyApp()
app.show_banner()
app_id = input("\n请输入游戏AppID: ").strip()
app_id_list = [id for id in app_id.split("-") if id.isdigit()]
if not app_id_list:
app.logger.error("App ID无效")
return
await app.run(app_id_list[0])

136
src/manifest_handler.py Normal file
View File

@@ -0,0 +1,136 @@
import vdf
from pathlib import Path
from typing import List, Optional
from steam.client.cdn import DepotManifest
from .constants import STEAM_CACHE_CDN_LIST
from .models import ManifestInfo, SteamAppManifestInfo
from .logger import Logger
from .network.client import HttpClient
from .utils.i18n import t
class ManifestHandler:
"""清单处理器"""
def __init__(self, client: HttpClient, logger: Logger, steam_path: Path):
self.client = client
self.logger = logger
self.steam_path = steam_path
self.depot_cache = steam_path / "depotcache"
self.depot_cache.mkdir(exist_ok=True)
async def download_manifest(self, manifest_info: ManifestInfo) -> Optional[bytes]:
"""下载清单文件"""
for _ in range(3):
for cdn in STEAM_CACHE_CDN_LIST:
url = cdn + manifest_info.url
try:
r = await self.client.get(url)
if r.status_code == 200:
return r.content
except Exception as e:
self.logger.debug(t("manifest.download.failed", url=url, error=e))
def process_manifest(
self, manifest_data: bytes, manifest_info: ManifestInfo, remove_old: bool = True
) -> bool:
"""处理清单文件"""
try:
depot_id = manifest_info.depot_id
manifest_id = manifest_info.manifest_id
depot_key = bytes.fromhex(manifest_info.depot_key)
manifest = DepotManifest(manifest_data)
manifest_path = self.depot_cache / f"{depot_id}_{manifest_id}.manifest"
config_path = self.depot_cache / "config.vdf"
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as f:
d = vdf.load(f)
else:
d = {"depots": {}}
d["depots"][depot_id] = {"DecryptionKey": depot_key.hex()}
d = {"depots": dict(sorted(d["depots"].items()))}
if remove_old:
for file in self.depot_cache.iterdir():
if file.suffix == ".manifest":
parts = file.stem.split("_")
if (
len(parts) == 2
and parts[0] == str(depot_id)
and parts[1] != str(manifest_id)
):
file.unlink(missing_ok=True)
self.logger.info(t("manifest.delete_old", name=file.name))
with open(manifest_path, "wb") as f:
f.write(manifest.serialize(compress=False))
with open(config_path, "w", encoding="utf-8") as f:
vdf.dump(d, f, pretty=True)
self.logger.info(
t(
"manifest.process.success",
depot_id=depot_id,
manifest_id=manifest_id,
)
)
return True
except Exception as e:
self.logger.error(t("manifest.process.failed", error=e))
return False
async def process_manifests(
self, manifests: SteamAppManifestInfo
) -> List[ManifestInfo]:
"""批量处理清单"""
processed = []
app_manifest = manifests.mainapp
dlc_manifest = manifests.dlcs
for manifest_info in app_manifest + dlc_manifest:
manifest_path = (
self.depot_cache
/ f"{manifest_info.depot_id}_{manifest_info.manifest_id}.manifest"
)
if manifest_path.exists():
self.logger.warning(t("manifest.exists", name=manifest_path.name))
processed.append(manifest_info)
continue
self.logger.info(
t(
"manifest.downloading",
depot_id=manifest_info.depot_id,
manifest_id=manifest_info.manifest_id,
)
)
manifest_data = await self.download_manifest(manifest_info)
if manifest_data:
if self.process_manifest(manifest_data, manifest_info):
processed.append(manifest_info)
else:
self.logger.error(
t(
"manifest.downloading.failed",
depot_id=manifest_info.depot_id,
manifest_id=manifest_info.manifest_id,
)
)
else:
self.logger.error(
t(
"manifest.process.failed2",
depot_id=manifest_info.depot_id,
manifest_id=manifest_info.manifest_id,
)
)
return processed

View File

@@ -1,6 +1,5 @@
from typing import List
from dataclasses import dataclass
from datetime import datetime
@dataclass
@@ -17,19 +16,39 @@ class DepotInfo:
@dataclass
class RepoInfo:
"""GitHub仓库信息"""
class ManifestInfo:
"""清单信息"""
app_id: int
depot_id: str
depot_key: str
manifest_id: str
url: str
@dataclass
class SteamAppInfo:
appId: int
name: str
last_update: datetime
sha: str
dlcCount: int
depotCount: int
workshopDecryptionKey: str
@dataclass
class SteamAppManifestInfo:
mainapp: List[ManifestInfo]
dlcs: List[ManifestInfo]
@dataclass
class AppConfig:
"""应用配置"""
github_token: str = ""
key: str = ""
port: int = 5000
custom_steam_path: str = ""
debug_mode: bool = False
logging_files: bool = True
show_console: bool = True
language: str = "zh"

View File

@@ -8,7 +8,7 @@ class HttpClient:
"""HTTP客户端封装"""
def __init__(self):
self._client = httpx.AsyncClient(verify=False, timeout=30.0)
self._client = httpx.AsyncClient(timeout=60.0)
async def get(self, url: str, headers: Optional[Dict] = None) -> httpx.Response:
"""GET请求"""

View File

@@ -1,92 +0,0 @@
import time
from typing import List, Dict, Optional
from datetime import datetime
from ..constants import GITHUB_API_BASE, CN_CDN_LIST, GLOBAL_CDN_LIST
from ..models import RepoInfo
from ..logger import Logger
from .client import HttpClient
class GitHubAPI:
"""GitHub API封装"""
def __init__(
self,
client: HttpClient,
headers: Optional[Dict] = None,
logger: Optional[Logger] = None,
):
self.client = client
self.headers = headers or {}
self.logger = logger or Logger("GitHubAPI")
self.is_cn = True
async def check_rate_limit(self) -> None:
"""检查API请求限制"""
url = f"{GITHUB_API_BASE}/rate_limit"
try:
r = await self.client.get(url, headers=self.headers)
if r.status_code == 200:
r_json = r.json()
rate_limit = r_json.get("rate", {})
remaining = rate_limit.get("remaining", 0)
reset_time = rate_limit.get("reset", 0)
reset_formatted = time.strftime(
"%Y-%m-%d %H:%M:%S", time.localtime(reset_time)
)
self.logger.info(f"剩余Github API请求次数: {remaining}")
if remaining == 0:
self.logger.warning(
f"GitHub API 请求数已用尽, 将在 {reset_formatted} 重置"
)
else:
self.logger.error("Github请求数检查失败, 网络错误")
except Exception as e:
self.logger.error(f"检查Github API 请求数失败: {str(e)}")
async def get_latest_repo_info(
self, repos: List[str], app_id: str
) -> Optional[RepoInfo]:
"""获取最新的仓库信息"""
latest_date = None
selected_repo = None
selected_sha = None
for repo in repos:
url = f"{GITHUB_API_BASE}/repos/{repo}/branches/{app_id}"
try:
r = await self.client.get(url, headers=self.headers)
if r.status_code == 200:
r_json = r.json()
if "commit" in r_json:
date_str = r_json["commit"]["commit"]["author"]["date"]
date = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
if latest_date is None or date > latest_date:
latest_date = date
selected_repo = repo
selected_sha = r_json["commit"]["sha"]
except Exception as e:
self.logger.warning(f"检查仓库 {repo} 失败: {str(e)}")
if selected_repo:
return RepoInfo(
name=selected_repo, last_update=latest_date, sha=selected_sha
)
return None
async def fetch_file(self, repo: str, sha: str, path: str) -> bytes:
"""获取文件内容"""
cdn_list = CN_CDN_LIST if self.is_cn else GLOBAL_CDN_LIST
for _ in range(3):
for cdn_template in cdn_list:
url = cdn_template.format(repo=repo, sha=sha, path=path)
try:
r = await self.client.get(url, headers=self.headers)
if r.status_code == 200:
return r.content
except Exception as e:
self.logger.debug(f"{url} 下载失败: {str(e)}")
raise Exception(f"无法下载文件: {path}")

View File

@@ -4,36 +4,65 @@ from typing import List
from .base import UnlockTool
from ..models import DepotInfo
class GreenLuma(UnlockTool):
"""GreenLuma解锁工具实现"""
async def setup(self, depot_data: List[DepotInfo], app_id: str, **kwargs) -> bool:
"""设置GreenLuma解锁"""
applist_dir = self.steam_path / "AppList"
applist_dir.mkdir(exist_ok=True)
for f in applist_dir.glob("*.txt"):
f.unlink()
for idx, depot in enumerate(depot_data, 1):
(applist_dir / f"{idx}.txt").write_text(depot.depot_id)
if applist_dir.is_file():
applist_dir.unlink(missing_ok=True)
if not applist_dir.is_dir():
applist_dir.mkdir(parents=True, exist_ok=True)
depot_dict = {}
for i in applist_dir.iterdir():
if i.stem.isdecimal() and i.suffix == '.txt':
with i.open('r', encoding='utf-8') as f:
app_id_ = f.read().strip()
depot_dict[int(i.stem)] = int(app_id_) if app_id_.isdecimal() else None
def find_next_index():
if not depot_dict:
return 0
for i in range(max(depot_dict.keys())):
if i not in depot_dict:
return i
return max(depot_dict.keys()) + 1
if app_id and app_id.isdecimal():
app_id_int = int(app_id)
if app_id_int not in depot_dict.values():
index = find_next_index()
with (applist_dir / f'{index}.txt').open('w', encoding='utf-8') as f:
f.write(str(app_id))
depot_dict[index] = app_id_int
for depot in depot_data:
depot_id = int(depot.depot_id)
if depot_id not in depot_dict.values():
index = find_next_index()
with (applist_dir / f'{index}.txt').open('w', encoding='utf-8') as f:
f.write(str(depot_id))
depot_dict[index] = depot_id
config_path = self.steam_path / "config" / "config.vdf"
try:
with open(config_path, "r", encoding="utf-8") as f:
content = vdf.loads(f.read())
content.setdefault("depots", {}).update(
{
depot.depot_id: {"DecryptionKey": depot.decryption_key}
for depot in depot_data
}
)
if config_path.is_file():
with open(config_path, "r", encoding="utf-8") as f:
content = vdf.loads(f.read())
else:
content = {}
config_path.parent.mkdir(parents=True, exist_ok=True)
if "depots" not in content:
content["depots"] = {}
for depot in depot_data:
content["depots"][depot.depot_id] = {"DecryptionKey": depot.decryption_key}
with open(config_path, "w", encoding="utf-8") as f:
f.write(vdf.dumps(content))
return True
except Exception:
return False
except Exception as e:
print(f"GreenLuma配置失败: {e}")
return False

View File

@@ -1,7 +1,8 @@
from typing import List, Dict
from typing import List
from datetime import datetime
from .base import UnlockTool
from ..models import DepotInfo
from ..models import DepotInfo, SteamAppInfo
class SteamTools(UnlockTool):
@@ -10,29 +11,32 @@ class SteamTools(UnlockTool):
async def setup(
self,
depot_data: List[DepotInfo],
app_id: str,
depot_map: Dict[str, List[str]] = None,
version_lock: bool = False,
app_info: SteamAppInfo,
) -> bool:
"""设置SteamTools解锁"""
st_path = self.steam_path / "config" / "stplug-in"
st_path.mkdir(parents=True, exist_ok=True)
lua_content = f'addappid({app_id}, 1, "None")\n'
lua_content = f"""
-- Generated Lua Manifest by Onekey
-- Steam App {app_info.appId} Manifest
-- Name: {app_info.name}
-- Generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
-- Total Depots: {app_info.depotCount}
-- Total DLCs: {app_info.dlcCount}
-- MAIN APP
addappid({app_info.appId}, "0", "{app_info.workshopDecryptionKey}")
-- ALL Depots
"""
for depot in depot_data:
if version_lock and depot_map and depot.depot_id in depot_map:
for manifest_id in depot_map[depot.depot_id]:
lua_content += (
f'addappid({depot.depot_id}, 1, "{depot.decryption_key}")\n'
f'setManifestid({depot.depot_id},"{manifest_id}")\n'
)
else:
lua_content += (
f'addappid({depot.depot_id}, 1, "{depot.decryption_key}")\n'
)
lua_content += (
f'addappid({depot.depot_id}, "1", "{depot.decryption_key}")\n'
)
lua_file = st_path / f"{app_id}.lua"
lua_file = st_path / f"{app_info.appId}.lua"
lua_file.write_text(lua_content, encoding="utf-8")
return True

247
src/utils/i18n.py Normal file
View File

@@ -0,0 +1,247 @@
from typing import Dict
class I18n:
"""国际化管理类"""
def __init__(self, default_lang: str = "zh"):
self.current_lang = default_lang
self.translations: Dict[str, Dict[str, str]] = {}
self._load_translations()
def _load_translations(self):
"""加载所有语言的翻译"""
# 中文翻译
self.translations["zh"] = {
# 系统托盘
"tray.open_browser": "打开浏览器",
"tray.show_console": "显示控制台",
"tray.exit": "退出程序",
# 主程序
"main.starting": "正在启动Onekey...",
"main.tray_created": "系统托盘已创建",
"main.browser_opened": "浏览器已自动打开",
"main.browser_open_failed": "无法自动打开浏览器,请手动访问: http://localhost:{port}",
"main.exit": "程序已退出",
"main.start_error": "启动错误: {error}",
"main.press_enter": "按回车键退出...",
"main.startup_failed": "启动失败: {error}",
"main.checkip_success": "已获取IP属地",
"main.checkip_failed": "获取IP属地失败: {error}, 默认您在中国大陆境内",
# 配置
"config.generated": "配置文件已生成",
"config.create_failed": "配置文件创建失败: {error}",
"config.corrupted": "配置文件损坏,正在重新生成...",
"config.regenerated": "配置文件已重新生成,使用默认配置继续运行",
"config.load_failed": "配置加载失败: {error}",
"config.use_default": "使用默认配置继续运行",
"config.steam_path_failed": "Steam路径获取失败: {error}",
"config.continue_partial": "程序将继续运行,但部分功能可能不可用",
# API相关
"api.key_not_exist": "卡密不存在",
"api.key_type": "卡密类型: {type}",
"api.key_expires": "卡密过期时间: {time}",
"api.key_info_failed": "获取卡密信息失败: {error}",
"api.fetching_game": "正在获取游戏 {app_id} 的信息...",
"api.invalid_key": "API密钥无效",
"api.request_failed": "API请求失败状态码: {code}",
"api.no_manifest": "未找到此游戏的清单信息",
"api.game_name": "游戏名称: {name}",
"api.depot_count": "Depot数量: {count}",
"api.dlc_name": "DLC名称: {name}",
"api.dlc_appid": "DLC AppId: {id}",
"api.fetch_failed": "获取主游戏信息失败: {error}",
# 工具相关
"tool.selected": "选择的解锁工具: {tool}",
"tool.invalid_selection": "无效的工具选择",
"tool.config_success": "游戏解锁配置成功!",
"tool.restart_steam": "重启Steam后生效",
"tool.config_failed": "配置失败",
# 任务相关
"task.no_steam_path": "Steam路径未配置或无效无法继续",
"task.no_manifest_processed": "没有成功处理的清单",
"task.run_error": "运行错误: {error}",
# 卡密类型
"key_type.week": "周卡",
"key_type.month": "月卡",
"key_type.year": "年卡",
"key_type.permanent": "永久卡",
# 错误消息
"error.load_icon": "加载图标失败: {error}",
"error.import": "导入错误: {error}",
"error.ensure_root": "请确保在项目根目录中运行此程序",
# Web相关
"web.starting": "启动Onekey Web GUI...",
"web.visit": "请在浏览器中访问: http://localhost:{port}",
"web.task_running": "已有任务正在运行",
"web.invalid_appid": "请输入有效的App ID",
"web.invalid_format": "App ID格式无效",
"web.task_started": "任务已开始",
"web.task_failed": "任务执行失败: {error}",
"web.config_saved": "配置已保存",
"web.config_save_failed": "保存配置失败: {error}",
"web.config_reset": "配置已重置为默认值",
"web.config_reset_failed": "重置配置失败: {error}",
"web.key_empty": "卡密不能为空",
"web.key_service_unavailable": "卡密验证服务不可用",
"web.verify_timeout": "验证超时,请检查网络连接",
"web.verify_failed": "验证失败: {error}",
"web.connected": "已连接到服务器",
"web.client_disconnected": "客户端断开连接",
"web.websocket_error": "WebSocket 错误: {error}",
"web.invalid_config_data": "无效的配置数据",
# 清单处理
"manifest.download.failed": "{url} 下载失败: {error}",
"manifest.delete_old": "删除旧清单: {name}",
"manifest.process.success": "清单处理成功: {depot_id}_{manifest_id}.manifest",
"manifest.process.failed": "处理清单时出错: {error}",
"manifest.process.failed2": "处理清单失败: {depot_id}_{manifest_id}",
"manifest.exists": "清单已存在: {name}",
"manifest.downloading": "正在下载清单: {depot_id}_{manifest_id}",
"manifest.downloading.failed": "下载清单失败: {depot_id}_{manifest_id}",
}
# 英文翻译
self.translations["en"] = {
# System tray
"tray.open_browser": "Open Browser",
"tray.show_console": "Show Console",
"tray.exit": "Exit",
# Main program
"main.starting": "Starting Onekey...",
"main.tray_created": "System tray created",
"main.browser_opened": "Browser opened automatically",
"main.browser_open_failed": "Failed to open browser automatically, please visit: http://localhost:{port}",
"main.exit": "Program exited",
"main.start_error": "Startup error: {error}",
"main.press_enter": "Press Enter to exit...",
"main.startup_failed": "Startup failed: {error}",
"main.checkip_success": "Obtained IP territory",
"main.checkip_failed": "Failed to obtain IP territory: {error}, by default you are in mainland China",
# Configuration
"config.generated": "Configuration file generated",
"config.create_failed": "Failed to create configuration file: {error}",
"config.corrupted": "Configuration file corrupted, regenerating...",
"config.regenerated": "Configuration file regenerated, continuing with default config",
"config.load_failed": "Failed to load configuration: {error}",
"config.use_default": "Continuing with default configuration",
"config.steam_path_failed": "Failed to get Steam path: {error}",
"config.continue_partial": "Program will continue but some features may be unavailable",
# API related
"api.key_not_exist": "Key does not exist",
"api.key_type": "Key type: {type}",
"api.key_expires": "Key expires at: {time}",
"api.key_info_failed": "Failed to get key info: {error}",
"api.fetching_game": "Fetching game {app_id} information...",
"api.invalid_key": "Invalid API key",
"api.request_failed": "API request failed with status code: {code}",
"api.no_manifest": "No manifest found for this game",
"api.game_name": "Game name: {name}",
"api.depot_count": "Depot count: {count}",
"api.dlc_name": "DLC name: {name}",
"api.dlc_appid": "DLC AppId: {id}",
"api.fetch_failed": "Failed to fetch main game info: {error}",
# Tool related
"tool.selected": "Selected unlock tool: {tool}",
"tool.invalid_selection": "Invalid tool selection",
"tool.config_success": "Game unlock configuration successful!",
"tool.restart_steam": "Restart Steam to take effect",
"tool.config_failed": "Configuration failed",
# Task related
"task.no_steam_path": "Steam path not configured or invalid, cannot continue",
"task.no_manifest_processed": "No manifests successfully processed",
"task.run_error": "Run error: {error}",
# Key types
"key_type.week": "Weekly",
"key_type.month": "Monthly",
"key_type.year": "Yearly",
"key_type.permanent": "Permanent",
# Error messages
"error.load_icon": "Failed to load icon: {error}",
"error.import": "Import error: {error}",
"error.ensure_root": "Please ensure running this program from project root",
# Web related
"web.starting": "Starting Onekey Web GUI...",
"web.visit": "Please visit: http://localhost:{port}",
"web.task_running": "A task is already running",
"web.invalid_appid": "Please enter a valid App ID",
"web.invalid_format": "Invalid App ID format",
"web.task_started": "Task started",
"web.task_failed": "Task execution failed: {error}",
"web.config_saved": "Configuration saved",
"web.config_save_failed": "Failed to save configuration: {error}",
"web.config_reset": "Configuration reset to default",
"web.config_reset_failed": "Failed to reset configuration: {error}",
"web.key_empty": "Key cannot be empty",
"web.key_service_unavailable": "Key verification service unavailable",
"web.verify_timeout": "Verification timeout, please check network connection",
"web.verify_failed": "Verification failed: {error}",
"web.connected": "Connected to server",
"web.client_disconnected": "Client disconnected",
"web.websocket_error": "WebSocket error: {error}",
"web.invalid_config_data": "Invalid configuration data",
# List processing
"manifest.download.failed": "Downloading from {url} failed: {error}",
"manifest.delete_old": "Delete old manifest: {name}",
"manifest.process.success": "Manifest processing successful: {depot_id}_{manifest_id}.manifest",
"manifest.process.failed": "Error while processing manifest: {error}",
"manifest.process.failed2": "Manifest processing failed: {depot_id}_{manifest_id}",
"manifest.exists": "Manifest already exists: {name}",
"manifest.downloading": "Downloading manifest: {depot_id}_{manifest_id}",
"manifest.downloading.failed": "Manifest download failed: {depot_id}_{manifest_id}",
}
def set_language(self, lang: str):
"""设置当前语言"""
if lang in self.translations:
self.current_lang = lang
else:
raise ValueError(f"Unsupported language: {lang}")
def t(self, key: str, **kwargs) -> str:
"""
获取翻译文本
Args:
key: 翻译键
**kwargs: 格式化参数
Returns:
翻译后的文本
"""
lang_dict = self.translations.get(self.current_lang, {})
text = lang_dict.get(key, key)
# 格式化文本
if kwargs:
try:
text = text.format(**kwargs)
except KeyError:
pass
return text
def get_all_translations(self, lang: str = None) -> Dict[str, str]:
"""获取指定语言的所有翻译"""
lang = lang or self.current_lang
return self.translations.get(lang, {})
# 全局i18n实例
_i18n_instance = None
def get_i18n() -> I18n:
"""获取全局i18n实例"""
global _i18n_instance
if _i18n_instance is None:
from ..config import ConfigManager
config = ConfigManager()
_i18n_instance = I18n(config.app_config.language)
return _i18n_instance
def t(key: str, **kwargs) -> str:
"""便捷的翻译函数"""
return get_i18n().t(key, **kwargs)

View File

@@ -1,32 +0,0 @@
from typing import Tuple
from ..constants import REGION_CHECK_URL
from ..network.client import HttpClient
from ..logger import Logger
class RegionDetector:
"""地区检测器"""
def __init__(self, client: HttpClient, logger: Logger):
self.client = client
self.logger = logger
async def check_cn(self) -> Tuple[bool, str]:
"""检查是否在中国大陆"""
try:
req = await self.client.get(REGION_CHECK_URL)
body = req.json()
is_cn = bool(body.get("flag", True))
country = body.get("country", "Unknown")
if not is_cn:
self.logger.info(
f"您在非中国大陆地区({country})上使用了项目, "
"已自动切换回Github官方下载CDN"
)
return is_cn, country
except Exception as e:
self.logger.warning("检查服务器位置失败,自动认为你在中国大陆")
return True, "CN"

View File

@@ -1,19 +1,4 @@
import vdf
from typing import List, Optional, Tuple
from ..models import DepotInfo
def parse_key_file(content: bytes) -> List[DepotInfo]:
"""解析密钥文件"""
try:
depots = vdf.loads(content.decode("utf-8"))["depots"]
return [
DepotInfo(depot_id=d_id, decryption_key=d_info["DecryptionKey"])
for d_id, d_info in depots.items()
]
except Exception:
return []
from typing import Optional, Tuple
def parse_manifest_filename(filename: str) -> Tuple[Optional[str], Optional[str]]:

0
web/__init__.py Normal file
View File

448
web/app.py Normal file
View File

@@ -0,0 +1,448 @@
import os
import sys
import time
import json
import httpx
import asyncio
from pathlib import Path
from typing import List
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from src.constants import STEAM_API_BASE
from src.utils.i18n import t
# 添加项目根目录到Python路径
project_root = Path(__file__)
sys.path.insert(0, str(project_root))
def get_base_path():
"""获取程序基础路径"""
if hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS)
elif getattr(sys, "frozen", False):
return Path(os.path.dirname(os.path.abspath(sys.executable)))
else:
return Path(os.path.dirname(os.path.abspath(__file__)))
base_path = get_base_path()
try:
from src.main import OnekeyApp
from src.config import ConfigManager
except ImportError as e:
print(t("error.import", error=str(e)))
print(t("error.ensure_root"))
sys.exit(1)
class ConnectionManager:
"""WebSocket 连接管理器"""
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_personal_message(self, message: str, websocket: WebSocket):
await websocket.send_text(message)
async def broadcast(self, message: str):
for connection in self.active_connections:
try:
await connection.send_text(message)
except BaseException:
# 连接可能已关闭
pass
class WebOnekeyApp:
"""Web版本的Onekey应用"""
def __init__(self, manager: ConnectionManager):
self.onekey_app = None
self.current_task = None
self.task_status = "idle" # idle, running, completed, error
self.task_progress = []
self.task_result = None
self.manager = manager
def init_app(self):
"""初始化Onekey应用"""
try:
self.onekey_app = OnekeyApp()
return True
except Exception as e:
return False, str(e)
async def run_unlock_task(self, app_id: str, tool_type: str, dlc: bool):
"""运行解锁任务"""
try:
self.task_status = "running"
self.task_progress = []
# 重新初始化应用以确保新的任务状态
self.onekey_app = OnekeyApp()
# 添加自定义日志处理器来捕获进度
self._add_progress_handler()
# 执行解锁任务
result = await self.onekey_app.run(app_id, tool_type, dlc)
if result:
self.task_status = "completed"
self.task_result = {
"success": True,
"message": "游戏解锁配置成功重启Steam后生效",
}
else:
self.task_status = "error"
self.task_result = {"success": False, "message": "配置失败"}
except Exception as e:
self.task_status = "error"
self.task_result = {"success": False, "message": f"配置失败: {str(e)}"}
finally:
# 确保应用资源被清理
if hasattr(self, "onekey_app") and self.onekey_app:
try:
if hasattr(self.onekey_app, "client"):
await self.onekey_app.client.close()
except BaseException:
pass
self.onekey_app = None
def _add_progress_handler(self):
"""添加进度处理器"""
if self.onekey_app and self.onekey_app.logger:
original_info = self.onekey_app.logger.info
original_warning = self.onekey_app.logger.warning
original_error = self.onekey_app.logger.error
def info_with_progress(msg):
self.task_progress.append(
{"type": "info", "message": str(msg), "timestamp": time.time()}
)
# 广播进度消息
asyncio.create_task(
self.manager.broadcast(
json.dumps(
{
"type": "task_progress",
"data": {"type": "info", "message": str(msg)},
}
)
)
)
return original_info(msg)
def warning_with_progress(msg):
self.task_progress.append(
{"type": "warning", "message": str(msg), "timestamp": time.time()}
)
asyncio.create_task(
self.manager.broadcast(
json.dumps(
{
"type": "task_progress",
"data": {"type": "warning", "message": str(msg)},
}
)
)
)
return original_warning(msg)
def error_with_progress(msg):
self.task_progress.append(
{"type": "error", "message": str(msg), "timestamp": time.time()}
)
asyncio.create_task(
self.manager.broadcast(
json.dumps(
{
"type": "task_progress",
"data": {"type": "error", "message": str(msg)},
}
)
)
)
return original_error(msg)
self.onekey_app.logger.info = info_with_progress
self.onekey_app.logger.warning = warning_with_progress
self.onekey_app.logger.error = error_with_progress
# 创建FastAPI应用
app = FastAPI(title="Onekey")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
manager = ConnectionManager()
# 修复为静态文件路由添加name参数
config = ConfigManager()
app.mount(
"/static",
StaticFiles(directory=f"{base_path}/{config.app_config.language}/static"),
name="static",
)
templates = Jinja2Templates(
directory=f"{base_path}/{config.app_config.language}/templates"
)
# 创建Web应用实例
web_app = WebOnekeyApp(manager)
@app.get("/")
async def index(request: Request):
"""主页"""
config = ConfigManager()
if not config.app_config.key:
return RedirectResponse(request.url_for("oobe"))
else:
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/oobe")
async def oobe(request: Request):
"""OOBE页面"""
return templates.TemplateResponse("oobe.html", {"request": request})
@app.post("/api/init")
async def init_app():
"""初始化应用"""
result = web_app.init_app()
if isinstance(result, tuple):
return JSONResponse({"success": False, "message": result[1]})
return JSONResponse({"success": True})
@app.get("/api/config")
async def get_config():
"""获取配置信息"""
try:
config = ConfigManager()
return JSONResponse(
{
"success": True,
"config": {
"steam_path": str(config.steam_path) if config.steam_path else "",
"debug_mode": config.app_config.debug_mode,
},
}
)
except Exception as e:
return JSONResponse({"success": False, "message": str(e)})
@app.post("/api/start_unlock")
async def start_unlock(request: Request):
"""开始解锁任务"""
data = await request.json()
app_id = data.get("app_id", "").strip()
tool_type = data.get("tool_type", "steamtools")
dlc = data.get("dlc")
if not app_id:
return JSONResponse({"success": False, "message": "请输入有效的App ID"})
# 验证App ID格式
app_id_list = [id for id in app_id.split("-") if id.isdigit()]
if not app_id_list:
return JSONResponse({"success": False, "message": "App ID格式无效"})
if web_app.task_status == "running":
return JSONResponse({"success": False, "message": "已有任务正在运行"})
try:
await web_app.run_unlock_task(app_id_list[0], tool_type, dlc)
except Exception as e:
web_app.task_status = "error"
web_app.task_result = {
"success": False,
"message": f"任务执行失败: {str(e)}",
}
return JSONResponse({"success": True, "message": "任务已开始"})
@app.get("/api/task_status")
async def get_task_status():
"""获取任务状态"""
return JSONResponse(
{
"status": web_app.task_status,
"progress": (
web_app.task_progress[-10:] if web_app.task_progress else []
), # 只返回最近10条
"result": web_app.task_result,
}
)
@app.get("/about")
async def about_page(request: Request):
"""关于页面"""
return templates.TemplateResponse("about.html", {"request": request})
@app.get("/settings")
async def settings_page(request: Request):
"""设置页面"""
return templates.TemplateResponse("settings.html", {"request": request})
@app.post("/api/config/update")
async def update_config(request: Request):
"""更新配置"""
try:
data = await request.json()
# 验证必需的字段
if not isinstance(data, dict):
return {"success": False, "message": "无效的配置数据"}
# 加载当前配置
config_manager = ConfigManager()
# 准备新的配置数据
new_config = {
"KEY": data.get("key", ""),
"Port": config_manager.app_config.port,
"Custom_Steam_Path": data.get("steam_path", ""),
"Debug_Mode": data.get("debug_mode", False),
"Logging_Files": data.get("logging_files", True),
"Show_Console": data.get("show_console", True),
"Language": data.get("language", "zh"),
}
# 保存配置
import json
config_path = config_manager.config_path
with open(config_path, "w", encoding="utf-8") as f:
json.dump(new_config, f, indent=2, ensure_ascii=False)
return {"success": True, "message": "配置已保存"}
except Exception as e:
return {"success": False, "message": f"保存配置失败: {str(e)}"}
@app.post("/api/config/reset")
async def reset_config():
"""重置配置为默认值"""
try:
from src.config import DEFAULT_CONFIG
import json
config_manager = ConfigManager()
config_path = config_manager.config_path
with open(config_path, "w", encoding="utf-8") as f:
json.dump(DEFAULT_CONFIG, f, indent=2, ensure_ascii=False)
return {"success": True, "message": "配置已重置为默认值"}
except Exception as e:
return {"success": False, "message": f"重置配置失败: {str(e)}"}
@app.get("/api/config/detailed")
async def get_detailed_config():
"""获取详细配置信息"""
try:
config = ConfigManager()
return {
"success": True,
"config": {
"steam_path": str(config.steam_path) if config.steam_path else "",
"debug_mode": config.app_config.debug_mode,
"logging_files": config.app_config.logging_files,
"show_console": config.app_config.show_console,
"steam_path_exists": (
config.steam_path.exists() if config.steam_path else False
),
"key": getattr(config.app_config, "key", ""),
"language": config.app_config.language,
},
}
except Exception as e:
return {"success": False, "message": str(e)}
@app.post("/api/getKeyInfo")
async def get_key_info(request: Request):
"""获取卡密信息"""
try:
data = await request.json()
key = data.get("key", "").strip()
if not key:
return JSONResponse({"success": False, "message": "卡密不能为空"})
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{STEAM_API_BASE}/getKeyInfo",
json={"key": key},
headers={"Content-Type": "application/json"},
)
if response.status_code == 200:
result = response.json()
return JSONResponse(result)
else:
return JSONResponse({"success": False, "message": "卡密验证服务不可用"})
except httpx.TimeoutException:
return JSONResponse({"success": False, "message": "验证超时,请检查网络连接"})
except Exception as e:
return JSONResponse({"success": False, "message": f"验证失败: {str(e)}"})
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket 端点"""
await manager.connect(websocket)
try:
await websocket.send_text(
json.dumps({"type": "connected", "data": {"message": "已连接到服务器"}})
)
while True:
data = await websocket.receive_text()
message = json.loads(data)
if message.get("type") == "ping":
await websocket.send_text(
json.dumps({"type": "pong", "data": {"timestamp": time.time()}})
)
except WebSocketDisconnect:
manager.disconnect(websocket)
print(t("web.client_disconnected"))
except Exception as e:
print(t("web.websocket_error", error=str(e)))
manager.disconnect(websocket)
print(t("web.starting"))
print(t("web.visit", port=config.app_config.port))

View File

@@ -0,0 +1,277 @@
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInDown {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slideInUp {
from {
transform: translateY(60px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes subtle-rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.3;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.05);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
@keyframes shimmer {
0% {
background-position: -200% center;
}
100% {
background-position: 200% center;
}
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
10%,
30%,
50%,
70%,
90% {
transform: translateX(-5px);
}
20%,
40%,
60%,
80% {
transform: translateX(5px);
}
}
@keyframes success-pulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.4);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 0 10px rgba(76, 175, 80, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0);
}
}
@keyframes loading-bounce {
0%,
80%,
100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.loading {
display: flex;
align-items: center;
gap: 12px;
color: var(--md-sys-color-on-surface-variant);
}
.loading::before {
content: "";
width: 20px;
height: 20px;
border: 2px solid var(--md-sys-color-primary-container);
border-top: 2px solid var(--md-sys-color-primary);
border-radius: 50%;
animation: spin 1s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite;
}
.loading-dots {
display: inline-flex;
gap: 4px;
}
.loading-dots span {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--md-sys-color-primary);
animation: loading-bounce 1.4s ease-in-out infinite;
}
.loading-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dots span:nth-child(2) {
animation-delay: -0.16s;
}
.skeleton-loader {
background: linear-gradient(
90deg,
var(--md-sys-color-surface-container) 25%,
var(--md-sys-color-surface-container-high) 50%,
var(--md-sys-color-surface-container) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.error-state {
animation: shake 0.5s ease-in-out;
}
.success-state {
animation: success-pulse 0.5s ease-out;
}
.ripple {
position: relative;
overflow: hidden;
}
.ripple::before {
content: "";
position: absolute;
top: var(--ripple-y, 50%);
left: var(--ripple-x, 50%);
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
transform: translate(-50%, -50%);
transition:
width 0.6s ease,
height 0.6s ease,
opacity 0.6s ease;
opacity: 0;
}
.ripple:active::before {
width: 300px;
height: 300px;
opacity: 0.3;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
.animate-fadeIn {
animation: fadeIn var(--transition-medium) ease;
}
.animate-slideUp {
animation: slideUp var(--transition-medium) ease-out;
}
.animate-slideIn {
animation: slideIn var(--transition-medium) ease-out;
}
.animate-pulse {
animation: pulse 2s ease-in-out infinite;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
.animate-spin {
animation: spin 1s linear infinite;
}

165
web/en/static/css/base.css Normal file
View File

@@ -0,0 +1,165 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
transition:
background-color var(--transition-medium) ease,
color var(--transition-medium) ease,
border-color var(--transition-medium) ease;
}
:root {
transition:
background-color 0.3s ease,
color 0.3s ease;
}
body {
font-family:
"LXGW Wenkai Mono",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background-color: var(--md-sys-color-background);
color: var(--md-sys-color-on-background);
line-height: 1.5;
min-height: 100vh;
overflow-x: hidden;
overflow-y: auto;
position: relative;
}
body::before {
content: "";
position: fixed;
top: -50%;
right: -50%;
width: 200%;
height: 200%;
background:
radial-gradient(
circle at 30% 80%,
rgba(103, 80, 164, 0.05) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 20%,
rgba(0, 188, 212, 0.05) 0%,
transparent 50%
);
pointer-events: none;
z-index: 0;
}
[data-theme="dark"] body::before {
background:
radial-gradient(
circle at 30% 80%,
rgba(208, 188, 255, 0.03) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 20%,
rgba(77, 208, 225, 0.03) 0%,
transparent 50%
);
}
[data-theme="dark"] {
color-scheme: dark;
}
:focus-visible {
outline: 2px solid var(--md-sys-color-primary);
outline-offset: 2px;
}
::selection {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
[data-theme="dark"] ::selection {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: var(--md-sys-color-surface-container);
border-radius: 6px;
}
::-webkit-scrollbar-thumb {
background: var(--md-sys-color-primary);
border-radius: 6px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--gradient-primary);
}
[data-theme="dark"]::-webkit-scrollbar-track {
background: var(--md-sys-color-surface-container);
}
[data-theme="dark"]::-webkit-scrollbar-thumb {
background: var(--md-sys-color-primary);
}
[data-theme="dark"]::-webkit-scrollbar-thumb:hover {
background: var(--gradient-primary);
}
input::placeholder {
font-family:
"LXGW Wenkai Mono",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
font-size: 12px;
color: var(--md-sys-color-on-surface-variant);
margin-left: 4px;
}
[data-theme="dark"] input:-webkit-autofill,
[data-theme="dark"] input:-webkit-autofill:hover,
[data-theme="dark"] input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--md-sys-color-on-surface);
-webkit-box-shadow: 0 0 0px 1000px var(--md-sys-color-surface-container) inset;
transition: background-color 5000s ease-in-out 0s;
}
.btn,
.card,
.theme-toggle {
will-change: transform;
}
.btn:not(:hover),
.card:not(:hover),
.theme-toggle:not(:hover) {
will-change: auto;
}
button:active,
.btn:active,
.card:active {
transform: scale(0.98);
}
* {
transition:
background-color var(--transition-medium) ease,
color var(--transition-medium) ease,
border-color var(--transition-medium) ease,
box-shadow var(--transition-medium) ease;
}

View File

@@ -0,0 +1,459 @@
.card {
background: var(--md-sys-color-surface);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: var(--md-sys-shape-corner-large);
box-shadow: var(--md-sys-elevation-level1);
border: 1px solid var(--md-sys-color-outline-variant);
overflow: hidden;
transition: all var(--transition-medium) cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
--hover-scale: 1.02;
--hover-shadow: var(--md-sys-elevation-level3);
animation: slideUp 0.6s ease-out backwards;
}
.card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--gradient-primary);
opacity: 0;
transition: opacity var(--transition-medium) ease;
}
.card:hover {
transform: translateY(-2px) scale(var(--hover-scale, 1));
box-shadow: var(--hover-shadow);
}
.card:hover::before {
opacity: 1;
}
.card:nth-child(1) {
animation-delay: 0.1s;
}
.card:nth-child(2) {
animation-delay: 0.2s;
}
.card:nth-child(3) {
animation-delay: 0.3s;
}
.card-header {
padding: 24px;
display: flex;
align-items: center;
gap: 16px;
background: var(--md-sys-color-surface-container-low);
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.card-header .material-icons {
font-size: 28px;
color: var(--md-sys-color-primary);
}
.card-header h2 {
font-size: 20px;
font-weight: 600;
color: var(--md-sys-color-on-surface);
flex: 1;
}
.card-content {
padding: 24px;
}
.btn {
padding: 14px 28px;
border-radius: var(--md-sys-shape-corner-full);
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all var(--transition-medium) cubic-bezier(0.4, 0, 0.2, 1);
text-decoration: none;
position: relative;
overflow: hidden;
}
.btn::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition:
width var(--transition-slow) ease,
height var(--transition-slow) ease;
}
.btn:active::before {
width: 300px;
height: 300px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
box-shadow: var(--md-sys-elevation-level1);
}
.btn-primary:hover:not(:disabled) {
background: var(--gradient-primary);
transform: translateY(-2px);
box-shadow: var(--md-sys-elevation-level3);
}
.btn-secondary {
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
border: 1px solid var(--md-sys-color-outline-variant);
}
.btn-secondary:hover:not(:disabled) {
background: var(--md-sys-color-secondary);
color: var(--md-sys-color-on-secondary);
transform: translateY(-2px);
box-shadow: var(--md-sys-elevation-level2);
}
.btn-text {
background-color: transparent;
color: var(--md-sys-color-primary);
padding: 8px 16px;
}
.btn-text:hover:not(:disabled) {
background-color: var(--md-sys-color-primary-container);
}
.button-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.input-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.input-label {
font-size: 14px;
font-weight: 500;
color: var(--md-sys-color-on-surface);
margin-left: 4px;
}
.text-field {
padding: 16px;
border: 2px solid var(--md-sys-color-outline-variant);
border-radius: var(--md-sys-shape-corner-medium);
background-color: var(--md-sys-color-surface-container-low);
color: var(--md-sys-color-on-surface);
font-size: 16px;
transition: all var(--transition-fast) ease;
position: relative;
}
.text-field:hover {
border-color: var(--md-sys-color-outline);
background-color: var(--md-sys-color-surface-container);
}
.text-field:focus {
outline: none;
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-surface-container);
box-shadow: 0 0 0 3px rgba(103, 80, 164, 0.15);
}
[data-theme="dark"] .text-field:focus {
box-shadow: 0 0 0 3px rgba(208, 188, 255, 0.15);
}
.input-helper {
font-size: 12px;
color: var(--md-sys-color-on-surface-variant);
margin-left: 4px;
}
.radio-group {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.radio-item {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
padding: 8px 12px;
border-radius: var(--md-sys-shape-corner-medium);
transition: background-color var(--transition-fast) ease;
}
.radio-item:hover {
background-color: var(--md-sys-color-primary-container);
}
.radio-item input[type="radio"] {
display: none;
}
.radio-button {
width: 20px;
height: 20px;
border: 2px solid var(--md-sys-color-outline);
border-radius: 50%;
position: relative;
transition: all var(--transition-fast) ease;
}
.radio-button::after {
content: "";
width: 12px;
height: 12px;
background: var(--md-sys-color-primary);
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform var(--transition-fast)
cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.radio-item input[type="radio"]:checked + .radio-button {
border-color: var(--md-sys-color-primary);
}
.radio-item input[type="radio"]:checked + .radio-button::after {
transform: translate(-50%, -50%) scale(1);
}
.checkbox-item {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
padding: 8px 12px;
border-radius: var(--md-sys-shape-corner-medium);
transition: background-color var(--transition-fast) ease;
}
.checkbox-item:hover {
background-color: var(--md-sys-color-primary-container);
}
.checkbox-item input[type="checkbox"] {
display: none;
}
.checkbox-button {
width: 20px;
height: 20px;
border: 2px solid var(--md-sys-color-outline);
border-radius: var(--md-sys-shape-corner-extra-small);
position: relative;
transition: all var(--transition-fast) ease;
}
.checkbox-button::after {
content: "✓";
color: var(--md-sys-color-on-primary);
font-size: 14px;
font-weight: bold;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform var(--transition-fast)
cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.checkbox-item input[type="checkbox"]:checked + .checkbox-button {
background: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
.checkbox-item input[type="checkbox"]:checked + .checkbox-button::after {
transform: translate(-50%, -50%) scale(1);
}
.theme-toggle {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--md-sys-color-surface-container);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast) ease;
color: var(--md-sys-color-on-surface);
}
.theme-toggle:hover {
background: var(--md-sys-color-primary-container);
transform: scale(1.1);
}
.theme-toggle .material-icons {
font-size: 20px;
transition: transform var(--transition-medium) ease;
}
.theme-toggle:active .material-icons {
transform: rotate(180deg);
}
.snackbar {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--md-sys-color-inverse-surface);
color: var(--md-sys-color-inverse-on-surface);
border-radius: var(--md-sys-shape-corner-medium);
box-shadow: var(--md-sys-elevation-level4);
z-index: 2000;
max-width: 560px;
min-width: 344px;
opacity: 0;
visibility: hidden;
transition: all var(--transition-medium) cubic-bezier(0.4, 0, 0.2, 1);
}
.snackbar.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
visibility: visible;
}
.snackbar.success {
background: var(--md-sys-color-success);
color: var(--md-sys-color-on-success);
}
.snackbar.error {
background: var(--md-sys-color-error);
color: var(--md-sys-color-on-error);
}
.snackbar.warning {
background: var(--md-sys-color-warning);
color: var(--md-sys-color-on-warning);
}
.snackbar.info {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
.snackbar-content {
display: flex;
align-items: center;
padding: 16px 20px;
gap: 16px;
}
.snackbar-content span {
flex: 1;
font-size: 14px;
font-weight: 500;
}
.snackbar-action {
background: rgba(255, 255, 255, 0.2);
border: none;
color: inherit;
cursor: pointer;
padding: 8px;
border-radius: 50%;
transition: all var(--transition-fast) ease;
}
.snackbar-action:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.status-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: var(--md-sys-shape-corner-medium);
background: var(--md-sys-color-surface-container);
border: 1px solid var(--md-sys-color-outline-variant);
transition: all var(--transition-fast) ease;
}
.status-item:hover {
transform: translateX(4px);
border-color: var(--md-sys-color-primary);
}
.status-icon {
font-size: 20px;
transition: transform var(--transition-fast) ease;
}
.status-item:hover .status-icon {
transform: scale(1.1);
}
.status-icon.success {
color: var(--md-sys-color-success);
}
.status-icon.error {
color: var(--md-sys-color-error);
}
.status-icon.warning {
color: var(--md-sys-color-warning);
}
.status-text {
flex: 1;
font-size: 14px;
color: var(--md-sys-color-on-surface);
}
@media (prefers-contrast: high) {
.card {
border-width: 2px;
}
.btn {
border: 2px solid currentColor;
}
.text-field {
border-width: 3px;
}
}

View File

@@ -0,0 +1,218 @@
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
padding-bottom: 24px;
position: relative;
z-index: 1;
}
.app-bar {
background: var(--md-sys-color-surface);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: var(--md-sys-elevation-level1);
position: sticky;
top: 0;
z-index: 1000;
transition: all var(--transition-medium) ease;
}
.app-bar:hover {
box-shadow: var(--md-sys-elevation-level2);
}
.app-bar-content {
max-width: 1200px;
margin: 0 auto;
padding: 16px 24px;
display: flex;
align-items: center;
gap: 16px;
}
.app-icon {
background: var(--gradient-primary);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 32px;
animation: subtle-rotate 20s linear infinite;
}
.app-title {
font-size: 24px;
font-weight: 600;
color: var(--md-sys-color-on-surface);
flex: 1;
}
.app-version {
font-size: 12px;
font-weight: 500;
color: var(--md-sys-color-primary);
background: var(--md-sys-color-primary-container);
padding: 6px 16px;
border-radius: var(--md-sys-shape-corner-full);
border: 1px solid var(--md-sys-color-primary);
}
.main-content {
flex: 1;
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px;
display: grid;
grid-template-columns: 1fr;
gap: 24px;
}
.app-footer {
background: var(--md-sys-color-surface);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
margin-top: 32px;
border-top: 1px solid var(--md-sys-color-outline-variant);
padding-bottom: 24px;
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px;
}
.copyright {
text-align: center;
padding: 20px 0;
border-top: 1px solid var(--md-sys-color-outline-variant);
margin-top: 32px;
}
.copyright p {
font-size: 13px;
color: var(--md-sys-color-on-surface-variant);
margin: 4px 0;
line-height: 1.5;
}
.copyright a {
color: var(--md-sys-color-primary);
text-decoration: none;
font-weight: 600;
position: relative;
}
.copyright a::after {
content: "";
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background: var(--md-sys-color-primary);
transition: width var(--transition-medium) ease;
}
.copyright a:hover::after {
width: 100%;
}
@media (min-width: 768px) {
.main-content {
grid-template-columns: 1fr 1fr;
}
.progress-card {
grid-column: 1 / -1;
}
}
@media (max-width: 768px) {
.main-content {
padding: 16px;
}
.app-bar-content {
padding: 12px 16px;
}
.app-title {
font-size: 20px;
}
.app-icon {
font-size: 28px;
}
.project-header {
flex-direction: column;
text-align: center;
gap: 16px;
}
.project-version {
align-items: center;
}
.project-links {
grid-template-columns: 1fr;
}
.button-group {
flex-direction: column;
}
.button-group .btn {
width: 100%;
}
.settings-text,
.about-text {
display: none;
}
}
@media (max-width: 480px) {
.project-info-card {
padding: 24px 16px;
}
.tech-info,
.usage-notice {
padding: 16px;
}
.footer-content {
padding: 24px 16px;
}
}
@media print {
.app-bar,
.theme-toggle,
.settings-link,
.about-link,
.btn,
.snackbar {
display: none !important;
}
body {
background: white;
color: black;
}
.card {
box-shadow: none;
border: 1px solid #ccc;
page-break-inside: avoid;
}
:root {
--md-sys-color-background: #ffffff !important;
--md-sys-color-on-background: #000000 !important;
--md-sys-color-surface: #ffffff !important;
--md-sys-color-on-surface: #000000 !important;
}
}

230
web/en/static/css/oobe.css Normal file
View File

@@ -0,0 +1,230 @@
.oobe-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
var(--md-sys-color-primary-container) 0%,
var(--md-sys-color-secondary-container) 100%
);
padding: 20px;
}
.oobe-card {
max-width: 500px;
width: 100%;
background: var(--md-sys-color-surface);
border-radius: var(--md-sys-shape-corner-extra-large);
box-shadow: var(--md-sys-elevation-level3);
overflow: hidden;
animation: slideInUp 0.6s ease-out;
}
.oobe-header {
background: linear-gradient(
45deg,
var(--md-sys-color-primary),
var(--md-sys-color-tertiary)
);
color: var(--md-sys-color-on-primary);
padding: 40px 32px;
text-align: center;
}
.oobe-logo {
font-size: 64px;
margin-bottom: 16px;
animation: float 3s ease-in-out infinite;
}
.oobe-title {
font-size: 28px;
font-weight: 600;
margin: 0 0 8px 0;
letter-spacing: -0.5px;
}
.oobe-subtitle {
font-size: 16px;
opacity: 0.9;
margin: 0;
}
.oobe-content {
padding: 32px;
}
.oobe-step {
display: none;
animation: fadeIn 0.4s ease-out;
}
.oobe-step.active {
display: block;
}
.step-indicator {
display: flex;
justify-content: center;
margin-bottom: 32px;
}
.step-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--md-sys-color-outline);
margin: 0 6px;
transition: all 0.3s ease;
}
.step-dot.active {
background: var(--md-sys-color-primary);
transform: scale(1.2);
}
.step-dot.completed {
background: var(--md-sys-color-tertiary);
}
.welcome-text {
text-align: center;
margin-bottom: 32px;
text-decoration: none;
}
.welcome-text h3 {
color: var(--md-sys-color-on-surface);
margin: 0 0 16px 0;
font-size: 20px;
font-weight: 500;
text-decoration: none;
}
.welcome-text p {
color: var(--md-sys-color-on-surface-variant);
margin: 0 0 12px 0;
line-height: 1.5;
text-decoration: none;
}
.welcome-text a {
color: var(--md-sys-color-on-surface-variant);
margin: 0 0 12px 0;
line-height: 1.5;
text-decoration: none;
}
.key-input-section {
margin-bottom: 24px;
}
.key-status {
margin-top: 16px;
padding: 16px;
border-radius: var(--md-sys-shape-corner-medium);
background: var(--md-sys-color-surface-variant);
display: none;
}
.key-status.show {
display: block;
animation: slideInDown 0.3s ease-out;
}
.key-status.success {
background: rgba(76, 175, 80, 0.1);
border: 1px solid rgba(76, 175, 80, 0.3);
color: #2e7d32;
}
.key-status.error {
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
color: #c62828;
}
.key-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 16px;
}
.key-info-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.key-info-item .material-icons {
font-size: 18px;
opacity: 0.7;
}
.oobe-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 32px;
}
.btn-large {
padding: 16px 32px;
font-size: 16px;
font-weight: 500;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--md-sys-shape-corner-extra-large);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.loading-overlay.show {
opacity: 1;
visibility: visible;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid var(--md-sys-color-outline);
border-top: 4px solid var(--md-sys-color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@media (max-width: 600px) {
.oobe-container {
padding: 12px;
}
.oobe-header {
padding: 32px 24px;
}
.oobe-content {
padding: 24px;
}
.key-info {
grid-template-columns: 1fr;
}
.oobe-actions {
flex-direction: column;
}
}

View File

@@ -0,0 +1,314 @@
.project-info-card {
background: var(--gradient-primary);
border-radius: var(--md-sys-shape-corner-extra-large);
padding: 32px;
margin-bottom: 24px;
box-shadow: var(--md-sys-elevation-level3);
border: 1px solid rgba(255, 255, 255, 0.2);
position: relative;
overflow: hidden;
}
[data-theme="dark"] .project-info-card {
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(
135deg,
var(--md-sys-color-primary-container) 0%,
var(--md-sys-color-secondary-container) 100%
);
color: var(--md-sys-color-on-primary-container);
}
.project-info-card::before {
content: "";
position: absolute;
top: -50%;
right: -50%;
width: 200%;
height: 200%;
background: radial-gradient(
circle,
rgba(255, 255, 255, 0.1) 0%,
transparent 70%
);
animation: rotate 30s linear infinite;
}
.project-header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 20px;
position: relative;
z-index: 1;
}
.project-logo {
width: 64px;
height: 64px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border-radius: var(--md-sys-shape-corner-large);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
transition: transform var(--transition-medium) ease;
}
.project-logo:hover {
transform: scale(1.1) rotate(5deg);
}
.project-logo .material-icons {
color: white;
font-size: 36px;
}
.project-details {
flex: 1;
}
.project-name {
font-size: 24px;
font-weight: 600;
color: white;
margin: 0 0 4px 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] .project-info-card .project-name {
color: var(--md-sys-color-on-primary-container);
}
.project-subtitle {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
margin: 0;
}
[data-theme="dark"] .project-info-card .project-subtitle {
color: var(--md-sys-color-on-primary-container);
}
.project-version {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.version-label {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
color: white;
padding: 6px 16px;
border-radius: var(--md-sys-shape-corner-full);
font-size: 14px;
font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] .project-info-card .version-label {
color: var(--md-sys-color-on-primary-container);
}
.version-type {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
}
.project-description {
margin-bottom: 24px;
position: relative;
z-index: 1;
}
.project-description p {
font-size: 15px;
color: rgba(255, 255, 255, 0.95);
line-height: 1.6;
margin: 0;
}
.project-links {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 12px;
position: relative;
z-index: 1;
}
.project-link {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
color: white;
text-decoration: none;
border-radius: var(--md-sys-shape-corner-medium);
border: 1px solid rgba(255, 255, 255, 0.2);
transition: all var(--transition-medium) ease;
}
[data-theme="dark"] .project-link {
background: rgba(255, 255, 255, 0.1);
color: var(--md-sys-color-on-primary-container);
}
.project-link:hover {
background: rgba(255, 255, 255, 0.25);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
[data-theme="dark"] .project-link:hover {
background: rgba(255, 255, 255, 0.2);
}
.project-link .material-icons {
font-size: 24px;
transition: transform var(--transition-fast) ease;
}
.project-link:hover .material-icons {
transform: scale(1.1);
}
.link-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.link-title {
font-size: 14px;
font-weight: 600;
}
.link-url {
font-size: 12px;
opacity: 0.8;
}
.tech-info {
background: var(--md-sys-color-surface-container);
backdrop-filter: blur(10px);
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: var(--md-sys-shape-corner-large);
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--md-sys-elevation-level1);
transition: all var(--transition-medium) ease;
}
.tech-info:hover {
transform: translateY(-2px);
box-shadow: var(--md-sys-elevation-level3);
}
.tech-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.tech-header .material-icons {
font-size: 28px;
color: var(--md-sys-color-primary);
}
.tech-header h4 {
font-size: 18px;
font-weight: 600;
color: var(--md-sys-color-on-surface);
margin: 0;
}
.tech-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.tech-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
background: var(--md-sys-color-surface-container-low);
border-radius: var(--md-sys-shape-corner-small);
border: 1px solid var(--md-sys-color-outline-variant);
transition: all var(--transition-fast) ease;
}
.tech-item:hover {
transform: translateX(4px);
border-color: var(--md-sys-color-primary);
}
.tech-item strong {
color: var(--md-sys-color-on-surface);
font-weight: 600;
font-size: 14px;
}
.tech-item span {
color: var(--md-sys-color-on-surface-variant);
font-size: 13px;
line-height: 1.5;
}
.usage-notice {
background: var(--md-sys-color-warning-container);
border: 1px solid var(--md-sys-color-warning);
border-radius: var(--md-sys-shape-corner-large);
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--md-sys-elevation-level1);
}
.notice-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.notice-header .material-icons {
color: var(--md-sys-color-warning);
font-size: 28px;
}
.notice-header h4 {
font-size: 18px;
font-weight: 600;
color: var(--md-sys-color-on-warning-container);
margin: 0;
}
.notice-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.notice-content p {
font-size: 14px;
color: var(--md-sys-color-on-warning-container);
line-height: 1.5;
margin: 0;
padding: 8px 12px;
background: var(--md-sys-color-surface);
border-radius: var(--md-sys-shape-corner-small);
border-left: 3px solid var(--md-sys-color-warning);
}
.notice-content strong {
color: var(--md-sys-color-on-warning-container);
font-weight: 600;
}

View File

@@ -0,0 +1,299 @@
.settings-main {
display: flex;
flex-direction: column;
gap: 24px;
}
.settings-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.setting-item {
display: flex;
flex-direction: column;
gap: 12px;
}
.checkbox-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.checkbox-label {
font-size: 16px;
font-weight: 500;
color: var(--md-sys-color-on-surface);
}
.checkbox-description {
font-size: 13px;
color: var(--md-sys-color-on-surface-variant);
line-height: 1.4;
}
#keyInfoSection {
padding: 20px;
background: var(--md-sys-color-surface-container-low);
border-radius: var(--md-sys-shape-corner-medium);
margin-bottom: 24px;
}
.key-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
margin-top: 16px;
}
.key-info-card {
background: var(--md-sys-color-surface-variant);
border-radius: var(--md-sys-shape-corner-medium);
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
transition: all 0.3s ease;
}
.key-info-card:hover {
background: var(--md-sys-color-primary-container);
transform: translateY(-2px);
}
.key-info-icon {
color: var(--md-sys-color-primary);
font-size: 24px;
}
.key-info-content {
flex: 1;
}
.key-info-label {
font-size: 12px;
color: var(--md-sys-color-on-surface-variant);
text-transform: uppercase;
font-weight: 500;
letter-spacing: 0.5px;
}
.key-info-value {
font-size: 16px;
color: var(--md-sys-color-on-surface);
font-weight: 500;
margin-top: 4px;
}
.key-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: var(--md-sys-shape-corner-small);
font-size: 12px;
font-weight: 500;
}
.key-status-badge.active {
background: rgba(76, 175, 80, 0.1);
color: #2e7d32;
}
.key-status-badge.expired {
background: rgba(244, 67, 54, 0.1);
color: #c62828;
}
.key-status-badge.inactive {
background: rgba(158, 158, 158, 0.1);
color: #616161;
}
.key-change-section {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--md-sys-color-outline);
}
.key-input-group {
display: flex;
gap: 12px;
align-items: end;
}
.key-input-group .text-field {
flex: 1;
font-family: monospace;
letter-spacing: 1px;
}
.path-input-group {
display: flex;
gap: 12px;
align-items: stretch;
}
.path-input-group .text-field {
flex: 1;
}
.status-indicator {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--md-sys-color-surface-container-low);
border-radius: var(--md-sys-shape-corner-medium);
border: 1px solid var(--md-sys-color-outline-variant);
margin-top: 16px;
}
.status-indicator .status-icon {
font-size: 20px;
}
.status-indicator .status-text {
flex: 1;
font-size: 14px;
color: var(--md-sys-color-on-surface);
}
.action-buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.config-status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.config-status-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--md-sys-color-surface-container-low);
border-radius: var(--md-sys-shape-corner-medium);
border: 1px solid var(--md-sys-color-outline-variant);
}
.config-status-item .material-icons {
font-size: 24px;
color: var(--md-sys-color-primary);
}
.config-status-content {
flex: 1;
}
.config-status-label {
font-size: 12px;
color: var(--md-sys-color-on-surface-variant);
margin-bottom: 4px;
}
.config-status-value {
font-size: 14px;
font-weight: 500;
color: var(--md-sys-color-on-surface);
}
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
opacity: 0;
visibility: hidden;
transition: all var(--transition-medium) ease;
}
.dialog-overlay.show {
opacity: 1;
visibility: visible;
}
.dialog {
background: var(--md-sys-color-surface);
border-radius: var(--md-sys-shape-corner-extra-large);
box-shadow: var(--md-sys-elevation-level5);
max-width: 400px;
width: 90%;
transform: scale(0.9);
transition: transform var(--transition-medium) ease;
}
.dialog-overlay.show .dialog {
transform: scale(1);
}
.dialog-header {
padding: 24px 24px 16px;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.dialog-header h3 {
margin: 0;
font-size: 20px;
font-weight: 500;
color: var(--md-sys-color-on-surface);
}
.dialog-content {
padding: 24px;
}
.dialog-content p {
margin: 0;
font-size: 14px;
color: var(--md-sys-color-on-surface-variant);
line-height: 1.5;
}
.dialog-actions {
padding: 16px 24px;
display: flex;
gap: 12px;
justify-content: flex-end;
border-top: 1px solid var(--md-sys-color-outline-variant);
}
@media (max-width: 768px) {
.key-input-group {
flex-direction: column;
align-items: stretch;
}
.path-input-group {
flex-direction: column;
}
.action-buttons {
flex-direction: column;
}
.action-buttons .btn {
width: 100%;
}
.config-status-grid {
grid-template-columns: 1fr;
}
}
.btn.btn-text:first-child {
margin-right: 8px;
}

View File

@@ -0,0 +1,8 @@
@import url("./variables.css");
@import url("./base.css");
@import url("./layout.css");
@import url("./components.css");
@import url("./animations.css");
@import url("./oobe.css");
@import url("./project-info.css");
@import url("./utilities.css");

View File

@@ -0,0 +1,222 @@
.progress-container {
background: var(--md-sys-color-surface-container-low);
border-radius: var(--md-sys-shape-corner-medium);
min-height: 200px;
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--md-sys-color-outline-variant);
}
.progress-container::-webkit-scrollbar {
width: 8px;
}
.progress-container::-webkit-scrollbar-track {
background: var(--md-sys-color-surface-container);
border-radius: 4px;
}
.progress-container::-webkit-scrollbar-thumb {
background: var(--md-sys-color-primary);
border-radius: 4px;
}
.progress-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: var(--md-sys-color-on-surface-variant);
gap: 12px;
}
.progress-placeholder .material-icons {
font-size: 48px;
opacity: 0.3;
animation: pulse 2s ease-in-out infinite;
}
.log-entry {
padding: 12px 16px;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
font-family: "Courier New", monospace;
font-size: 13px;
display: flex;
align-items: flex-start;
gap: 12px;
transition: background-color var(--transition-fast) ease;
animation: slideIn var(--transition-medium) ease-out;
position: relative;
}
.log-entry::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--md-sys-color-primary);
opacity: 0;
transition: opacity var(--transition-fast) ease;
}
.log-entry:hover::before {
opacity: 1;
}
.log-entry:hover {
background-color: var(--md-sys-color-primary-container);
}
[data-theme="dark"] .log-entry {
background: var(--md-sys-color-surface-container);
}
[data-theme="dark"] .log-entry:hover {
background: var(--md-sys-color-surface-container-high);
}
.log-entry:last-child {
border-bottom: none;
}
.log-entry.warning {
background-color: var(--md-sys-color-warning-container);
border-left: 3px solid var(--md-sys-color-warning);
}
.log-entry.error {
background-color: var(--md-sys-color-error-container);
border-left: 3px solid var(--md-sys-color-error);
}
.log-timestamp {
font-family:
"LXGW Wenkai Mono",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
color: var(--md-sys-color-on-surface-variant);
font-size: 11px;
min-width: 60px;
opacity: 0.7;
}
.log-message {
font-family:
"LXGW Wenkai Mono",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
flex: 1;
word-break: break-word;
color: var(--md-sys-color-on-surface);
}
.config-status {
display: flex;
flex-direction: column;
gap: 12px;
}
.settings-link,
.about-link {
color: var(--md-sys-color-on-surface) !important;
text-decoration: none;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: var(--md-sys-shape-corner-full);
background: var(--md-sys-color-surface-container);
border: 1px solid var(--md-sys-color-outline-variant);
transition: all var(--transition-medium) ease;
}
.settings-link:hover,
.about-link:hover {
background: var(--md-sys-color-primary-container);
border-color: var(--md-sys-color-primary);
transform: translateY(-2px);
box-shadow: var(--md-sys-elevation-level2);
}
.settings-link:hover .material-icons,
.about-link:hover .material-icons {
animation: rotate 1s ease-in-out;
}
.settings-text,
.about-text {
font-size: 14px;
font-weight: 600;
}
[data-tooltip] {
position: relative;
}
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(-8px);
background: var(--md-sys-color-inverse-surface);
color: var(--md-sys-color-inverse-on-surface);
padding: 8px 12px;
border-radius: var(--md-sys-shape-corner-small);
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all var(--transition-fast) ease;
pointer-events: none;
z-index: 1000;
}
[data-tooltip]:hover::after {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
.unlock-form {
display: flex;
flex-direction: column;
gap: 24px;
}
@media (prefers-contrast: high) {
.card {
border-width: 2px;
}
.btn {
border: 2px solid currentColor;
}
.text-field {
border-width: 3px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@media (max-width: 768px) and (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .card {
box-shadow: var(--md-sys-elevation-level2);
}
}

View File

@@ -0,0 +1,206 @@
:root {
--transition-fast: 200ms;
--transition-medium: 300ms;
--transition-slow: 400ms;
--md-sys-color-primary: #6750a4;
--md-sys-color-on-primary: #ffffff;
--md-sys-color-primary-container: #e9ddff;
--md-sys-color-on-primary-container: #22005d;
--md-sys-color-secondary: #00bcd4;
--md-sys-color-on-secondary: #ffffff;
--md-sys-color-secondary-container: #b2ebf2;
--md-sys-color-on-secondary-container: #00363d;
--md-sys-color-tertiary: #ff6f00;
--md-sys-color-on-tertiary: #ffffff;
--md-sys-color-tertiary-container: #ffe0b2;
--md-sys-color-on-tertiary-container: #4a1c00;
--md-sys-color-error: #dc2626;
--md-sys-color-on-error: #ffffff;
--md-sys-color-error-container: #fee2e2;
--md-sys-color-on-error-container: #7f1d1d;
--md-sys-color-background: #fdfcff;
--md-sys-color-on-background: #1a1c1e;
--md-sys-color-surface: #fdfcff;
--md-sys-color-on-surface: #1a1c1e;
--md-sys-color-surface-variant: #e7e0ec;
--md-sys-color-on-surface-variant: #49454e;
--md-sys-color-surface-container-lowest: #ffffff;
--md-sys-color-surface-container-low: #f7f2fa;
--md-sys-color-surface-container: #f1ecf4;
--md-sys-color-surface-container-high: #ebe6ee;
--md-sys-color-surface-container-highest: #e6e0e9;
--md-sys-color-outline: #79747e;
--md-sys-color-outline-variant: #cac4cf;
--md-sys-color-success: #16a34a;
--md-sys-color-on-success: #ffffff;
--md-sys-color-success-container: #dcfce7;
--md-sys-color-warning: #f59e0b;
--md-sys-color-on-warning: #ffffff;
--md-sys-color-warning-container: #fef3c7;
--md-sys-color-surface-tint: #6750a4;
--md-sys-color-scrim: #000000;
--md-sys-color-inverse-surface: #313033;
--md-sys-color-inverse-on-surface: #f4eff4;
--md-sys-color-inverse-primary: #d0bcff;
--md-sys-elevation-level0: none;
--md-sys-elevation-level1:
0px 1px 3px 1px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);
--md-sys-elevation-level2:
0px 2px 6px 2px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);
--md-sys-elevation-level3:
0px 4px 8px 3px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.3);
--md-sys-elevation-level4:
0px 6px 10px 4px rgba(0, 0, 0, 0.15), 0px 2px 3px 0px rgba(0, 0, 0, 0.3);
--md-sys-elevation-level5:
0px 8px 12px 6px rgba(0, 0, 0, 0.15), 0px 4px 4px 0px rgba(0, 0, 0, 0.3);
--md-sys-shape-corner-none: 0px;
--md-sys-shape-corner-extra-small: 4px;
--md-sys-shape-corner-small: 8px;
--md-sys-shape-corner-medium: 12px;
--md-sys-shape-corner-large: 16px;
--md-sys-shape-corner-extra-large: 28px;
--md-sys-shape-corner-full: 999px;
--gradient-primary: linear-gradient(135deg, #6750a4 0%, #8b7cc4 100%);
--gradient-secondary: linear-gradient(135deg, #00bcd4 0%, #4dd0e1 100%);
--gradient-surface: linear-gradient(135deg, #fdfcff 0%, #f7f2fa 100%);
}
[data-theme="dark"] {
--md-sys-color-primary: #d0bcff;
--md-sys-color-on-primary: #381e72;
--md-sys-color-primary-container: #4f378a;
--md-sys-color-on-primary-container: #e9ddff;
--md-sys-color-secondary: #4dd0e1;
--md-sys-color-on-secondary: #00363d;
--md-sys-color-secondary-container: #005662;
--md-sys-color-on-secondary-container: #b2ebf2;
--md-sys-color-tertiary: #ffb74d;
--md-sys-color-on-tertiary: #4a1c00;
--md-sys-color-tertiary-container: #6a2c00;
--md-sys-color-on-tertiary-container: #ffe0b2;
--md-sys-color-error: #f87171;
--md-sys-color-on-error: #7f1d1d;
--md-sys-color-error-container: #991b1b;
--md-sys-color-on-error-container: #fee2e2;
--md-sys-color-background: #1a1c1e;
--md-sys-color-on-background: #e3e2e6;
--md-sys-color-surface: #1a1c1e;
--md-sys-color-on-surface: #e3e2e6;
--md-sys-color-surface-variant: #49454e;
--md-sys-color-on-surface-variant: #cac4cf;
--md-sys-color-surface-container-lowest: #0e0f11;
--md-sys-color-surface-container-low: #1a1c1e;
--md-sys-color-surface-container: #1e2022;
--md-sys-color-surface-container-high: #282a2d;
--md-sys-color-surface-container-highest: #333538;
--md-sys-color-outline: #938f99;
--md-sys-color-outline-variant: #49454e;
--md-sys-color-success: #4ade80;
--md-sys-color-on-success: #14532d;
--md-sys-color-success-container: #166534;
--md-sys-color-warning: #fbbf24;
--md-sys-color-on-warning: #451a03;
--md-sys-color-warning-container: #78350f;
--md-sys-color-surface-tint: #d0bcff;
--md-sys-color-scrim: #000000;
--md-sys-color-inverse-surface: #e6e0e9;
--md-sys-color-inverse-on-surface: #313033;
--md-sys-color-inverse-primary: #6750a4;
--md-sys-elevation-level0: none;
--md-sys-elevation-level1:
0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level2:
0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 2px 6px 2px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level3:
0px 1px 3px 0px rgba(0, 0, 0, 0.3), 0px 4px 8px 3px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level4:
0px 2px 3px 0px rgba(0, 0, 0, 0.3), 0px 6px 10px 4px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level5:
0px 4px 4px 0px rgba(0, 0, 0, 0.3), 0px 8px 12px 6px rgba(0, 0, 0, 0.15);
--gradient-primary: linear-gradient(135deg, #4f378a 0%, #6750a4 100%);
--gradient-secondary: linear-gradient(135deg, #005662 0%, #00838f 100%);
--gradient-surface: linear-gradient(135deg, #1a1c1e 0%, #1e2022 100%);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--md-sys-color-primary: #d0bcff;
--md-sys-color-on-primary: #381e72;
--md-sys-color-primary-container: #4f378a;
--md-sys-color-on-primary-container: #e9ddff;
--md-sys-color-secondary: #4dd0e1;
--md-sys-color-on-secondary: #00363d;
--md-sys-color-secondary-container: #005662;
--md-sys-color-on-secondary-container: #b2ebf2;
--md-sys-color-tertiary: #ffb74d;
--md-sys-color-on-tertiary: #4a1c00;
--md-sys-color-tertiary-container: #6a2c00;
--md-sys-color-on-tertiary-container: #ffe0b2;
--md-sys-color-error: #f87171;
--md-sys-color-on-error: #7f1d1d;
--md-sys-color-error-container: #991b1b;
--md-sys-color-on-error-container: #fee2e2;
--md-sys-color-background: #1a1c1e;
--md-sys-color-on-background: #e3e2e6;
--md-sys-color-surface: #1a1c1e;
--md-sys-color-on-surface: #e3e2e6;
--md-sys-color-surface-variant: #49454e;
--md-sys-color-on-surface-variant: #cac4cf;
--md-sys-color-surface-container-lowest: #0e0f11;
--md-sys-color-surface-container-low: #1a1c1e;
--md-sys-color-surface-container: #1e2022;
--md-sys-color-surface-container-high: #282a2d;
--md-sys-color-surface-container-highest: #333538;
--md-sys-color-outline: #938f99;
--md-sys-color-outline-variant: #49454e;
--md-sys-color-success: #4ade80;
--md-sys-color-on-success: #14532d;
--md-sys-color-success-container: #166534;
--md-sys-color-warning: #fbbf24;
--md-sys-color-on-warning: #451a03;
--md-sys-color-warning-container: #78350f;
--md-sys-color-surface-tint: #d0bcff;
--md-sys-color-scrim: #000000;
--md-sys-color-inverse-surface: #e6e0e9;
--md-sys-color-inverse-on-surface: #313033;
--md-sys-color-inverse-primary: #6750a4;
--md-sys-elevation-level0: none;
--md-sys-elevation-level1:
0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level2:
0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 2px 6px 2px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level3:
0px 1px 3px 0px rgba(0, 0, 0, 0.3), 0px 4px 8px 3px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level4:
0px 2px 3px 0px rgba(0, 0, 0, 0.3), 0px 6px 10px 4px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level5:
0px 4px 4px 0px rgba(0, 0, 0, 0.3), 0px 8px 12px 6px rgba(0, 0, 0, 0.15);
--gradient-primary: linear-gradient(135deg, #4f378a 0%, #6750a4 100%);
--gradient-secondary: linear-gradient(135deg, #005662 0%, #00838f 100%);
--gradient-surface: linear-gradient(135deg, #1a1c1e 0%, #1e2022 100%);
}
}

685
web/en/static/js/app.js Normal file
View File

@@ -0,0 +1,685 @@
class OnekeyWebApp {
constructor() {
this.socket = null;
this.taskStatus = "idle";
this.reconnectTimer = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 2000;
this.initializeSocket();
this.initializeEventListeners();
this.checkConfig();
}
initializeSocket() {
this.connectWebSocket();
}
connectWebSocket() {
try {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/ws`;
this.socket = new WebSocket(wsUrl);
this.socket.onopen = () => {
console.log("Connected to server");
this.showSnackbar("Connected to server", "success");
this.reconnectAttempts = 0;
this.startHeartbeat();
};
this.socket.onclose = (event) => {
console.log("Disconnected from server", event);
this.showSnackbar("Disconnected from server", "error");
this.stopHeartbeat();
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectTimer = setTimeout(() => {
this.reconnectAttempts++;
console.log(
`Trying reconnect... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
);
this.connectWebSocket();
}, this.reconnectDelay);
}
};
this.socket.onerror = (error) => {
console.error("WebSocket error:", error);
};
this.socket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.handleMessage(message);
} catch (e) {
console.error("Failed to parse message:", e);
}
};
} catch (error) {
console.error("Failed to connect WebSocket:", error);
this.showSnackbar("Failed to connect WebSocket", "error");
}
}
handleMessage(message) {
switch (message.type) {
case "connected":
console.log(message.data.message);
break;
case "task_progress":
this.addLogEntry(message.data.type, message.data.message);
break;
case "pong":
break;
default:
console.log("Unknown message type:", message.type);
}
}
startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type: "ping" }));
}
}, 30000);
}
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.stopHeartbeat();
if (this.socket) {
this.socket.close();
this.socket = null;
}
}
initializeEventListeners() {
const unlockForm = document.getElementById("unlockForm");
unlockForm.addEventListener("submit", (e) => {
e.preventDefault();
this.startUnlockTask();
});
const resetBtn = document.getElementById("resetBtn");
resetBtn.addEventListener("click", () => {
this.resetForm();
});
const clearLogBtn = document.getElementById("clearLogBtn");
clearLogBtn.addEventListener("click", () => {
this.clearLogs();
});
const snackbarClose = document.getElementById("snackbarClose");
snackbarClose.addEventListener("click", () => {
this.hideSnackbar();
});
window.addEventListener("beforeunload", () => {
this.disconnect();
});
}
async checkConfig() {
const configStatus = document.getElementById("configStatus");
try {
const response = await fetch("/api/config");
const data = await response.json();
if (data.success) {
configStatus.innerHTML = this.generateConfigStatusHTML(data.config);
} else {
configStatus.innerHTML = `
<div class="status-item">
<span class="material-icons status-icon error">error</span>
<span class="status-text">Load config failed: ${data.message}</span>
</div>
`;
}
} catch (error) {
configStatus.innerHTML = `
<div class="status-item">
<span class="material-icons status-icon error">error</span>
<span class="status-text">Failed to connect WebSocket</span>
</div>
`;
}
}
generateConfigStatusHTML(config) {
const items = [];
if (config.steam_path) {
items.push(`
<div class="status-item">
<span class="material-icons status-icon success">check_circle</span>
<span class="status-text">Steam Path: ${config.steam_path}</span>
</div>
`);
} else {
items.push(`
<div class="status-item">
<span class="material-icons status-icon error">error</span>
<span class="status-text">Steam path not found</span>
</div>
`);
}
if (config.debug_mode) {
items.push(`
<div class="status-item">
<span class="material-icons status-icon warning">bug_report</span>
<span class="status-text">Debug mode is enable</span>
</div>
`);
}
return items.join("");
}
toggleAndDLC() {
document.getElementById("+DLC").checked = true;
}
async startUnlockTask() {
if (this.taskStatus === "running") {
this.showSnackbar("There is already a task running", "warning");
return;
}
const formData = new FormData(document.getElementById("unlockForm"));
const appId = formData.get("appId").trim();
const toolType = formData.get("toolType");
const ADLC = formData.get("+DLC") === "on";
if (!appId) {
this.showSnackbar("Please enter App ID", "error");
return;
}
const appIdPattern = /^[\d-]+$/;
if (!appIdPattern.test(appId)) {
this.showSnackbar(
"Invalid App ID format, should be a number or numbers separated by -",
"error",
);
return;
}
this.taskStatus = "running";
this.updateUIForRunningTask();
this.clearLogs();
this.addLogEntry("info", `Start working on the game ${appId}...`);
try {
const response = await fetch("/api/start_unlock", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
app_id: appId,
tool_type: toolType,
dlc: ADLC,
}),
});
const data = await response.json();
if (data.success) {
this.showSnackbar("Mission has started", "success");
this.startStatusPolling();
} else {
this.taskStatus = "idle";
this.updateUIForIdleTask();
this.showSnackbar(data.message, "error");
this.addLogEntry("error", data.message);
}
} catch (error) {
this.taskStatus = "idle";
this.updateUIForIdleTask();
this.showSnackbar("Failed to start task", "error");
this.addLogEntry("error", `Failed to start task: ${error.message}`);
}
}
startStatusPolling() {
const pollInterval = setInterval(async () => {
try {
const response = await fetch("/api/task_status");
const data = await response.json();
if (data.status === "completed") {
clearInterval(pollInterval);
this.taskStatus = "completed";
this.updateUIForIdleTask();
if (data.result && data.result.success) {
this.showSnackbar(data.result.message, "success");
this.addLogEntry("info", data.result.message);
} else if (data.result) {
this.showSnackbar(data.result.message, "error");
this.addLogEntry("error", data.result.message);
}
} else if (data.status === "error") {
clearInterval(pollInterval);
this.taskStatus = "error";
this.updateUIForIdleTask();
if (data.result) {
this.showSnackbar(data.result.message, "error");
this.addLogEntry("error", data.result.message);
}
}
} catch (error) {
console.error("Status polling error:", error);
}
}, 1000);
}
updateUIForRunningTask() {
const unlockBtn = document.getElementById("unlockBtn");
const resetBtn = document.getElementById("resetBtn");
const appIdInput = document.getElementById("appId");
const toolTypeRadios = document.querySelectorAll('input[name="toolType"]');
unlockBtn.disabled = true;
unlockBtn.innerHTML = `
<span class="material-icons">hourglass_empty</span>
Executing...
`;
resetBtn.disabled = true;
appIdInput.disabled = true;
toolTypeRadios.forEach((radio) => (radio.disabled = true));
}
updateUIForIdleTask() {
const unlockBtn = document.getElementById("unlockBtn");
const resetBtn = document.getElementById("resetBtn");
const appIdInput = document.getElementById("appId");
const toolTypeRadios = document.querySelectorAll('input[name="toolType"]');
unlockBtn.disabled = false;
unlockBtn.innerHTML = `
<span class="material-icons">play_arrow</span>
Start unlocking
`;
resetBtn.disabled = false;
appIdInput.disabled = false;
toolTypeRadios.forEach((radio) => (radio.disabled = false));
}
resetForm() {
if (this.taskStatus === "running") {
this.showSnackbar("The task is running and cannot be reset.", "warning");
return;
}
document.getElementById("unlockForm").reset();
document.querySelector(
'input[name="toolType"][value="steamtools"]',
).checked = true;
this.clearLogs();
this.showSnackbar("Form has been reset", "success");
}
addLogEntry(type, message) {
const progressContainer = document.getElementById("progressContainer");
const placeholder = progressContainer.querySelector(
".progress-placeholder",
);
if (placeholder) {
placeholder.remove();
}
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement("div");
logEntry.className = `log-entry ${type}`;
logEntry.innerHTML = `
<span class="log-timestamp">${timestamp}</span>
<span class="log-message">${this.escapeHtml(message)}</span>
`;
progressContainer.appendChild(logEntry);
progressContainer.scrollTop = progressContainer.scrollHeight;
}
clearLogs() {
const progressContainer = document.getElementById("progressContainer");
progressContainer.innerHTML = `
<div class="progress-placeholder">
<span class="material-icons">info</span>
<p>Wait for task to start...</p>
</div>
`;
}
showSnackbar(message, type = "info") {
const snackbar = document.getElementById("snackbar");
const snackbarMessage = document.getElementById("snackbarMessage");
snackbarMessage.textContent = message;
snackbar.className = `snackbar ${type}`;
snackbar.offsetHeight;
snackbar.classList.add("show");
setTimeout(() => {
this.hideSnackbar();
}, 4000);
}
hideSnackbar() {
const snackbar = document.getElementById("snackbar");
snackbar.classList.remove("show");
}
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
}
const style = document.createElement("style");
style.textContent = `
@keyframes iconRotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute("href"));
if (target) {
target.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
});
});
document.addEventListener("DOMContentLoaded", () => {
const cards = document.querySelectorAll(".card");
cards.forEach((card) => {
card.addEventListener("mousemove", (e) => {
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const percentX = (x - centerX) / centerX;
const percentY = (y - centerY) / centerY;
const rotateX = percentY * 5;
const rotateY = percentX * 5;
card.style.transform = `perspective(1000px) rotateX(${-rotateX}deg) rotateY(${rotateY}deg) translateZ(10px)`;
});
card.addEventListener("mouseleave", () => {
card.style.transform = "";
});
});
});
function typeWriter(element, text, speed = 50) {
let i = 0;
element.textContent = "";
function type() {
if (i < text.length) {
element.textContent += text.charAt(i);
i++;
setTimeout(type, speed);
}
}
type();
}
function animateValue(element, start, end, duration) {
const range = end - start;
const increment = range / (duration / 16);
let current = start;
const timer = setInterval(() => {
current += increment;
if (
(increment > 0 && current >= end) ||
(increment < 0 && current <= end)
) {
current = end;
clearInterval(timer);
}
element.textContent = Math.round(current);
}, 16);
}
document.querySelectorAll(".btn").forEach((button) => {
button.addEventListener("mousemove", (e) => {
const rect = button.getBoundingClientRect();
const x = e.clientX - rect.left - rect.width / 2;
const y = e.clientY - rect.top - rect.height / 2;
button.style.transform = `translate(${x * 0.1}px, ${y * 0.1}px)`;
});
button.addEventListener("mouseleave", () => {
button.style.transform = "";
});
});
function createParticles() {
const particlesContainer = document.createElement("div");
particlesContainer.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
`;
document.body.appendChild(particlesContainer);
for (let i = 0; i < 50; i++) {
const particle = document.createElement("div");
particle.style.cssText = `
position: absolute;
width: 4px;
height: 4px;
background: rgba(94, 53, 177, 0.3);
border-radius: 50%;
top: ${Math.random() * 100}%;
left: ${Math.random() * 100}%;
animation: floatParticle ${
10 + Math.random() * 20
}s linear infinite;
`;
particlesContainer.appendChild(particle);
}
const style = document.createElement("style");
style.textContent = `
@keyframes floatParticle {
0% {
transform: translateY(100vh) rotate(0deg);
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
transform: translateY(-100vh) rotate(720deg);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
const observerOptions = {
threshold: 0.1,
rootMargin: "0px 0px -50px 0px",
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.style.opacity = "1";
entry.target.style.transform = "translateY(0)";
observer.unobserve(entry.target);
}
});
}, observerOptions);
document.querySelectorAll(".card").forEach((card) => {
card.style.opacity = "0";
card.style.transform = "translateY(20px)";
card.style.transition = "opacity 0.6s ease, transform 0.6s ease";
observer.observe(card);
});
document.addEventListener("mousemove", (e) => {
const light = document.createElement("div");
light.style.cssText = `
position: fixed;
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(94, 53, 177, 0.1) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
z-index: 9999;
transform: translate(-50%, -50%);
transition: opacity 0.3s ease;
`;
light.style.left = e.clientX + "px";
light.style.top = e.clientY + "px";
document.body.appendChild(light);
setTimeout(() => {
light.style.opacity = "0";
setTimeout(() => light.remove(), 300);
}, 100);
});
document.querySelectorAll(".status-icon").forEach((icon) => {
if (icon.classList.contains("success")) {
icon.style.animation = "pulse-icon 2s ease-in-out infinite";
}
});
const pulseStyle = document.createElement("style");
pulseStyle.textContent = `
@keyframes pulse-icon {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
`;
document.head.appendChild(pulseStyle);
const originalShowSnackbar = window.showSnackbar;
if (typeof originalShowSnackbar === "function") {
window.showSnackbar = function (message, type = "info") {
originalShowSnackbar(message, type);
if ("vibrate" in navigator) {
if (type === "error") {
navigator.vibrate([100, 50, 100]);
} else {
navigator.vibrate(50);
}
}
const audio = new Audio(
`data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBSuBzvLZiTYIG2m98OScTgwOUarm7blmFgU7k9n1unEiBC13yO/eizEIHWq+8+OWT` +
`BEFS6Xj67xqGAU+lNr1unIiBCx0xvDdiTYIHWu+8+OWT`,
);
if (type === "success") {
audio.volume = 0.1;
audio.play().catch(() => {});
}
};
}
document.querySelectorAll(".text-field").forEach((input) => {
input.addEventListener("focus", (e) => {
const ripple = document.createElement("div");
ripple.style.cssText = `
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
border: 2px solid var(--md-sys-color-primary);
border-radius: var(--md-sys-shape-corner-medium);
opacity: 0;
pointer-events: none;
animation: inputRipple 0.6s ease-out;
`;
const wrapper = input.parentElement;
wrapper.style.position = "relative";
wrapper.appendChild(ripple);
setTimeout(() => ripple.remove(), 600);
});
});
const inputRippleStyle = document.createElement("style");
inputRippleStyle.textContent = `
@keyframes inputRipple {
0% {
transform: scale(0.8);
opacity: 1;
}
100% {
transform: scale(1.2);
opacity: 0;
}
}
`;
document.head.appendChild(inputRippleStyle);
document.addEventListener("DOMContentLoaded", () => {
createParticles();
document.body.classList.add("loaded");
console.log("UI enhancements loaded ✨");
new OnekeyWebApp();
});

View File

@@ -0,0 +1,180 @@
class ProjectInfoEnhancer {
constructor() {
this.initializeProjectInfo();
}
initializeProjectInfo() {
this.addProjectLinkTracking();
this.addVersionClickEaster();
this.addLogoClickEffect();
}
addProjectLinkTracking() {
const projectLinks = document.querySelectorAll(".project-link");
projectLinks.forEach((link) => {
link.addEventListener("click", (e) => {
const linkType = link.classList.contains("github")
? "GitHub repository"
: link.classList.contains("releases")
? "Download release version"
: link.classList.contains("docs")
? "Use documentation"
: link.classList.contains("issues")
? "Problem feedback"
: "unknown link";
console.log(`User clicked on ${linkType} link`);
link.style.transform = "scale(0.95)";
setTimeout(() => {
link.style.transform = "";
}, 150);
});
});
}
addVersionClickEaster() {
const versionLabels = document.querySelectorAll(".version-label");
let clickCount = 0;
versionLabels.forEach((label) => {
label.addEventListener("click", () => {
clickCount++;
if (clickCount === 5) {
this.showEasterEgg();
clickCount = 0;
}
label.style.animation = "pulse 0.3s ease";
setTimeout(() => {
label.style.animation = "";
}, 300);
});
});
}
addLogoClickEffect() {
const logos = document.querySelectorAll(".project-logo");
logos.forEach((logo) => {
logo.addEventListener("click", () => {
logo.style.transform = "rotate(360deg)";
logo.style.transition = "transform 0.6s ease";
setTimeout(() => {
logo.style.transform = "";
logo.style.transition = "";
}, 600);
this.showTooltip(logo, "🎮 Onekey - Steam unlocking made easy!");
});
});
}
showEasterEgg() {
const messages = [
"🎉 You found a hidden easter egg!",
"🚀 Thank you for using Onekey Tools!",
"⭐ Dont forget to give the project a star!",
"🎮 Happy gaming!",
"🔓 Unlock it with one click and enjoy the game!",
];
const randomMessage = messages[Math.floor(Math.random() * messages.length)];
const easterEgg = document.createElement("div");
easterEgg.className = "easter-egg";
easterEgg.textContent = randomMessage;
easterEgg.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: linear-gradient(45deg, #6750a4, #7d5260);
color: white;
padding: 20px 30px;
border-radius: 15px;
font-size: 18px;
font-weight: 500;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
z-index: 9999;
animation: easterEggBounce 0.6s ease-out;
`;
if (!document.getElementById("easter-egg-styles")) {
const style = document.createElement("style");
style.id = "easter-egg-styles";
style.textContent = `
@keyframes easterEggBounce {
0% { transform: translate(-50%, -50%) scale(0); opacity: 0; }
50% { transform: translate(-50%, -50%) scale(1.1); opacity: 1; }
100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
`;
document.head.appendChild(style);
}
document.body.appendChild(easterEgg);
setTimeout(() => {
easterEgg.style.animation = "easterEggBounce 0.3s ease-in reverse";
setTimeout(() => {
if (easterEgg.parentNode) {
easterEgg.parentNode.removeChild(easterEgg);
}
}, 300);
}, 3000);
}
showTooltip(element, message) {
const tooltip = document.createElement("div");
tooltip.className = "custom-tooltip";
tooltip.textContent = message;
tooltip.style.cssText = `
position: absolute;
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
`;
const rect = element.getBoundingClientRect();
tooltip.style.left = rect.left + rect.width / 2 + "px";
tooltip.style.top = rect.bottom + 10 + "px";
tooltip.style.transform = "translateX(-50%)";
document.body.appendChild(tooltip);
setTimeout(() => {
tooltip.style.opacity = "1";
}, 10);
setTimeout(() => {
tooltip.style.opacity = "0";
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
}, 300);
}, 2000);
}
}
document.addEventListener("DOMContentLoaded", () => {
new ProjectInfoEnhancer();
});

View File

@@ -0,0 +1,677 @@
class SettingsManager {
constructor() {
this.currentConfig = {};
this.currentKeyInfo = null;
this.newKeyData = null;
this.initializeEventListeners();
this.loadConfig();
this.loadKeyInfo();
}
initializeEventListeners() {
document.getElementById("saveConfig").addEventListener("click", () => {
this.saveConfig();
});
document.getElementById("resetConfig").addEventListener("click", () => {
this.showConfirmDialog(
"Reset configuration",
"Are you sure you want to reset all configurations to default? This operation is irreversible.",
() => this.resetConfig(),
);
});
document.getElementById("testConfig").addEventListener("click", () => {
this.testConfig();
});
document.getElementById("detectSteamPath").addEventListener("click", () => {
this.detectSteamPath();
});
document.getElementById("steamPath").addEventListener("input", () => {
this.validateSteamPath();
});
document.getElementById("verifyNewKey").addEventListener("click", () => {
this.verifyNewKey();
});
document.getElementById("changeKey").addEventListener("click", () => {
this.showConfirmDialog(
"Change card password",
"Are you sure you want to change to a new card password? Re-verification is required after replacement.",
() => this.changeKey(),
);
});
document.getElementById("newKey").addEventListener("input", () => {
this.resetNewKeyStatus();
});
document.getElementById("newKey").addEventListener("keypress", (e) => {
if (e.key === "Enter") {
this.verifyNewKey();
}
});
document.getElementById("dialogCancel").addEventListener("click", () => {
this.hideConfirmDialog();
});
document.getElementById("dialogConfirm").addEventListener("click", () => {
this.executeConfirmAction();
});
document.getElementById("snackbarClose").addEventListener("click", () => {
this.hideSnackbar();
});
}
async loadConfig() {
try {
const response = await fetch("/api/config/detailed");
const data = await response.json();
if (data.success) {
this.currentConfig = data.config;
this.populateForm();
this.updateConfigStatus();
} else {
this.showSnackbar(
"Failed to load configuration: " + data.message,
"error",
);
}
} catch (error) {
this.showSnackbar("Unable to connect to server", "error");
console.error("Load config error:", error);
}
}
async loadKeyInfo() {
const keyInfoSection = document.getElementById("keyInfoSection");
try {
const configResponse = await fetch("/api/config/detailed");
const configData = await configResponse.json();
if (!configData.success || !configData.config.key) {
keyInfoSection.innerHTML = `
<div class="expiry-warning">
<span class="material-icons">warning</span>
<div>
<strong>No card password set</strong><br>
Please enter your authorization card password below
</div>
</div>
`;
return;
}
const keyResponse = await fetch("/api/getKeyInfo", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ key: configData.config.key }),
});
const keyData = await keyResponse.json();
if (keyData.key && keyData.info) {
this.currentKeyInfo = keyData.info;
this.displayKeyInfo(keyData.info);
} else {
keyInfoSection.innerHTML = `
<div class="expiry-warning">
<span class="material-icons">error</span>
<div>
<strong>Card password verification failed</strong><br>
The current card password is invalid or expired, please replace it with a new one
</div>
</div>
`;
}
} catch (error) {
keyInfoSection.innerHTML = `
<div class="expiry-warning">
<span class="material-icons">error</span>
<div>
<strong>Failed to obtain card password information</strong><br>
Please check the network connection or contact customer service
</div>
</div>
`;
console.error("Load key info error:", error);
}
}
displayKeyInfo(keyInfo) {
const keyInfoSection = document.getElementById("keyInfoSection");
const expiresAt = new Date(keyInfo.expiresAt);
const createdAt = new Date(keyInfo.createdAt);
const firstUsedAt = keyInfo.firstUsedAt
? new Date(keyInfo.firstUsedAt)
: null;
const now = new Date();
const isExpired = expiresAt < now;
const daysLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24));
const isExpiringSoon = daysLeft <= 7 && daysLeft > 0;
const typeNames = {
day: "day card",
week: "Weekly card",
month: "monthly card",
year: "Annual Pass",
permanent: "permanent card",
};
let statusBadge = "";
if (isExpired && keyInfo.type != "permanent") {
statusBadge =
'<span class="key-status-badge expired"><span class="material-icons" style="font-size: 14px;">cancel</span>Expired</span>';
} else if (!keyInfo.isActive) {
statusBadge =
'<span class="key-status-badge inactive"><span class="material-icons" style="font-size: 14px;">pause</span>Not activated</span>';
} else {
statusBadge =
'<span class="key-status-badge active"><span class="material-icons" style="font-size: 14px;">check_circle</span>normal</span>';
}
let warningSection = "";
if (isExpiringSoon) {
warningSection = `
<div class="expiry-warning">
<span class="material-icons">schedule</span>
<div>
<strong>upcoming expiry reminder</strong><br>
Your card password will expire in ${daysLeft} days, please renew in time
</div>
</div>
`;
}
keyInfoSection.innerHTML = `
<div class="key-info-grid">
<div class="key-info-card">
<span class="material-icons key-info-icon">fingerprint</span>
<div class="key-info-content">
<div class="key-info-label">Cardamom</div>
<div class="key-info-value">${keyInfo.key.substring(
0,
8,
)}...${keyInfo.key.substring(
keyInfo.key.length - 8,
)}</div>
</div>
</div>
<div class="key-info-card">
<span class="material-icons key-info-icon">label</span>
<div class="key-info-content">
<div class="key-info-label">Type</div>
<div class="key-info-value">${
typeNames[keyInfo.type] || keyInfo.type
}</div>
</div>
</div>
<div class="key-info-card">
<span class="material-icons key-info-icon">toggle_on</span>
<div class="key-info-content">
<div class="key-info-label">State</div>
<div class="key-info-value">${statusBadge}</div>
</div>
</div>
<div class="key-info-card">
<span class="material-icons key-info-icon">event</span>
<div class="key-info-content">
<div class="key-info-label">Expiration time</div>
<div class="key-info-value">${expiresAt.toLocaleDateString()} ${expiresAt
.toLocaleTimeString()
.substring(0, 5)}</div>
</div>
</div>
<div class="key-info-card">
<span class="material-icons key-info-icon">analytics</span>
<div class="key-info-content">
<div class="key-info-label">Number of uses</div>
<div class="key-info-value">${keyInfo.usageCount} / ${
keyInfo.totalUsage || "∞"
}</div>
</div>
</div>
<div class="key-info-card">
<span class="material-icons key-info-icon">schedule</span>
<div class="key-info-content">
<div class="key-info-label">Creation time</div>
<div class="key-info-value">${createdAt.toLocaleDateString()}</div>
</div>
</div>
</div>
${warningSection}
`;
}
async verifyNewKey() {
const newKeyInput = document.getElementById("newKey");
const key = newKeyInput.value.trim();
if (!key) {
this.showSnackbar("Please enter new card password", "error");
return;
}
if (!key.match(/^[A-Z0-9_-]+$/)) {
this.showSnackbar("The card password format is incorrect", "error");
return;
}
const verifyBtn = document.getElementById("verifyNewKey");
const changeBtn = document.getElementById("changeKey");
verifyBtn.disabled = true;
verifyBtn.innerHTML =
'<span class="material-icons">hourglass_empty</span>Verifying...';
try {
const response = await fetch("/api/getKeyInfo", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ key: key }),
});
const data = await response.json();
if (data.key && data.info) {
this.newKeyData = data.info;
this.showSnackbar(
"New card password verification successful!",
"success",
);
changeBtn.style.display = "flex";
verifyBtn.style.display = "none";
const typeNames = {
day: "day card",
week: "Weekly card",
month: "Monthly card",
year: "Annual Pass",
permanent: "permanent card",
};
const expiresAt = new Date(data.info.expiresAt);
this.showSnackbar(
`Verification successful! New card password type:
${typeNames[data.info.type]}
}, valid until:${expiresAt.toLocaleDateString()}`,
"success",
);
} else {
this.showSnackbar(
"The new card password is invalid or expired",
"error",
);
this.newKeyData = null;
}
} catch (error) {
this.showSnackbar(
"Verification failed, please check network connection",
"error",
);
console.error("New key verification error:", error);
} finally {
verifyBtn.disabled = false;
verifyBtn.innerHTML = '<span class="material-icons">check</span>Verify';
}
}
async changeKey() {
if (!this.newKeyData) {
this.showSnackbar("Please verify the new card password first", "error");
return;
}
try {
const newKey = document.getElementById("newKey").value.trim();
const updateData = {
key: newKey,
steam_path: this.currentConfig.steam_path || "",
debug_mode: this.currentConfig.debug_mode || false,
logging_files: this.currentConfig.logging_files !== false,
show_console: this.currentConfig.show_console !== false,
};
const response = await fetch("/api/config/update", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(updateData),
});
const data = await response.json();
if (data.success) {
this.showSnackbar("Card secret changed successfully!", "success");
await this.loadKeyInfo();
this.resetNewKeyStatus();
document.getElementById("newKey").value = "";
} else {
this.showSnackbar("Replacement failed: " + data.message, "error");
}
} catch (error) {
this.showSnackbar(
"An error occurred while changing the card password",
"error",
);
console.error("Change key error:", error);
}
this.hideConfirmDialog();
}
resetNewKeyStatus() {
const verifyBtn = document.getElementById("verifyNewKey");
const changeBtn = document.getElementById("changeKey");
verifyBtn.style.display = "flex";
changeBtn.style.display = "none";
this.newKeyData = null;
}
populateForm() {
document.getElementById("steamPath").value =
this.currentConfig.steam_path || "";
document.getElementById("debugMode").checked =
this.currentConfig.debug_mode || false;
document.getElementById("loggingFiles").checked =
this.currentConfig.logging_files !== false;
document.getElementById("showConsole").checked =
this.currentConfig.show_console !== false;
this.validateSteamPath();
}
async saveConfig() {
try {
const config = {
key: this.currentConfig.key || "",
steam_path: document.getElementById("steamPath").value.trim(),
debug_mode: document.getElementById("debugMode").checked,
logging_files: document.getElementById("loggingFiles").checked,
show_console: document.getElementById("showConsole").checked,
};
const response = await fetch("/api/config/update", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(config),
});
const data = await response.json();
if (data.success) {
this.showSnackbar("Configuration saved", "success");
await this.loadConfig();
} else {
this.showSnackbar("Save failed: " + data.message, "error");
}
} catch (error) {
this.showSnackbar(
"An error occurred while saving the configuration",
"error",
);
console.error("Save config error:", error);
}
}
async resetConfig() {
try {
const response = await fetch("/api/config/reset", {
method: "POST",
});
const data = await response.json();
if (data.success) {
this.showSnackbar(
"The configuration has been reset (the card password remains unchanged)",
"success",
);
await this.loadConfig();
} else {
this.showSnackbar("Reset failed: " + data.message, "error");
}
} catch (error) {
this.showSnackbar(
"An error occurred while resetting the configuration",
"error",
);
console.error("Reset config error:", error);
}
this.hideConfirmDialog();
}
async testConfig() {
this.showSnackbar("Testing configuration...", "info");
try {
const response = await fetch("/api/config");
const data = await response.json();
if (data.success) {
let messages = [];
if (data.config.steam_path) {
messages.push("✓ Steam Path configuration is normal");
} else {
messages.push("✗ Steam Abnormal path configuration");
}
if (this.currentKeyInfo) {
const expiresAt = new Date(this.currentKeyInfo.expiresAt);
let isExpired = expiresAt < new Date();
if ((this.currentKeyInfo.type = "permanent")) {
isExpired = false;
}
if (this.currentKeyInfo.isActive && !isExpired) {
messages.push("✓ Card secret status is normal");
} else {
messages.push("✗ Abnormal card secret status");
}
}
this.showSnackbar(
`Configuration test completed: ${messages.join(", ")}`,
"success",
);
} else {
this.showSnackbar(
"Configuration test failed: " + data.message,
"error",
);
}
} catch (error) {
this.showSnackbar(
"An error occurred while configuring the test",
"error",
);
console.error("Test config error:", error);
}
}
detectSteamPath() {
const commonPaths = [
"C:\\Program Files (x86)\\Steam",
"C:\\Program Files\\Steam",
"D:\\Steam",
"E:\\Steam",
];
const suggestedPath = commonPaths[0];
document.getElementById("steamPath").value = suggestedPath;
this.validateSteamPath();
this.showSnackbar(
"It has been set as a common path, please confirm whether it is correct",
"info",
);
}
validateSteamPath() {
const steamPath = document.getElementById("steamPath").value.trim();
const statusElement = document.getElementById("steamPathStatus");
if (!steamPath) {
statusElement.className = "status-indicator";
statusElement.innerHTML = `
<span class="material-icons status-icon">info</span>
<span class="status-text">The automatically detected path will be used</span>
`;
} else {
if (steamPath.toLowerCase().includes("steam")) {
statusElement.className = "status-indicator success";
statusElement.innerHTML = `
<span class="material-icons status-icon">check_circle</span>
<span class="status-text">The path format looks correct</span>
`;
} else {
statusElement.className = "status-indicator warning";
statusElement.innerHTML = `
<span class="material-icons status-icon">warning</span>
<span class="status-text">The path may be incorrect, please confirm</span>
`;
}
}
}
updateConfigStatus() {
const statusGrid = document.getElementById("configStatusGrid");
const config = this.currentConfig;
const statusCards = [];
if (config.steam_path && config.steam_path_exists) {
statusCards.push({
type: "success",
icon: "folder",
title: "Steam path",
description: `Path is valid: ${config.steam_path}`,
});
} else if (config.steam_path) {
statusCards.push({
type: "warning",
icon: "folder_off",
title: "Steam path",
description: "Path is set but may be invalid",
});
} else {
statusCards.push({
type: "error",
icon: "error",
title: "Steam path",
description: "Not set or auto-detection failed",
});
}
if (config.debug_mode) {
statusCards.push({
type: "warning",
icon: "bug_report",
title: "Debug mode",
description: "Enabled, detailed logs will be output",
});
}
if (config.logging_files) {
statusCards.push({
type: "success",
icon: "description",
title: "Log file",
description: "Enabled, logs will be saved to file",
});
}
statusGrid.innerHTML = statusCards
.map(
(card) => `
<div class="status-card ${card.type}">
<span class="material-icons status-card-icon">${card.icon}</span>
<div class="status-card-content">
<div class="status-card-title">${card.title}</div>
<div class="status-card-description">${card.description}</div>
</div>
</div>
`,
)
.join("");
}
showConfirmDialog(title, message, confirmAction) {
document.getElementById("dialogTitle").textContent = title;
document.getElementById("dialogMessage").textContent = message;
this.confirmAction = confirmAction;
const dialog = document.getElementById("confirmDialog");
dialog.classList.add("show");
}
hideConfirmDialog() {
const dialog = document.getElementById("confirmDialog");
dialog.classList.remove("show");
this.confirmAction = null;
}
executeConfirmAction() {
if (this.confirmAction) {
this.confirmAction();
}
this.hideConfirmDialog();
}
showSnackbar(message, type = "info") {
const snackbar = document.getElementById("snackbar");
const snackbarMessage = document.getElementById("snackbarMessage");
snackbarMessage.textContent = message;
snackbar.className = `snackbar ${type}`;
snackbar.offsetHeight;
snackbar.classList.add("show");
setTimeout(() => {
this.hideSnackbar();
}, 4000);
}
hideSnackbar() {
const snackbar = document.getElementById("snackbar");
snackbar.classList.remove("show");
}
}
function goBack() {
window.location.href = "/";
}
document.addEventListener("DOMContentLoaded", () => {
new SettingsManager();
});

178
web/en/static/js/theme.js Normal file
View File

@@ -0,0 +1,178 @@
class ThemeManager {
constructor() {
this.themeToggle = document.getElementById("themeToggle");
this.currentTheme = this.getStoredTheme() || this.getPreferredTheme();
this.isTransitioning = false;
this.createThemeIndicator();
this.applyTheme(this.currentTheme, false);
this.initializeEventListeners();
}
getStoredTheme() {
return localStorage.getItem("theme");
}
getPreferredTheme() {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
createThemeIndicator() {
const indicator = document.createElement("div");
indicator.className = "theme-indicator";
indicator.id = "themeIndicator";
document.body.appendChild(indicator);
this.themeIndicator = indicator;
}
showThemeIndicator(message) {
this.themeIndicator.textContent = message;
this.themeIndicator.classList.add("show");
setTimeout(() => {
this.themeIndicator.classList.remove("show");
}, 2000);
}
applyTheme(theme, animate = true) {
if (this.isTransitioning) return;
this.isTransitioning = true;
if (animate) {
document.body.classList.add("theme-transitioning");
}
document.documentElement.setAttribute("data-theme", theme);
this.updateToggleButton(theme);
localStorage.setItem("theme", theme);
this.currentTheme = theme;
window.dispatchEvent(
new CustomEvent("themechange", {
detail: { theme, animated: animate },
}),
);
setTimeout(() => {
document.body.classList.remove("theme-transitioning");
this.isTransitioning = false;
}, 600);
}
updateToggleButton(theme) {
if (this.themeToggle) {
const icon = this.themeToggle.querySelector(".material-icons");
icon.textContent = theme === "dark" ? "dark_mode" : "light_mode";
this.themeToggle.title =
theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
icon.style.animation = "none";
icon.offsetHeight;
icon.style.animation = "iconRotate 300ms ease";
}
}
toggleTheme(event) {
const newTheme = this.currentTheme === "dark" ? "light" : "dark";
if (event && event.currentTarget) {
const rect = event.currentTarget.getBoundingClientRect();
const x = ((rect.left + rect.width / 2) / window.innerWidth) * 100;
const y = ((rect.top + rect.height / 2) / window.innerHeight) * 100;
document.documentElement.style.setProperty("--x", `${x}%`);
document.documentElement.style.setProperty("--y", `${y}%`);
}
if ("vibrate" in navigator) {
navigator.vibrate(50);
}
this.applyTheme(newTheme);
this.logThemeSwitch(newTheme);
}
logThemeSwitch(theme) {
console.log(`Topic switch to: ${theme}`);
}
initializeEventListeners() {
if (this.themeToggle) {
this.themeToggle.addEventListener("click", (e) => this.toggleTheme(e));
this.themeToggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this.toggleTheme(e);
}
});
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", (e) => {
if (!this.getStoredTheme()) {
this.applyTheme(e.matches ? "dark" : "light");
}
});
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "T") {
e.preventDefault();
this.toggleTheme();
}
});
window.addEventListener("storage", (e) => {
if (e.key === "theme" && e.newValue) {
this.applyTheme(e.newValue, false);
}
});
}
getThemePalette() {
const computedStyle = getComputedStyle(document.documentElement);
return {
primary: computedStyle.getPropertyValue("--md-sys-color-primary").trim(),
secondary: computedStyle
.getPropertyValue("--md-sys-color-secondary")
.trim(),
surface: computedStyle.getPropertyValue("--md-sys-color-surface").trim(),
background: computedStyle
.getPropertyValue("--md-sys-color-background")
.trim(),
onBackground: computedStyle
.getPropertyValue("--md-sys-color-on-background")
.trim(),
};
}
shouldUseDarkMode() {
const hour = new Date().getHours();
return hour >= 18 || hour < 6;
}
enableAutoThemeSwitch() {
const checkTime = () => {
if (!this.getStoredTheme()) {
const shouldBeDark = this.shouldUseDarkMode();
const currentTheme = this.currentTheme;
if (
(shouldBeDark && currentTheme === "light") ||
(!shouldBeDark && currentTheme === "dark")
) {
this.applyTheme(shouldBeDark ? "dark" : "light");
}
}
};
setInterval(checkTime, 60000);
checkTime();
}
}
window.ThemeManager = new ThemeManager();

172
web/en/templates/about.html Normal file
View File

@@ -0,0 +1,172 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onekey - About</title>
<!-- Material Design 3 -->
<link
href="https://cdn.jsdmirror.com/gh/ikun0014/font@main/LXGWWenKaiMono-Regular/result.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
<!-- Custom Styles -->
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<div class="footer-content">
<!-- Project Info Card -->
<div class="project-info-card">
<div class="project-header">
<div class="project-logo">
<span class="material-icons">extension</span>
</div>
<div class="project-details">
<h3 class="project-name">Onekey</h3>
<p class="project-subtitle">
Intuitive, elegant game unlock solution
</p>
</div>
<div class="project-version">
<span class="version-label">v2.1.1</span>
<span class="version-type">Web UI</span>
</div>
</div>
<div class="project-links">
<a
href="https://github.com/ikunshare/Onekey"
target="_blank"
class="project-link github"
>
<span class="material-icons">code</span>
<div class="link-content">
<span class="link-title">GitHub Repository</span>
<span class="link-url">github.com/ikunshare/Onekey</span>
</div>
</a>
<a
href="https://github.com/ikunshare/Onekey/releases"
target="_blank"
class="project-link releases"
>
<span class="material-icons">file_download</span>
<div class="link-content">
<span class="link-title">Download Release</span>
<span class="link-url">Get Latest Version</span>
</div>
</a>
<a
href="https://shop.ikunshare.com"
target="_blank"
class="project-link buy_cdk"
>
<span class="material-icons">shopping_cart</span>
<div class="link-content">
<span class="link-title">Purchase Link</span>
<span class="link-url">Buy Activation Key</span>
</div>
</a>
<a
href="https://github.com/qwq-xinkeng"
target="_blank"
class="project-link author"
>
<span class="material-icons">person</span>
<div class="link-content">
<span class="link-title">Author Page</span>
<span class="link-url">github.com/qwq-xinkeng</span>
</div>
</a>
<a
href="https://github.com/ikun0014"
target="_blank"
class="project-link author"
>
<span class="material-icons">person</span>
<div class="link-content">
<span class="link-title">Author Page</span>
<span class="link-url">github.com/ikun0014</span>
</div>
</a>
<a
href="https://github.com/ikunshare/Onekey/issues"
target="_blank"
class="project-link issues"
>
<span class="material-icons">bug_report</span>
<div class="link-content">
<span class="link-title">Issue Feedback</span>
<span class="link-url">Report Bugs or Suggestions</span>
</div>
</a>
</div>
</div>
<!-- Technical Information -->
<div class="tech-info">
<div class="tech-header">
<span class="material-icons">code</span>
<h4>Technical Information</h4>
</div>
<div class="tech-content">
<div class="tech-item">
<strong>🐍 Backend Technology</strong>
<span>Python 3.8+ • FastAPI • AsyncIO • HTTPX</span>
</div>
<div class="tech-item">
<strong>🌐 Frontend Technology</strong>
<span>HTML5 • CSS3 • JavaScript ES6+ • Material Design 3.0</span>
</div>
<div class="tech-item">
<strong>🔧 Supported Tools</strong>
<span>SteamTools • GreenLuma</span>
</div>
</div>
</div>
<!-- Usage Notice -->
<div class="usage-notice">
<div class="notice-header">
<span class="material-icons">info</span>
<h4>Usage Notice</h4>
</div>
<div class="notice-content">
<p>
<strong>🖥️ System Requirements</strong> - Please ensure Windows 10/11
is installed and Steam client is properly configured
</p>
<p>
<strong>🛠️ Tool Preparation</strong> - Please install SteamTools or
GreenLuma unlock tool before use
</p>
<p>
<strong>🔒 Disclaimer</strong> - This tool is for educational and
communication purposes only, users assume all related risks
</p>
<p>
<strong>⭐ Support Project</strong> - If this tool helps you, feel
free to star the project on GitHub
</p>
</div>
</div>
<!-- Copyright Information -->
<div class="copyright">
<p>© 2025 Onekey Steam Unlock Tool • Authors: qwq-xinkeng && ikun0014</p>
<p>
Project URL:
<a href="https://github.com/ikunshare/Onekey" target="_blank"
>https://github.com/ikunshare/Onekey</a
>
</p>
</div>
<script src="{{ url_for('static', path='js/theme.js') }}"></script>
</div>
</html>

180
web/en/templates/index.html Normal file
View File

@@ -0,0 +1,180 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onekey - Home</title>
<!-- Material Design 3 -->
<link
href="https://cdn.jsdmirror.com/gh/ikun0014/font@main/LXGWWenKaiMono-Regular/result.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
<!-- Custom Styles -->
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="app-container">
<!-- Top App Bar -->
<header class="app-bar">
<div class="app-bar-content">
<span class="material-icons app-icon">games</span>
<h1 class="app-title">Onekey</h1>
<button
type="button"
class="theme-toggle"
id="themeToggle"
title="Toggle Theme"
>
<span class="material-icons">light_mode</span>
</button>
<a href="/settings" class="btn btn-text settings-link">
<span class="material-icons">settings</span>
<span class="settings-text">Settings</span>
</a>
<a href="/about" class="btn btn-text about-link">
<span class="material-icons">info</span>
<span class="about-text">About</span>
</a>
</div>
</header>
<!-- Main Content Area -->
<main class="main-content">
<!-- Configuration Status Card -->
<div class="card config-card">
<div class="card-header">
<span class="material-icons">settings</span>
<h2>Configuration Status</h2>
</div>
<div class="card-content">
<div class="config-status" id="configStatus">
<div class="loading">Checking configuration...</div>
</div>
</div>
</div>
<!-- Game Unlock Card -->
<div class="card unlock-card">
<div class="card-header">
<span class="material-icons">lock_open</span>
<h2>Game Unlock</h2>
</div>
<div class="card-content">
<form id="unlockForm" class="unlock-form">
<div class="input-group">
<label for="appId" class="input-label">Steam App ID</label>
<input
type="text"
id="appId"
name="appId"
class="text-field"
placeholder="Enter game App ID"
inputmode="numeric"
autocomplete="off"
autofocus
required
/>
<div class="input-feedback" id="appIdFeedback"></div>
<div class="input-helper">Example: 730 (CS2), 570 (Dota 2)</div>
</div>
<div class="input-group">
<label class="input-label">Unlock Tool</label>
<div class="radio-group">
<label class="radio-item">
<input
type="radio"
name="toolType"
value="steamtools"
checked
/>
<span class="radio-button"></span>
<span class="radio-label"
>SteamTools (actively updated, recommended)</span
>
</label>
<label class="radio-item">
<input type="radio" name="toolType" value="greenluma" />
<span class="radio-button"></span>
<span class="radio-label"
>GreenLuma (updated yearly, no GUI)</span
>
</label>
</div>
</div>
<div class="input-group" id="+DLCGroup">
<label class="checkbox-item">
<input type="checkbox" id="+DLC" name="+DLC" />
<span class="checkbox-button"></span>
<span class="checkbox-label"
>Retrieve and include all DLCs</span
>
</label>
<div class="input-helper">
Note: Some DLC depots are bundled with the base game and not
separated
</div>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary" id="unlockBtn">
<span class="material-icons">play_arrow</span>
Start Unlock
</button>
<button type="button" class="btn btn-secondary" id="resetBtn">
<span class="material-icons">refresh</span>
Reset
</button>
</div>
</form>
</div>
</div>
<!-- Progress Log Card -->
<div class="card progress-card">
<div class="card-header">
<span class="material-icons">timeline</span>
<h2>Execution Log</h2>
<div class="card-actions">
<button class="btn btn-text" id="clearLogBtn">
<span class="material-icons">clear_all</span>
Clear
</button>
</div>
</div>
<div class="card-content">
<div class="progress-container" id="progressContainer">
<div class="progress-placeholder">
<span class="material-icons">info</span>
<p>Waiting for task to start...</p>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Snackbar -->
<div id="snackbar" class="snackbar">
<div class="snackbar-content">
<span id="snackbarMessage"></span>
<button id="snackbarClose" class="snackbar-action">
<span class="material-icons">close</span>
</button>
</div>
</div>
<!-- Scripts -->
<script src="{{ url_for('static', path='js/app.js') }}"></script>
<script src="{{ url_for('static', path='js/theme.js') }}"></script>
<script src="{{ url_for('static', path='js/project-info.js') }}"></script>
</body>
</html>

501
web/en/templates/oobe.html Normal file
View File

@@ -0,0 +1,501 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onekey - First Time Setup Wizard</title>
<!-- Material Design 3 -->
<link
href="https://cdn.jsdmirror.com/gh/ikun0014/font@main/LXGWWenKaiMono-Regular/result.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
<!-- Custom Styles -->
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="oobe-container">
<!-- Top App Bar -->
<div class="oobe-card">
<div class="oobe-header">
<button
type="button"
class="theme-toggle"
id="themeToggle"
title="Toggle Theme"
>
<span class="material-icons">light_mode</span>
</button>
<div class="oobe-logo">
<span class="material-icons" style="font-size: inherit"
>extension</span
>
</div>
<h1 class="oobe-title">Welcome to Onekey</h1>
<p class="oobe-subtitle">One-click unlock, enjoy gaming experience</p>
</div>
<div class="oobe-content">
<div class="step-indicator">
<div class="step-dot active" data-step="0"></div>
<div class="step-dot" data-step="1"></div>
<div class="step-dot" data-step="2"></div>
</div>
<!-- Step 1: Welcome -->
<div class="oobe-step active" data-step="0">
<div class="welcome-text">
<h3>🎮 Welcome to the Onekey World</h3>
<p>
Onekey is a powerful Steam game unlock tool that helps you
easily manage and unlock games.
</p>
<p>
Before getting started, we need to verify your activation key.
</p>
<p><strong>Features:</strong></p>
<p>• Supports both SteamTools and GreenLuma unlock methods</p>
<p>• Intuitive web interface, simple operation</p>
<p>• Real-time log display, transparent process</p>
<p>
• Frontend code completely open source, absolutely no account
theft/mining
</p>
<a href="https://shop.ikunshare.com" target="_blank"
>• Click here to purchase activation key</a
>
</div>
</div>
<!-- Step 2: Key Verification -->
<div class="oobe-step" data-step="1">
<div class="welcome-text">
<h3>🔑 Activate Your Key</h3>
<p>
Please enter your activation key to activate the Onekey tool.
</p>
</div>
<div class="key-input-section">
<div class="input-group">
<label for="activationKey" class="input-label"
>Activation Key</label
>
<input
type="text"
id="activationKey"
class="text-field"
placeholder="Please enter your key"
autocomplete="off"
/>
<div class="input-helper">
Key format: [PREFIX]_XXXXXXXX-XXXXXXXXXXXXXXXX
</div>
</div>
<div class="key-status" id="keyStatus">
<div class="status-header">
<span class="material-icons" id="statusIcon">info</span>
<span id="statusMessage">Verifying...</span>
</div>
<div class="key-info" id="keyInfo"></div>
</div>
</div>
</div>
<!-- Step 4: Complete -->
<div class="oobe-step" data-step="3">
<div class="welcome-text">
<h3>🎉 Setup Complete</h3>
<p>
Congratulations! You have successfully activated the Onekey
tool.
</p>
<p>Now you can start using all features.</p>
<div
class="key-info"
id="finalKeyInfo"
style="margin-top: 24px"
></div>
</div>
</div>
<div class="oobe-actions">
<button
type="button"
id="prevBtn"
class="btn btn-text"
style="display: none"
>
<span class="material-icons">arrow_back</span>
Previous
</button>
<button
type="button"
id="nextBtn"
class="btn btn-primary btn-large"
>
<span class="material-icons">arrow_forward</span>
Next
</button>
<button
type="button"
id="verifyBtn"
class="btn btn-primary btn-large"
style="display: none"
>
<span class="material-icons">verified</span>
Verify Key
</button>
<button
type="button"
id="finishBtn"
class="btn btn-primary btn-large"
style="display: none"
>
<span class="material-icons">check</span>
Start Using
</button>
</div>
</div>
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-spinner"></div>
</div>
</div>
</div>
<!-- Snackbar -->
<div id="snackbar" class="snackbar">
<div class="snackbar-content">
<span id="snackbarMessage"></span>
<button id="snackbarClose" class="snackbar-action">
<span class="material-icons">close</span>
</button>
</div>
</div>
<script>
class OOBEManager {
constructor() {
this.currentStep = 0;
this.totalSteps = 3;
this.keyData = null;
this.initializeEventListeners();
this.updateStepDisplay();
}
initializeEventListeners() {
document.getElementById("nextBtn").addEventListener("click", () => {
this.nextStep();
});
document.getElementById("prevBtn").addEventListener("click", () => {
this.prevStep();
});
document.getElementById("verifyBtn").addEventListener("click", () => {
this.verifyKey();
});
document.getElementById("finishBtn").addEventListener("click", () => {
this.finishSetup();
});
document
.getElementById("activationKey")
.addEventListener("input", () => {
this.resetKeyStatus();
});
document
.getElementById("activationKey")
.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
this.verifyKey();
}
});
document
.getElementById("snackbarClose")
.addEventListener("click", () => {
this.hideSnackbar();
});
}
nextStep() {
if (this.currentStep < this.totalSteps - 1) {
this.currentStep++;
this.updateStepDisplay();
}
}
prevStep() {
if (this.currentStep > 0) {
this.currentStep--;
this.updateStepDisplay();
}
}
updateStepDisplay() {
document.querySelectorAll(".step-dot").forEach((dot, index) => {
dot.classList.remove("active", "completed");
if (index < this.currentStep) {
dot.classList.add("completed");
} else if (index === this.currentStep) {
dot.classList.add("active");
}
});
document.querySelectorAll(".oobe-step").forEach((step, index) => {
step.classList.toggle("active", index === this.currentStep);
});
this.updateButtons();
}
updateButtons() {
const prevBtn = document.getElementById("prevBtn");
const nextBtn = document.getElementById("nextBtn");
const verifyBtn = document.getElementById("verifyBtn");
const finishBtn = document.getElementById("finishBtn");
[prevBtn, nextBtn, verifyBtn, finishBtn].forEach((btn) => {
btn.style.display = "none";
});
if (this.currentStep > 0) {
prevBtn.style.display = "flex";
}
switch (this.currentStep) {
case 0:
nextBtn.style.display = "flex";
break;
case 1:
verifyBtn.style.display = "flex";
break;
case 2:
finishBtn.style.display = "flex";
break;
}
}
resetKeyStatus() {
const keyStatus = document.getElementById("keyStatus");
keyStatus.classList.remove("show", "success", "error");
}
async verifyKey() {
const keyInput = document.getElementById("activationKey");
const key = keyInput.value.trim();
if (!key) {
this.showSnackbar("Please enter the key", "error");
return;
}
if (!key.match(/^[A-Z0-9_-]+$/)) {
this.showKeyStatus("error", "Invalid key format", "error");
return;
}
this.showLoading(true);
try {
const response = await fetch("/api/getKeyInfo", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ key: key }),
});
const data = await response.json();
if (data.key && data.info) {
this.keyData = data.info;
this.showKeyStatus(
"success",
"Key verified successfully!",
"check_circle",
);
this.displayKeyInfo(data.info);
setTimeout(() => {
this.nextStep();
this.showFinalKeyInfo(data.info);
}, 2000);
} else {
this.showKeyStatus(
"error",
data.message || "Key does not exist or has expired",
"error",
);
}
} catch (error) {
this.showKeyStatus(
"error",
"Verification failed, please check network connection",
"error",
);
console.error("Key verification error:", error);
} finally {
this.showLoading(false);
}
}
showKeyStatus(type, message, icon) {
const keyStatus = document.getElementById("keyStatus");
const statusIcon = document.getElementById("statusIcon");
const statusMessage = document.getElementById("statusMessage");
statusIcon.textContent = icon;
statusMessage.textContent = message;
keyStatus.className = `key-status show ${type}`;
}
displayKeyInfo(keyInfo) {
const keyInfoContainer = document.getElementById("keyInfo");
const expiresAt = new Date(keyInfo.expiresAt);
const isExpired = expiresAt < new Date();
const typeNames = {
day: "Daily",
week: "Weekly",
month: "Monthly",
year: "Yearly",
permanent: "Permanent",
};
keyInfoContainer.innerHTML = `
<div class="key-info-item">
<span class="material-icons">label</span>
<span>Type: ${typeNames[keyInfo.type] || keyInfo.type}</span>
</div>
<div class="key-info-item">
<span class="material-icons">schedule</span>
<span>Expires: ${expiresAt.toLocaleDateString()}</span>
</div>
<div class="key-info-item">
<span class="material-icons">analytics</span>
<span>Usage Count: ${keyInfo.usageCount}</span>
</div>
<div class="key-info-item">
<span class="material-icons">${keyInfo.isActive && !isExpired ? "check_circle" : "cancel"}</span>
<span>Status: ${keyInfo.isActive && !isExpired ? "Valid" : "Invalid"}</span>
</div>
`;
}
showFinalKeyInfo(keyInfo) {
const finalKeyInfo = document.getElementById("finalKeyInfo");
const expiresAt = new Date(keyInfo.expiresAt);
const typeNames = {
day: "Daily",
week: "Weekly",
month: "Monthly",
year: "Yearly",
permanent: "Permanent",
};
finalKeyInfo.innerHTML = `
<div class="key-info-item">
<span class="material-icons">verified_user</span>
<span><strong>Key Type: </strong>${typeNames[keyInfo.type] || keyInfo.type}</span>
</div>
<div class="key-info-item">
<span class="material-icons">event</span>
<span><strong>Valid Until: </strong>${expiresAt.toLocaleDateString()} ${expiresAt.toLocaleTimeString()}</span>
</div>
`;
}
async finishSetup() {
if (!this.keyData) {
this.showSnackbar("Key data lost, please verify again", "error");
this.currentStep = 1;
this.updateStepDisplay();
return;
}
this.showLoading(true);
try {
const response = await fetch("/api/config/update", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: document.getElementById("activationKey").value.trim(),
steam_path: "",
debug_mode: false,
logging_files: true,
show_console: false,
}),
});
const data = await response.json();
if (data.success) {
this.showSnackbar(
"Configuration saved successfully, redirecting...",
"success",
);
setTimeout(() => {
window.location.href = "/";
}, 1500);
} else {
throw new Error(data.message || "Failed to save configuration");
}
} catch (error) {
this.showSnackbar(
"Failed to save configuration: " + error.message,
"error",
);
console.error("Save config error:", error);
} finally {
this.showLoading(false);
}
}
showLoading(show) {
const overlay = document.getElementById("loadingOverlay");
overlay.classList.toggle("show", show);
}
showSnackbar(message, type = "info") {
const snackbar = document.getElementById("snackbar");
const snackbarMessage = document.getElementById("snackbarMessage");
snackbarMessage.textContent = message;
snackbar.className = `snackbar ${type} show`;
setTimeout(() => {
this.hideSnackbar();
}, 4000);
}
hideSnackbar() {
const snackbar = document.getElementById("snackbar");
snackbar.classList.remove("show");
}
}
document.addEventListener("DOMContentLoaded", () => {
new OOBEManager();
});
</script>
<script src="{{ url_for('static', path='js/theme.js') }}"></script>
</body>
</html>

View File

@@ -0,0 +1,292 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onekey - Settings</title>
<!-- Material Design 3 -->
<link
href="https://cdn.jsdmirror.com/gh/ikun0014/font@main/LXGWWenKaiMono-Regular/result.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
<!-- Custom Styles -->
<link rel="stylesheet" href="/static/css/style.css" />
<link rel="stylesheet" href="/static/css/settings.css" />
</head>
<body>
<div class="app-container">
<!-- Top App Bar -->
<header class="app-bar">
<div class="app-bar-content">
<button class="btn btn-text" onclick="goBack()">
<span class="material-icons">arrow_back</span>
</button>
<span class="material-icons app-icon">settings</span>
<h1 class="app-title">Settings</h1>
</div>
</header>
<!-- Main Content Area -->
<main class="main-content settings-main">
<!-- Key Management Card -->
<div class="card">
<div class="card-header">
<span class="material-icons">verified</span>
<h2>Key Management</h2>
</div>
<div class="card-content">
<div class="settings-section">
<div id="keyInfoSection">
<div class="loading">Loading key information...</div>
</div>
<div class="key-change-section">
<h4
style="
margin: 0 0 16px 0;
color: var(--md-sys-color-on-surface);
"
>
<span
class="material-icons"
style="vertical-align: middle; margin-right: 8px"
>swap_horiz</span
>
Change Key
</h4>
<div class="key-input-group">
<div class="input-group" style="flex: 1; margin: 0">
<label for="newKey" class="input-label">New Key</label>
<input
type="text"
id="newKey"
class="text-field"
placeholder="Please enter new key"
autocomplete="off"
/>
<div class="input-helper">
Format: [PREFIX]_XXXXXXXX-XXXXXXXXXXXXXXXX
</div>
</div>
<button
type="button"
id="verifyNewKey"
class="btn btn-secondary"
>
<span class="material-icons">check</span>
Verify
</button>
<button
type="button"
id="changeKey"
class="btn btn-primary"
style="display: none"
>
<span class="material-icons">save</span>
Save
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Steam Configuration Card -->
<div class="card">
<div class="card-header">
<span class="material-icons">games</span>
<h2>Steam Configuration</h2>
</div>
<div class="card-content">
<div class="settings-section">
<div class="input-group">
<label for="steamPath" class="input-label"
>Steam Installation Path</label
>
<div class="path-input-group">
<input
type="text"
id="steamPath"
class="text-field"
placeholder="Leave blank for auto-detect, or manually enter Steam installation path"
/>
<button
type="button"
id="detectSteamPath"
class="btn btn-secondary"
>
<span class="material-icons">search</span>
Auto Detect
</button>
</div>
<div class="input-helper">
The program will attempt to automatically detect the Steam
installation path. If detection fails, please enter it
manually. Usually located at: C:\Program Files (x86)\Steam
</div>
</div>
<div class="status-indicator" id="steamPathStatus">
<span class="material-icons status-icon">info</span>
<span class="status-text">Waiting for detection...</span>
</div>
</div>
</div>
</div>
<!-- Application Configuration Card -->
<div class="card">
<div class="card-header">
<span class="material-icons">tune</span>
<h2>Application Configuration</h2>
</div>
<div class="card-content">
<div class="settings-section">
<div class="setting-item">
<label class="setting-label">Language Selection</label>
<div class="radio-group">
<label class="radio-item">
<input
type="radio"
name="language"
value="zh"
id="language-zh"
/>
<span class="radio-button"></span>
<span class="radio-label">Simplified Chinese</span>
</label>
<label class="radio-item">
<input
type="radio"
name="language"
value="en"
id="language-en"
checked
/>
<span class="radio-button"></span>
<span class="radio-label">English</span>
</label>
</div>
</div>
<div class="setting-item">
<label class="checkbox-item">
<input type="checkbox" id="debugMode" />
<span class="checkbox-button"></span>
<div class="checkbox-content">
<span class="checkbox-label">Debug Mode</span>
<span class="checkbox-description"
>Enable detailed debug log output</span
>
</div>
</label>
</div>
<div class="setting-item">
<label class="checkbox-item">
<input type="checkbox" id="loggingFiles" />
<span class="checkbox-button"></span>
<div class="checkbox-content">
<span class="checkbox-label">Save Log Files</span>
<span class="checkbox-description"
>Save logs to files for troubleshooting</span
>
</div>
</label>
</div>
<div class="setting-item">
<label class="checkbox-item">
<input type="checkbox" id="showConsole" />
<span class="checkbox-button"></span>
<div class="checkbox-content">
<span class="checkbox-label">Show Console Window</span>
<span class="checkbox-description"
>Display console window and log output on startup</span
>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- Action Buttons Card -->
<div class="card">
<div class="card-content">
<div class="action-buttons">
<button type="button" id="saveConfig" class="btn btn-primary">
<span class="material-icons">save</span>
Save Configuration
</button>
<button type="button" id="resetConfig" class="btn btn-secondary">
<span class="material-icons">restore</span>
Reset to Default
</button>
<button type="button" id="testConfig" class="btn btn-secondary">
<span class="material-icons">check_circle</span>
Test Configuration
</button>
</div>
</div>
</div>
<!-- Configuration Information Display Card -->
<div class="card">
<div class="card-header">
<span class="material-icons">info</span>
<h2>Configuration Status</h2>
</div>
<div class="card-content">
<div class="config-status-grid" id="configStatusGrid">
<div class="loading">Loading configuration status...</div>
</div>
</div>
</div>
</main>
</div>
<!-- Confirmation Dialog -->
<div id="confirmDialog" class="dialog-overlay">
<div class="dialog">
<div class="dialog-header">
<h3 id="dialogTitle">Confirm Action</h3>
</div>
<div class="dialog-content">
<p id="dialogMessage">
Are you sure you want to perform this action?
</p>
</div>
<div class="dialog-actions">
<button type="button" id="dialogCancel" class="btn btn-text">
Cancel
</button>
<button type="button" id="dialogConfirm" class="btn btn-primary">
Confirm
</button>
</div>
</div>
</div>
<!-- Snackbar -->
<div id="snackbar" class="snackbar">
<div class="snackbar-content">
<span id="snackbarMessage"></span>
<button id="snackbarClose" class="snackbar-action">
<span class="material-icons">close</span>
</button>
</div>
</div>
<!-- Scripts -->
<script src="{{ url_for('static', path='js/settings.js') }}"></script>
<script src="{{ url_for('static', path='js/theme.js') }}"></script>
<script src="{{ url_for('static', path='js/project-info.js') }}"></script>
</body>
</html>

View File

@@ -0,0 +1,277 @@
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInDown {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slideInUp {
from {
transform: translateY(60px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes subtle-rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.3;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.05);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
@keyframes shimmer {
0% {
background-position: -200% center;
}
100% {
background-position: 200% center;
}
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
10%,
30%,
50%,
70%,
90% {
transform: translateX(-5px);
}
20%,
40%,
60%,
80% {
transform: translateX(5px);
}
}
@keyframes success-pulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.4);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 0 10px rgba(76, 175, 80, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0);
}
}
@keyframes loading-bounce {
0%,
80%,
100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.loading {
display: flex;
align-items: center;
gap: 12px;
color: var(--md-sys-color-on-surface-variant);
}
.loading::before {
content: "";
width: 20px;
height: 20px;
border: 2px solid var(--md-sys-color-primary-container);
border-top: 2px solid var(--md-sys-color-primary);
border-radius: 50%;
animation: spin 1s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite;
}
.loading-dots {
display: inline-flex;
gap: 4px;
}
.loading-dots span {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--md-sys-color-primary);
animation: loading-bounce 1.4s ease-in-out infinite;
}
.loading-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dots span:nth-child(2) {
animation-delay: -0.16s;
}
.skeleton-loader {
background: linear-gradient(
90deg,
var(--md-sys-color-surface-container) 25%,
var(--md-sys-color-surface-container-high) 50%,
var(--md-sys-color-surface-container) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.error-state {
animation: shake 0.5s ease-in-out;
}
.success-state {
animation: success-pulse 0.5s ease-out;
}
.ripple {
position: relative;
overflow: hidden;
}
.ripple::before {
content: "";
position: absolute;
top: var(--ripple-y, 50%);
left: var(--ripple-x, 50%);
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
transform: translate(-50%, -50%);
transition:
width 0.6s ease,
height 0.6s ease,
opacity 0.6s ease;
opacity: 0;
}
.ripple:active::before {
width: 300px;
height: 300px;
opacity: 0.3;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
.animate-fadeIn {
animation: fadeIn var(--transition-medium) ease;
}
.animate-slideUp {
animation: slideUp var(--transition-medium) ease-out;
}
.animate-slideIn {
animation: slideIn var(--transition-medium) ease-out;
}
.animate-pulse {
animation: pulse 2s ease-in-out infinite;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
.animate-spin {
animation: spin 1s linear infinite;
}

165
web/zh/static/css/base.css Normal file
View File

@@ -0,0 +1,165 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
transition:
background-color var(--transition-medium) ease,
color var(--transition-medium) ease,
border-color var(--transition-medium) ease;
}
:root {
transition:
background-color 0.3s ease,
color 0.3s ease;
}
body {
font-family:
"LXGW Wenkai Mono",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background-color: var(--md-sys-color-background);
color: var(--md-sys-color-on-background);
line-height: 1.5;
min-height: 100vh;
overflow-x: hidden;
overflow-y: auto;
position: relative;
}
body::before {
content: "";
position: fixed;
top: -50%;
right: -50%;
width: 200%;
height: 200%;
background:
radial-gradient(
circle at 30% 80%,
rgba(103, 80, 164, 0.05) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 20%,
rgba(0, 188, 212, 0.05) 0%,
transparent 50%
);
pointer-events: none;
z-index: 0;
}
[data-theme="dark"] body::before {
background:
radial-gradient(
circle at 30% 80%,
rgba(208, 188, 255, 0.03) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 20%,
rgba(77, 208, 225, 0.03) 0%,
transparent 50%
);
}
[data-theme="dark"] {
color-scheme: dark;
}
:focus-visible {
outline: 2px solid var(--md-sys-color-primary);
outline-offset: 2px;
}
::selection {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
[data-theme="dark"] ::selection {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: var(--md-sys-color-surface-container);
border-radius: 6px;
}
::-webkit-scrollbar-thumb {
background: var(--md-sys-color-primary);
border-radius: 6px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--gradient-primary);
}
[data-theme="dark"]::-webkit-scrollbar-track {
background: var(--md-sys-color-surface-container);
}
[data-theme="dark"]::-webkit-scrollbar-thumb {
background: var(--md-sys-color-primary);
}
[data-theme="dark"]::-webkit-scrollbar-thumb:hover {
background: var(--gradient-primary);
}
input::placeholder {
font-family:
"LXGW Wenkai Mono",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
font-size: 12px;
color: var(--md-sys-color-on-surface-variant);
margin-left: 4px;
}
[data-theme="dark"] input:-webkit-autofill,
[data-theme="dark"] input:-webkit-autofill:hover,
[data-theme="dark"] input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--md-sys-color-on-surface);
-webkit-box-shadow: 0 0 0px 1000px var(--md-sys-color-surface-container) inset;
transition: background-color 5000s ease-in-out 0s;
}
.btn,
.card,
.theme-toggle {
will-change: transform;
}
.btn:not(:hover),
.card:not(:hover),
.theme-toggle:not(:hover) {
will-change: auto;
}
button:active,
.btn:active,
.card:active {
transform: scale(0.98);
}
* {
transition:
background-color var(--transition-medium) ease,
color var(--transition-medium) ease,
border-color var(--transition-medium) ease,
box-shadow var(--transition-medium) ease;
}

View File

@@ -0,0 +1,459 @@
.card {
background: var(--md-sys-color-surface);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: var(--md-sys-shape-corner-large);
box-shadow: var(--md-sys-elevation-level1);
border: 1px solid var(--md-sys-color-outline-variant);
overflow: hidden;
transition: all var(--transition-medium) cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
--hover-scale: 1.02;
--hover-shadow: var(--md-sys-elevation-level3);
animation: slideUp 0.6s ease-out backwards;
}
.card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--gradient-primary);
opacity: 0;
transition: opacity var(--transition-medium) ease;
}
.card:hover {
transform: translateY(-2px) scale(var(--hover-scale, 1));
box-shadow: var(--hover-shadow);
}
.card:hover::before {
opacity: 1;
}
.card:nth-child(1) {
animation-delay: 0.1s;
}
.card:nth-child(2) {
animation-delay: 0.2s;
}
.card:nth-child(3) {
animation-delay: 0.3s;
}
.card-header {
padding: 24px;
display: flex;
align-items: center;
gap: 16px;
background: var(--md-sys-color-surface-container-low);
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.card-header .material-icons {
font-size: 28px;
color: var(--md-sys-color-primary);
}
.card-header h2 {
font-size: 20px;
font-weight: 600;
color: var(--md-sys-color-on-surface);
flex: 1;
}
.card-content {
padding: 24px;
}
.btn {
padding: 14px 28px;
border-radius: var(--md-sys-shape-corner-full);
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all var(--transition-medium) cubic-bezier(0.4, 0, 0.2, 1);
text-decoration: none;
position: relative;
overflow: hidden;
}
.btn::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition:
width var(--transition-slow) ease,
height var(--transition-slow) ease;
}
.btn:active::before {
width: 300px;
height: 300px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
box-shadow: var(--md-sys-elevation-level1);
}
.btn-primary:hover:not(:disabled) {
background: var(--gradient-primary);
transform: translateY(-2px);
box-shadow: var(--md-sys-elevation-level3);
}
.btn-secondary {
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
border: 1px solid var(--md-sys-color-outline-variant);
}
.btn-secondary:hover:not(:disabled) {
background: var(--md-sys-color-secondary);
color: var(--md-sys-color-on-secondary);
transform: translateY(-2px);
box-shadow: var(--md-sys-elevation-level2);
}
.btn-text {
background-color: transparent;
color: var(--md-sys-color-primary);
padding: 8px 16px;
}
.btn-text:hover:not(:disabled) {
background-color: var(--md-sys-color-primary-container);
}
.button-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.input-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.input-label {
font-size: 14px;
font-weight: 500;
color: var(--md-sys-color-on-surface);
margin-left: 4px;
}
.text-field {
padding: 16px;
border: 2px solid var(--md-sys-color-outline-variant);
border-radius: var(--md-sys-shape-corner-medium);
background-color: var(--md-sys-color-surface-container-low);
color: var(--md-sys-color-on-surface);
font-size: 16px;
transition: all var(--transition-fast) ease;
position: relative;
}
.text-field:hover {
border-color: var(--md-sys-color-outline);
background-color: var(--md-sys-color-surface-container);
}
.text-field:focus {
outline: none;
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-surface-container);
box-shadow: 0 0 0 3px rgba(103, 80, 164, 0.15);
}
[data-theme="dark"] .text-field:focus {
box-shadow: 0 0 0 3px rgba(208, 188, 255, 0.15);
}
.input-helper {
font-size: 12px;
color: var(--md-sys-color-on-surface-variant);
margin-left: 4px;
}
.radio-group {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.radio-item {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
padding: 8px 12px;
border-radius: var(--md-sys-shape-corner-medium);
transition: background-color var(--transition-fast) ease;
}
.radio-item:hover {
background-color: var(--md-sys-color-primary-container);
}
.radio-item input[type="radio"] {
display: none;
}
.radio-button {
width: 20px;
height: 20px;
border: 2px solid var(--md-sys-color-outline);
border-radius: 50%;
position: relative;
transition: all var(--transition-fast) ease;
}
.radio-button::after {
content: "";
width: 12px;
height: 12px;
background: var(--md-sys-color-primary);
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform var(--transition-fast)
cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.radio-item input[type="radio"]:checked + .radio-button {
border-color: var(--md-sys-color-primary);
}
.radio-item input[type="radio"]:checked + .radio-button::after {
transform: translate(-50%, -50%) scale(1);
}
.checkbox-item {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
padding: 8px 12px;
border-radius: var(--md-sys-shape-corner-medium);
transition: background-color var(--transition-fast) ease;
}
.checkbox-item:hover {
background-color: var(--md-sys-color-primary-container);
}
.checkbox-item input[type="checkbox"] {
display: none;
}
.checkbox-button {
width: 20px;
height: 20px;
border: 2px solid var(--md-sys-color-outline);
border-radius: var(--md-sys-shape-corner-extra-small);
position: relative;
transition: all var(--transition-fast) ease;
}
.checkbox-button::after {
content: "✓";
color: var(--md-sys-color-on-primary);
font-size: 14px;
font-weight: bold;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform var(--transition-fast)
cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.checkbox-item input[type="checkbox"]:checked + .checkbox-button {
background: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
.checkbox-item input[type="checkbox"]:checked + .checkbox-button::after {
transform: translate(-50%, -50%) scale(1);
}
.theme-toggle {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--md-sys-color-surface-container);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast) ease;
color: var(--md-sys-color-on-surface);
}
.theme-toggle:hover {
background: var(--md-sys-color-primary-container);
transform: scale(1.1);
}
.theme-toggle .material-icons {
font-size: 20px;
transition: transform var(--transition-medium) ease;
}
.theme-toggle:active .material-icons {
transform: rotate(180deg);
}
.snackbar {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--md-sys-color-inverse-surface);
color: var(--md-sys-color-inverse-on-surface);
border-radius: var(--md-sys-shape-corner-medium);
box-shadow: var(--md-sys-elevation-level4);
z-index: 2000;
max-width: 560px;
min-width: 344px;
opacity: 0;
visibility: hidden;
transition: all var(--transition-medium) cubic-bezier(0.4, 0, 0.2, 1);
}
.snackbar.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
visibility: visible;
}
.snackbar.success {
background: var(--md-sys-color-success);
color: var(--md-sys-color-on-success);
}
.snackbar.error {
background: var(--md-sys-color-error);
color: var(--md-sys-color-on-error);
}
.snackbar.warning {
background: var(--md-sys-color-warning);
color: var(--md-sys-color-on-warning);
}
.snackbar.info {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
.snackbar-content {
display: flex;
align-items: center;
padding: 16px 20px;
gap: 16px;
}
.snackbar-content span {
flex: 1;
font-size: 14px;
font-weight: 500;
}
.snackbar-action {
background: rgba(255, 255, 255, 0.2);
border: none;
color: inherit;
cursor: pointer;
padding: 8px;
border-radius: 50%;
transition: all var(--transition-fast) ease;
}
.snackbar-action:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.status-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: var(--md-sys-shape-corner-medium);
background: var(--md-sys-color-surface-container);
border: 1px solid var(--md-sys-color-outline-variant);
transition: all var(--transition-fast) ease;
}
.status-item:hover {
transform: translateX(4px);
border-color: var(--md-sys-color-primary);
}
.status-icon {
font-size: 20px;
transition: transform var(--transition-fast) ease;
}
.status-item:hover .status-icon {
transform: scale(1.1);
}
.status-icon.success {
color: var(--md-sys-color-success);
}
.status-icon.error {
color: var(--md-sys-color-error);
}
.status-icon.warning {
color: var(--md-sys-color-warning);
}
.status-text {
flex: 1;
font-size: 14px;
color: var(--md-sys-color-on-surface);
}
@media (prefers-contrast: high) {
.card {
border-width: 2px;
}
.btn {
border: 2px solid currentColor;
}
.text-field {
border-width: 3px;
}
}

View File

@@ -0,0 +1,218 @@
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
padding-bottom: 24px;
position: relative;
z-index: 1;
}
.app-bar {
background: var(--md-sys-color-surface);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: var(--md-sys-elevation-level1);
position: sticky;
top: 0;
z-index: 1000;
transition: all var(--transition-medium) ease;
}
.app-bar:hover {
box-shadow: var(--md-sys-elevation-level2);
}
.app-bar-content {
max-width: 1200px;
margin: 0 auto;
padding: 16px 24px;
display: flex;
align-items: center;
gap: 16px;
}
.app-icon {
background: var(--gradient-primary);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 32px;
animation: subtle-rotate 20s linear infinite;
}
.app-title {
font-size: 24px;
font-weight: 600;
color: var(--md-sys-color-on-surface);
flex: 1;
}
.app-version {
font-size: 12px;
font-weight: 500;
color: var(--md-sys-color-primary);
background: var(--md-sys-color-primary-container);
padding: 6px 16px;
border-radius: var(--md-sys-shape-corner-full);
border: 1px solid var(--md-sys-color-primary);
}
.main-content {
flex: 1;
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px;
display: grid;
grid-template-columns: 1fr;
gap: 24px;
}
.app-footer {
background: var(--md-sys-color-surface);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
margin-top: 32px;
border-top: 1px solid var(--md-sys-color-outline-variant);
padding-bottom: 24px;
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px;
}
.copyright {
text-align: center;
padding: 20px 0;
border-top: 1px solid var(--md-sys-color-outline-variant);
margin-top: 32px;
}
.copyright p {
font-size: 13px;
color: var(--md-sys-color-on-surface-variant);
margin: 4px 0;
line-height: 1.5;
}
.copyright a {
color: var(--md-sys-color-primary);
text-decoration: none;
font-weight: 600;
position: relative;
}
.copyright a::after {
content: "";
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background: var(--md-sys-color-primary);
transition: width var(--transition-medium) ease;
}
.copyright a:hover::after {
width: 100%;
}
@media (min-width: 768px) {
.main-content {
grid-template-columns: 1fr 1fr;
}
.progress-card {
grid-column: 1 / -1;
}
}
@media (max-width: 768px) {
.main-content {
padding: 16px;
}
.app-bar-content {
padding: 12px 16px;
}
.app-title {
font-size: 20px;
}
.app-icon {
font-size: 28px;
}
.project-header {
flex-direction: column;
text-align: center;
gap: 16px;
}
.project-version {
align-items: center;
}
.project-links {
grid-template-columns: 1fr;
}
.button-group {
flex-direction: column;
}
.button-group .btn {
width: 100%;
}
.settings-text,
.about-text {
display: none;
}
}
@media (max-width: 480px) {
.project-info-card {
padding: 24px 16px;
}
.tech-info,
.usage-notice {
padding: 16px;
}
.footer-content {
padding: 24px 16px;
}
}
@media print {
.app-bar,
.theme-toggle,
.settings-link,
.about-link,
.btn,
.snackbar {
display: none !important;
}
body {
background: white;
color: black;
}
.card {
box-shadow: none;
border: 1px solid #ccc;
page-break-inside: avoid;
}
:root {
--md-sys-color-background: #ffffff !important;
--md-sys-color-on-background: #000000 !important;
--md-sys-color-surface: #ffffff !important;
--md-sys-color-on-surface: #000000 !important;
}
}

230
web/zh/static/css/oobe.css Normal file
View File

@@ -0,0 +1,230 @@
.oobe-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
var(--md-sys-color-primary-container) 0%,
var(--md-sys-color-secondary-container) 100%
);
padding: 20px;
}
.oobe-card {
max-width: 500px;
width: 100%;
background: var(--md-sys-color-surface);
border-radius: var(--md-sys-shape-corner-extra-large);
box-shadow: var(--md-sys-elevation-level3);
overflow: hidden;
animation: slideInUp 0.6s ease-out;
}
.oobe-header {
background: linear-gradient(
45deg,
var(--md-sys-color-primary),
var(--md-sys-color-tertiary)
);
color: var(--md-sys-color-on-primary);
padding: 40px 32px;
text-align: center;
}
.oobe-logo {
font-size: 64px;
margin-bottom: 16px;
animation: float 3s ease-in-out infinite;
}
.oobe-title {
font-size: 28px;
font-weight: 600;
margin: 0 0 8px 0;
letter-spacing: -0.5px;
}
.oobe-subtitle {
font-size: 16px;
opacity: 0.9;
margin: 0;
}
.oobe-content {
padding: 32px;
}
.oobe-step {
display: none;
animation: fadeIn 0.4s ease-out;
}
.oobe-step.active {
display: block;
}
.step-indicator {
display: flex;
justify-content: center;
margin-bottom: 32px;
}
.step-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--md-sys-color-outline);
margin: 0 6px;
transition: all 0.3s ease;
}
.step-dot.active {
background: var(--md-sys-color-primary);
transform: scale(1.2);
}
.step-dot.completed {
background: var(--md-sys-color-tertiary);
}
.welcome-text {
text-align: center;
margin-bottom: 32px;
text-decoration: none;
}
.welcome-text h3 {
color: var(--md-sys-color-on-surface);
margin: 0 0 16px 0;
font-size: 20px;
font-weight: 500;
text-decoration: none;
}
.welcome-text p {
color: var(--md-sys-color-on-surface-variant);
margin: 0 0 12px 0;
line-height: 1.5;
text-decoration: none;
}
.welcome-text a {
color: var(--md-sys-color-on-surface-variant);
margin: 0 0 12px 0;
line-height: 1.5;
text-decoration: none;
}
.key-input-section {
margin-bottom: 24px;
}
.key-status {
margin-top: 16px;
padding: 16px;
border-radius: var(--md-sys-shape-corner-medium);
background: var(--md-sys-color-surface-variant);
display: none;
}
.key-status.show {
display: block;
animation: slideInDown 0.3s ease-out;
}
.key-status.success {
background: rgba(76, 175, 80, 0.1);
border: 1px solid rgba(76, 175, 80, 0.3);
color: #2e7d32;
}
.key-status.error {
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
color: #c62828;
}
.key-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 16px;
}
.key-info-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.key-info-item .material-icons {
font-size: 18px;
opacity: 0.7;
}
.oobe-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 32px;
}
.btn-large {
padding: 16px 32px;
font-size: 16px;
font-weight: 500;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--md-sys-shape-corner-extra-large);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.loading-overlay.show {
opacity: 1;
visibility: visible;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid var(--md-sys-color-outline);
border-top: 4px solid var(--md-sys-color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@media (max-width: 600px) {
.oobe-container {
padding: 12px;
}
.oobe-header {
padding: 32px 24px;
}
.oobe-content {
padding: 24px;
}
.key-info {
grid-template-columns: 1fr;
}
.oobe-actions {
flex-direction: column;
}
}

View File

@@ -0,0 +1,314 @@
.project-info-card {
background: var(--gradient-primary);
border-radius: var(--md-sys-shape-corner-extra-large);
padding: 32px;
margin-bottom: 24px;
box-shadow: var(--md-sys-elevation-level3);
border: 1px solid rgba(255, 255, 255, 0.2);
position: relative;
overflow: hidden;
}
[data-theme="dark"] .project-info-card {
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(
135deg,
var(--md-sys-color-primary-container) 0%,
var(--md-sys-color-secondary-container) 100%
);
color: var(--md-sys-color-on-primary-container);
}
.project-info-card::before {
content: "";
position: absolute;
top: -50%;
right: -50%;
width: 200%;
height: 200%;
background: radial-gradient(
circle,
rgba(255, 255, 255, 0.1) 0%,
transparent 70%
);
animation: rotate 30s linear infinite;
}
.project-header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 20px;
position: relative;
z-index: 1;
}
.project-logo {
width: 64px;
height: 64px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border-radius: var(--md-sys-shape-corner-large);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
transition: transform var(--transition-medium) ease;
}
.project-logo:hover {
transform: scale(1.1) rotate(5deg);
}
.project-logo .material-icons {
color: white;
font-size: 36px;
}
.project-details {
flex: 1;
}
.project-name {
font-size: 24px;
font-weight: 600;
color: white;
margin: 0 0 4px 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] .project-info-card .project-name {
color: var(--md-sys-color-on-primary-container);
}
.project-subtitle {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
margin: 0;
}
[data-theme="dark"] .project-info-card .project-subtitle {
color: var(--md-sys-color-on-primary-container);
}
.project-version {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.version-label {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
color: white;
padding: 6px 16px;
border-radius: var(--md-sys-shape-corner-full);
font-size: 14px;
font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] .project-info-card .version-label {
color: var(--md-sys-color-on-primary-container);
}
.version-type {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
}
.project-description {
margin-bottom: 24px;
position: relative;
z-index: 1;
}
.project-description p {
font-size: 15px;
color: rgba(255, 255, 255, 0.95);
line-height: 1.6;
margin: 0;
}
.project-links {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 12px;
position: relative;
z-index: 1;
}
.project-link {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
color: white;
text-decoration: none;
border-radius: var(--md-sys-shape-corner-medium);
border: 1px solid rgba(255, 255, 255, 0.2);
transition: all var(--transition-medium) ease;
}
[data-theme="dark"] .project-link {
background: rgba(255, 255, 255, 0.1);
color: var(--md-sys-color-on-primary-container);
}
.project-link:hover {
background: rgba(255, 255, 255, 0.25);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
[data-theme="dark"] .project-link:hover {
background: rgba(255, 255, 255, 0.2);
}
.project-link .material-icons {
font-size: 24px;
transition: transform var(--transition-fast) ease;
}
.project-link:hover .material-icons {
transform: scale(1.1);
}
.link-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.link-title {
font-size: 14px;
font-weight: 600;
}
.link-url {
font-size: 12px;
opacity: 0.8;
}
.tech-info {
background: var(--md-sys-color-surface-container);
backdrop-filter: blur(10px);
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: var(--md-sys-shape-corner-large);
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--md-sys-elevation-level1);
transition: all var(--transition-medium) ease;
}
.tech-info:hover {
transform: translateY(-2px);
box-shadow: var(--md-sys-elevation-level3);
}
.tech-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.tech-header .material-icons {
font-size: 28px;
color: var(--md-sys-color-primary);
}
.tech-header h4 {
font-size: 18px;
font-weight: 600;
color: var(--md-sys-color-on-surface);
margin: 0;
}
.tech-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.tech-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
background: var(--md-sys-color-surface-container-low);
border-radius: var(--md-sys-shape-corner-small);
border: 1px solid var(--md-sys-color-outline-variant);
transition: all var(--transition-fast) ease;
}
.tech-item:hover {
transform: translateX(4px);
border-color: var(--md-sys-color-primary);
}
.tech-item strong {
color: var(--md-sys-color-on-surface);
font-weight: 600;
font-size: 14px;
}
.tech-item span {
color: var(--md-sys-color-on-surface-variant);
font-size: 13px;
line-height: 1.5;
}
.usage-notice {
background: var(--md-sys-color-warning-container);
border: 1px solid var(--md-sys-color-warning);
border-radius: var(--md-sys-shape-corner-large);
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--md-sys-elevation-level1);
}
.notice-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.notice-header .material-icons {
color: var(--md-sys-color-warning);
font-size: 28px;
}
.notice-header h4 {
font-size: 18px;
font-weight: 600;
color: var(--md-sys-color-on-warning-container);
margin: 0;
}
.notice-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.notice-content p {
font-size: 14px;
color: var(--md-sys-color-on-warning-container);
line-height: 1.5;
margin: 0;
padding: 8px 12px;
background: var(--md-sys-color-surface);
border-radius: var(--md-sys-shape-corner-small);
border-left: 3px solid var(--md-sys-color-warning);
}
.notice-content strong {
color: var(--md-sys-color-on-warning-container);
font-weight: 600;
}

View File

@@ -0,0 +1,299 @@
.settings-main {
display: flex;
flex-direction: column;
gap: 24px;
}
.settings-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.setting-item {
display: flex;
flex-direction: column;
gap: 12px;
}
.checkbox-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.checkbox-label {
font-size: 16px;
font-weight: 500;
color: var(--md-sys-color-on-surface);
}
.checkbox-description {
font-size: 13px;
color: var(--md-sys-color-on-surface-variant);
line-height: 1.4;
}
#keyInfoSection {
padding: 20px;
background: var(--md-sys-color-surface-container-low);
border-radius: var(--md-sys-shape-corner-medium);
margin-bottom: 24px;
}
.key-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
margin-top: 16px;
}
.key-info-card {
background: var(--md-sys-color-surface-variant);
border-radius: var(--md-sys-shape-corner-medium);
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
transition: all 0.3s ease;
}
.key-info-card:hover {
background: var(--md-sys-color-primary-container);
transform: translateY(-2px);
}
.key-info-icon {
color: var(--md-sys-color-primary);
font-size: 24px;
}
.key-info-content {
flex: 1;
}
.key-info-label {
font-size: 12px;
color: var(--md-sys-color-on-surface-variant);
text-transform: uppercase;
font-weight: 500;
letter-spacing: 0.5px;
}
.key-info-value {
font-size: 16px;
color: var(--md-sys-color-on-surface);
font-weight: 500;
margin-top: 4px;
}
.key-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: var(--md-sys-shape-corner-small);
font-size: 12px;
font-weight: 500;
}
.key-status-badge.active {
background: rgba(76, 175, 80, 0.1);
color: #2e7d32;
}
.key-status-badge.expired {
background: rgba(244, 67, 54, 0.1);
color: #c62828;
}
.key-status-badge.inactive {
background: rgba(158, 158, 158, 0.1);
color: #616161;
}
.key-change-section {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--md-sys-color-outline);
}
.key-input-group {
display: flex;
gap: 12px;
align-items: end;
}
.key-input-group .text-field {
flex: 1;
font-family: monospace;
letter-spacing: 1px;
}
.path-input-group {
display: flex;
gap: 12px;
align-items: stretch;
}
.path-input-group .text-field {
flex: 1;
}
.status-indicator {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--md-sys-color-surface-container-low);
border-radius: var(--md-sys-shape-corner-medium);
border: 1px solid var(--md-sys-color-outline-variant);
margin-top: 16px;
}
.status-indicator .status-icon {
font-size: 20px;
}
.status-indicator .status-text {
flex: 1;
font-size: 14px;
color: var(--md-sys-color-on-surface);
}
.action-buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.config-status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.config-status-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--md-sys-color-surface-container-low);
border-radius: var(--md-sys-shape-corner-medium);
border: 1px solid var(--md-sys-color-outline-variant);
}
.config-status-item .material-icons {
font-size: 24px;
color: var(--md-sys-color-primary);
}
.config-status-content {
flex: 1;
}
.config-status-label {
font-size: 12px;
color: var(--md-sys-color-on-surface-variant);
margin-bottom: 4px;
}
.config-status-value {
font-size: 14px;
font-weight: 500;
color: var(--md-sys-color-on-surface);
}
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
opacity: 0;
visibility: hidden;
transition: all var(--transition-medium) ease;
}
.dialog-overlay.show {
opacity: 1;
visibility: visible;
}
.dialog {
background: var(--md-sys-color-surface);
border-radius: var(--md-sys-shape-corner-extra-large);
box-shadow: var(--md-sys-elevation-level5);
max-width: 400px;
width: 90%;
transform: scale(0.9);
transition: transform var(--transition-medium) ease;
}
.dialog-overlay.show .dialog {
transform: scale(1);
}
.dialog-header {
padding: 24px 24px 16px;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.dialog-header h3 {
margin: 0;
font-size: 20px;
font-weight: 500;
color: var(--md-sys-color-on-surface);
}
.dialog-content {
padding: 24px;
}
.dialog-content p {
margin: 0;
font-size: 14px;
color: var(--md-sys-color-on-surface-variant);
line-height: 1.5;
}
.dialog-actions {
padding: 16px 24px;
display: flex;
gap: 12px;
justify-content: flex-end;
border-top: 1px solid var(--md-sys-color-outline-variant);
}
@media (max-width: 768px) {
.key-input-group {
flex-direction: column;
align-items: stretch;
}
.path-input-group {
flex-direction: column;
}
.action-buttons {
flex-direction: column;
}
.action-buttons .btn {
width: 100%;
}
.config-status-grid {
grid-template-columns: 1fr;
}
}
.btn.btn-text:first-child {
margin-right: 8px;
}

View File

@@ -0,0 +1,8 @@
@import url("./variables.css");
@import url("./base.css");
@import url("./layout.css");
@import url("./components.css");
@import url("./animations.css");
@import url("./oobe.css");
@import url("./project-info.css");
@import url("./utilities.css");

View File

@@ -0,0 +1,222 @@
.progress-container {
background: var(--md-sys-color-surface-container-low);
border-radius: var(--md-sys-shape-corner-medium);
min-height: 200px;
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--md-sys-color-outline-variant);
}
.progress-container::-webkit-scrollbar {
width: 8px;
}
.progress-container::-webkit-scrollbar-track {
background: var(--md-sys-color-surface-container);
border-radius: 4px;
}
.progress-container::-webkit-scrollbar-thumb {
background: var(--md-sys-color-primary);
border-radius: 4px;
}
.progress-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: var(--md-sys-color-on-surface-variant);
gap: 12px;
}
.progress-placeholder .material-icons {
font-size: 48px;
opacity: 0.3;
animation: pulse 2s ease-in-out infinite;
}
.log-entry {
padding: 12px 16px;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
font-family: "Courier New", monospace;
font-size: 13px;
display: flex;
align-items: flex-start;
gap: 12px;
transition: background-color var(--transition-fast) ease;
animation: slideIn var(--transition-medium) ease-out;
position: relative;
}
.log-entry::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--md-sys-color-primary);
opacity: 0;
transition: opacity var(--transition-fast) ease;
}
.log-entry:hover::before {
opacity: 1;
}
.log-entry:hover {
background-color: var(--md-sys-color-primary-container);
}
[data-theme="dark"] .log-entry {
background: var(--md-sys-color-surface-container);
}
[data-theme="dark"] .log-entry:hover {
background: var(--md-sys-color-surface-container-high);
}
.log-entry:last-child {
border-bottom: none;
}
.log-entry.warning {
background-color: var(--md-sys-color-warning-container);
border-left: 3px solid var(--md-sys-color-warning);
}
.log-entry.error {
background-color: var(--md-sys-color-error-container);
border-left: 3px solid var(--md-sys-color-error);
}
.log-timestamp {
font-family:
"LXGW Wenkai Mono",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
color: var(--md-sys-color-on-surface-variant);
font-size: 11px;
min-width: 60px;
opacity: 0.7;
}
.log-message {
font-family:
"LXGW Wenkai Mono",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
flex: 1;
word-break: break-word;
color: var(--md-sys-color-on-surface);
}
.config-status {
display: flex;
flex-direction: column;
gap: 12px;
}
.settings-link,
.about-link {
color: var(--md-sys-color-on-surface) !important;
text-decoration: none;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: var(--md-sys-shape-corner-full);
background: var(--md-sys-color-surface-container);
border: 1px solid var(--md-sys-color-outline-variant);
transition: all var(--transition-medium) ease;
}
.settings-link:hover,
.about-link:hover {
background: var(--md-sys-color-primary-container);
border-color: var(--md-sys-color-primary);
transform: translateY(-2px);
box-shadow: var(--md-sys-elevation-level2);
}
.settings-link:hover .material-icons,
.about-link:hover .material-icons {
animation: rotate 1s ease-in-out;
}
.settings-text,
.about-text {
font-size: 14px;
font-weight: 600;
}
[data-tooltip] {
position: relative;
}
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(-8px);
background: var(--md-sys-color-inverse-surface);
color: var(--md-sys-color-inverse-on-surface);
padding: 8px 12px;
border-radius: var(--md-sys-shape-corner-small);
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all var(--transition-fast) ease;
pointer-events: none;
z-index: 1000;
}
[data-tooltip]:hover::after {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
.unlock-form {
display: flex;
flex-direction: column;
gap: 24px;
}
@media (prefers-contrast: high) {
.card {
border-width: 2px;
}
.btn {
border: 2px solid currentColor;
}
.text-field {
border-width: 3px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@media (max-width: 768px) and (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .card {
box-shadow: var(--md-sys-elevation-level2);
}
}

View File

@@ -0,0 +1,206 @@
:root {
--transition-fast: 200ms;
--transition-medium: 300ms;
--transition-slow: 400ms;
--md-sys-color-primary: #6750a4;
--md-sys-color-on-primary: #ffffff;
--md-sys-color-primary-container: #e9ddff;
--md-sys-color-on-primary-container: #22005d;
--md-sys-color-secondary: #00bcd4;
--md-sys-color-on-secondary: #ffffff;
--md-sys-color-secondary-container: #b2ebf2;
--md-sys-color-on-secondary-container: #00363d;
--md-sys-color-tertiary: #ff6f00;
--md-sys-color-on-tertiary: #ffffff;
--md-sys-color-tertiary-container: #ffe0b2;
--md-sys-color-on-tertiary-container: #4a1c00;
--md-sys-color-error: #dc2626;
--md-sys-color-on-error: #ffffff;
--md-sys-color-error-container: #fee2e2;
--md-sys-color-on-error-container: #7f1d1d;
--md-sys-color-background: #fdfcff;
--md-sys-color-on-background: #1a1c1e;
--md-sys-color-surface: #fdfcff;
--md-sys-color-on-surface: #1a1c1e;
--md-sys-color-surface-variant: #e7e0ec;
--md-sys-color-on-surface-variant: #49454e;
--md-sys-color-surface-container-lowest: #ffffff;
--md-sys-color-surface-container-low: #f7f2fa;
--md-sys-color-surface-container: #f1ecf4;
--md-sys-color-surface-container-high: #ebe6ee;
--md-sys-color-surface-container-highest: #e6e0e9;
--md-sys-color-outline: #79747e;
--md-sys-color-outline-variant: #cac4cf;
--md-sys-color-success: #16a34a;
--md-sys-color-on-success: #ffffff;
--md-sys-color-success-container: #dcfce7;
--md-sys-color-warning: #f59e0b;
--md-sys-color-on-warning: #ffffff;
--md-sys-color-warning-container: #fef3c7;
--md-sys-color-surface-tint: #6750a4;
--md-sys-color-scrim: #000000;
--md-sys-color-inverse-surface: #313033;
--md-sys-color-inverse-on-surface: #f4eff4;
--md-sys-color-inverse-primary: #d0bcff;
--md-sys-elevation-level0: none;
--md-sys-elevation-level1:
0px 1px 3px 1px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);
--md-sys-elevation-level2:
0px 2px 6px 2px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);
--md-sys-elevation-level3:
0px 4px 8px 3px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.3);
--md-sys-elevation-level4:
0px 6px 10px 4px rgba(0, 0, 0, 0.15), 0px 2px 3px 0px rgba(0, 0, 0, 0.3);
--md-sys-elevation-level5:
0px 8px 12px 6px rgba(0, 0, 0, 0.15), 0px 4px 4px 0px rgba(0, 0, 0, 0.3);
--md-sys-shape-corner-none: 0px;
--md-sys-shape-corner-extra-small: 4px;
--md-sys-shape-corner-small: 8px;
--md-sys-shape-corner-medium: 12px;
--md-sys-shape-corner-large: 16px;
--md-sys-shape-corner-extra-large: 28px;
--md-sys-shape-corner-full: 999px;
--gradient-primary: linear-gradient(135deg, #6750a4 0%, #8b7cc4 100%);
--gradient-secondary: linear-gradient(135deg, #00bcd4 0%, #4dd0e1 100%);
--gradient-surface: linear-gradient(135deg, #fdfcff 0%, #f7f2fa 100%);
}
[data-theme="dark"] {
--md-sys-color-primary: #d0bcff;
--md-sys-color-on-primary: #381e72;
--md-sys-color-primary-container: #4f378a;
--md-sys-color-on-primary-container: #e9ddff;
--md-sys-color-secondary: #4dd0e1;
--md-sys-color-on-secondary: #00363d;
--md-sys-color-secondary-container: #005662;
--md-sys-color-on-secondary-container: #b2ebf2;
--md-sys-color-tertiary: #ffb74d;
--md-sys-color-on-tertiary: #4a1c00;
--md-sys-color-tertiary-container: #6a2c00;
--md-sys-color-on-tertiary-container: #ffe0b2;
--md-sys-color-error: #f87171;
--md-sys-color-on-error: #7f1d1d;
--md-sys-color-error-container: #991b1b;
--md-sys-color-on-error-container: #fee2e2;
--md-sys-color-background: #1a1c1e;
--md-sys-color-on-background: #e3e2e6;
--md-sys-color-surface: #1a1c1e;
--md-sys-color-on-surface: #e3e2e6;
--md-sys-color-surface-variant: #49454e;
--md-sys-color-on-surface-variant: #cac4cf;
--md-sys-color-surface-container-lowest: #0e0f11;
--md-sys-color-surface-container-low: #1a1c1e;
--md-sys-color-surface-container: #1e2022;
--md-sys-color-surface-container-high: #282a2d;
--md-sys-color-surface-container-highest: #333538;
--md-sys-color-outline: #938f99;
--md-sys-color-outline-variant: #49454e;
--md-sys-color-success: #4ade80;
--md-sys-color-on-success: #14532d;
--md-sys-color-success-container: #166534;
--md-sys-color-warning: #fbbf24;
--md-sys-color-on-warning: #451a03;
--md-sys-color-warning-container: #78350f;
--md-sys-color-surface-tint: #d0bcff;
--md-sys-color-scrim: #000000;
--md-sys-color-inverse-surface: #e6e0e9;
--md-sys-color-inverse-on-surface: #313033;
--md-sys-color-inverse-primary: #6750a4;
--md-sys-elevation-level0: none;
--md-sys-elevation-level1:
0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level2:
0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 2px 6px 2px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level3:
0px 1px 3px 0px rgba(0, 0, 0, 0.3), 0px 4px 8px 3px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level4:
0px 2px 3px 0px rgba(0, 0, 0, 0.3), 0px 6px 10px 4px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level5:
0px 4px 4px 0px rgba(0, 0, 0, 0.3), 0px 8px 12px 6px rgba(0, 0, 0, 0.15);
--gradient-primary: linear-gradient(135deg, #4f378a 0%, #6750a4 100%);
--gradient-secondary: linear-gradient(135deg, #005662 0%, #00838f 100%);
--gradient-surface: linear-gradient(135deg, #1a1c1e 0%, #1e2022 100%);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--md-sys-color-primary: #d0bcff;
--md-sys-color-on-primary: #381e72;
--md-sys-color-primary-container: #4f378a;
--md-sys-color-on-primary-container: #e9ddff;
--md-sys-color-secondary: #4dd0e1;
--md-sys-color-on-secondary: #00363d;
--md-sys-color-secondary-container: #005662;
--md-sys-color-on-secondary-container: #b2ebf2;
--md-sys-color-tertiary: #ffb74d;
--md-sys-color-on-tertiary: #4a1c00;
--md-sys-color-tertiary-container: #6a2c00;
--md-sys-color-on-tertiary-container: #ffe0b2;
--md-sys-color-error: #f87171;
--md-sys-color-on-error: #7f1d1d;
--md-sys-color-error-container: #991b1b;
--md-sys-color-on-error-container: #fee2e2;
--md-sys-color-background: #1a1c1e;
--md-sys-color-on-background: #e3e2e6;
--md-sys-color-surface: #1a1c1e;
--md-sys-color-on-surface: #e3e2e6;
--md-sys-color-surface-variant: #49454e;
--md-sys-color-on-surface-variant: #cac4cf;
--md-sys-color-surface-container-lowest: #0e0f11;
--md-sys-color-surface-container-low: #1a1c1e;
--md-sys-color-surface-container: #1e2022;
--md-sys-color-surface-container-high: #282a2d;
--md-sys-color-surface-container-highest: #333538;
--md-sys-color-outline: #938f99;
--md-sys-color-outline-variant: #49454e;
--md-sys-color-success: #4ade80;
--md-sys-color-on-success: #14532d;
--md-sys-color-success-container: #166534;
--md-sys-color-warning: #fbbf24;
--md-sys-color-on-warning: #451a03;
--md-sys-color-warning-container: #78350f;
--md-sys-color-surface-tint: #d0bcff;
--md-sys-color-scrim: #000000;
--md-sys-color-inverse-surface: #e6e0e9;
--md-sys-color-inverse-on-surface: #313033;
--md-sys-color-inverse-primary: #6750a4;
--md-sys-elevation-level0: none;
--md-sys-elevation-level1:
0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level2:
0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 2px 6px 2px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level3:
0px 1px 3px 0px rgba(0, 0, 0, 0.3), 0px 4px 8px 3px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level4:
0px 2px 3px 0px rgba(0, 0, 0, 0.3), 0px 6px 10px 4px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level5:
0px 4px 4px 0px rgba(0, 0, 0, 0.3), 0px 8px 12px 6px rgba(0, 0, 0, 0.15);
--gradient-primary: linear-gradient(135deg, #4f378a 0%, #6750a4 100%);
--gradient-secondary: linear-gradient(135deg, #005662 0%, #00838f 100%);
--gradient-surface: linear-gradient(135deg, #1a1c1e 0%, #1e2022 100%);
}
}

682
web/zh/static/js/app.js Normal file
View File

@@ -0,0 +1,682 @@
class OnekeyWebApp {
constructor() {
this.socket = null;
this.taskStatus = "idle";
this.reconnectTimer = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 2000;
this.initializeSocket();
this.initializeEventListeners();
this.checkConfig();
}
initializeSocket() {
this.connectWebSocket();
}
connectWebSocket() {
try {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/ws`;
this.socket = new WebSocket(wsUrl);
this.socket.onopen = () => {
console.log("Connected to server");
this.showSnackbar("已连接到服务器", "success");
this.reconnectAttempts = 0;
this.startHeartbeat();
};
this.socket.onclose = (event) => {
console.log("Disconnected from server", event);
this.showSnackbar("与服务器连接断开", "error");
this.stopHeartbeat();
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectTimer = setTimeout(() => {
this.reconnectAttempts++;
console.log(
`尝试重连... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
);
this.connectWebSocket();
}, this.reconnectDelay);
}
};
this.socket.onerror = (error) => {
console.error("WebSocket error:", error);
};
this.socket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.handleMessage(message);
} catch (e) {
console.error("Failed to parse message:", e);
}
};
} catch (error) {
console.error("Failed to connect WebSocket:", error);
this.showSnackbar("无法连接到服务器", "error");
}
}
handleMessage(message) {
switch (message.type) {
case "connected":
console.log(message.data.message);
break;
case "task_progress":
this.addLogEntry(message.data.type, message.data.message);
break;
case "pong":
break;
default:
console.log("Unknown message type:", message.type);
}
}
startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type: "ping" }));
}
}, 30000);
}
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.stopHeartbeat();
if (this.socket) {
this.socket.close();
this.socket = null;
}
}
initializeEventListeners() {
const unlockForm = document.getElementById("unlockForm");
unlockForm.addEventListener("submit", (e) => {
e.preventDefault();
this.startUnlockTask();
});
const resetBtn = document.getElementById("resetBtn");
resetBtn.addEventListener("click", () => {
this.resetForm();
});
const clearLogBtn = document.getElementById("clearLogBtn");
clearLogBtn.addEventListener("click", () => {
this.clearLogs();
});
const snackbarClose = document.getElementById("snackbarClose");
snackbarClose.addEventListener("click", () => {
this.hideSnackbar();
});
window.addEventListener("beforeunload", () => {
this.disconnect();
});
}
async checkConfig() {
const configStatus = document.getElementById("configStatus");
try {
const response = await fetch("/api/config");
const data = await response.json();
if (data.success) {
configStatus.innerHTML = this.generateConfigStatusHTML(data.config);
} else {
configStatus.innerHTML = `
<div class="status-item">
<span class="material-icons status-icon error">error</span>
<span class="status-text">配置加载失败: ${data.message}</span>
</div>
`;
}
} catch (error) {
configStatus.innerHTML = `
<div class="status-item">
<span class="material-icons status-icon error">error</span>
<span class="status-text">无法连接到服务器</span>
</div>
`;
}
}
generateConfigStatusHTML(config) {
const items = [];
if (config.steam_path) {
items.push(`
<div class="status-item">
<span class="material-icons status-icon success">check_circle</span>
<span class="status-text">Steam路径: ${config.steam_path}</span>
</div>
`);
} else {
items.push(`
<div class="status-item">
<span class="material-icons status-icon error">error</span>
<span class="status-text">Steam路径未找到</span>
</div>
`);
}
if (config.debug_mode) {
items.push(`
<div class="status-item">
<span class="material-icons status-icon warning">bug_report</span>
<span class="status-text">调试模式已启用</span>
</div>
`);
}
return items.join("");
}
toggleAndDLC() {
document.getElementById("+DLC").checked = true;
}
async startUnlockTask() {
if (this.taskStatus === "running") {
this.showSnackbar("已有任务正在运行", "warning");
return;
}
const formData = new FormData(document.getElementById("unlockForm"));
const appId = formData.get("appId").trim();
const toolType = formData.get("toolType");
const ADLC = formData.get("+DLC") === "on";
if (!appId) {
this.showSnackbar("请输入App ID", "error");
return;
}
const appIdPattern = /^[\d-]+$/;
if (!appIdPattern.test(appId)) {
this.showSnackbar("App ID格式无效应为数字或用-分隔的数字", "error");
return;
}
this.taskStatus = "running";
this.updateUIForRunningTask();
this.clearLogs();
this.addLogEntry("info", `开始处理游戏 ${appId}...`);
try {
const response = await fetch("/api/start_unlock", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
app_id: appId,
tool_type: toolType,
dlc: ADLC,
}),
});
const data = await response.json();
if (data.success) {
this.showSnackbar("任务已开始", "success");
this.startStatusPolling();
} else {
this.taskStatus = "idle";
this.updateUIForIdleTask();
this.showSnackbar(data.message, "error");
this.addLogEntry("error", data.message);
}
} catch (error) {
this.taskStatus = "idle";
this.updateUIForIdleTask();
this.showSnackbar("启动任务失败", "error");
this.addLogEntry("error", `启动任务失败: ${error.message}`);
}
}
startStatusPolling() {
const pollInterval = setInterval(async () => {
try {
const response = await fetch("/api/task_status");
const data = await response.json();
if (data.status === "completed") {
clearInterval(pollInterval);
this.taskStatus = "completed";
this.updateUIForIdleTask();
if (data.result && data.result.success) {
this.showSnackbar(data.result.message, "success");
this.addLogEntry("info", data.result.message);
} else if (data.result) {
this.showSnackbar(data.result.message, "error");
this.addLogEntry("error", data.result.message);
}
} else if (data.status === "error") {
clearInterval(pollInterval);
this.taskStatus = "error";
this.updateUIForIdleTask();
if (data.result) {
this.showSnackbar(data.result.message, "error");
this.addLogEntry("error", data.result.message);
}
}
} catch (error) {
console.error("Status polling error:", error);
}
}, 1000);
}
updateUIForRunningTask() {
const unlockBtn = document.getElementById("unlockBtn");
const resetBtn = document.getElementById("resetBtn");
const appIdInput = document.getElementById("appId");
const toolTypeRadios = document.querySelectorAll('input[name="toolType"]');
unlockBtn.disabled = true;
unlockBtn.innerHTML = `
<span class="material-icons">hourglass_empty</span>
执行中...
`;
resetBtn.disabled = true;
appIdInput.disabled = true;
toolTypeRadios.forEach((radio) => (radio.disabled = true));
}
updateUIForIdleTask() {
const unlockBtn = document.getElementById("unlockBtn");
const resetBtn = document.getElementById("resetBtn");
const appIdInput = document.getElementById("appId");
const toolTypeRadios = document.querySelectorAll('input[name="toolType"]');
unlockBtn.disabled = false;
unlockBtn.innerHTML = `
<span class="material-icons">play_arrow</span>
开始解锁
`;
resetBtn.disabled = false;
appIdInput.disabled = false;
toolTypeRadios.forEach((radio) => (radio.disabled = false));
}
resetForm() {
if (this.taskStatus === "running") {
this.showSnackbar("任务运行中,无法重置", "warning");
return;
}
document.getElementById("unlockForm").reset();
document.querySelector(
'input[name="toolType"][value="steamtools"]',
).checked = true;
this.clearLogs();
this.showSnackbar("表单已重置", "success");
}
addLogEntry(type, message) {
const progressContainer = document.getElementById("progressContainer");
const placeholder = progressContainer.querySelector(
".progress-placeholder",
);
if (placeholder) {
placeholder.remove();
}
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement("div");
logEntry.className = `log-entry ${type}`;
logEntry.innerHTML = `
<span class="log-timestamp">${timestamp}</span>
<span class="log-message">${this.escapeHtml(message)}</span>
`;
progressContainer.appendChild(logEntry);
progressContainer.scrollTop = progressContainer.scrollHeight;
}
clearLogs() {
const progressContainer = document.getElementById("progressContainer");
progressContainer.innerHTML = `
<div class="progress-placeholder">
<span class="material-icons">info</span>
<p>等待任务开始...</p>
</div>
`;
}
showSnackbar(message, type = "info") {
const snackbar = document.getElementById("snackbar");
const snackbarMessage = document.getElementById("snackbarMessage");
snackbarMessage.textContent = message;
snackbar.className = `snackbar ${type}`;
snackbar.offsetHeight;
snackbar.classList.add("show");
setTimeout(() => {
this.hideSnackbar();
}, 4000);
}
hideSnackbar() {
const snackbar = document.getElementById("snackbar");
snackbar.classList.remove("show");
}
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
}
const style = document.createElement("style");
style.textContent = `
@keyframes iconRotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute("href"));
if (target) {
target.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
});
});
document.addEventListener("DOMContentLoaded", () => {
const cards = document.querySelectorAll(".card");
cards.forEach((card) => {
card.addEventListener("mousemove", (e) => {
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const percentX = (x - centerX) / centerX;
const percentY = (y - centerY) / centerY;
const rotateX = percentY * 5;
const rotateY = percentX * 5;
card.style.transform = `perspective(1000px) rotateX(${-rotateX}deg) rotateY(${rotateY}deg) translateZ(10px)`;
});
card.addEventListener("mouseleave", () => {
card.style.transform = "";
});
});
});
function typeWriter(element, text, speed = 50) {
let i = 0;
element.textContent = "";
function type() {
if (i < text.length) {
element.textContent += text.charAt(i);
i++;
setTimeout(type, speed);
}
}
type();
}
function animateValue(element, start, end, duration) {
const range = end - start;
const increment = range / (duration / 16);
let current = start;
const timer = setInterval(() => {
current += increment;
if (
(increment > 0 && current >= end) ||
(increment < 0 && current <= end)
) {
current = end;
clearInterval(timer);
}
element.textContent = Math.round(current);
}, 16);
}
document.querySelectorAll(".btn").forEach((button) => {
button.addEventListener("mousemove", (e) => {
const rect = button.getBoundingClientRect();
const x = e.clientX - rect.left - rect.width / 2;
const y = e.clientY - rect.top - rect.height / 2;
button.style.transform = `translate(${x * 0.1}px, ${y * 0.1}px)`;
});
button.addEventListener("mouseleave", () => {
button.style.transform = "";
});
});
function createParticles() {
const particlesContainer = document.createElement("div");
particlesContainer.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
`;
document.body.appendChild(particlesContainer);
for (let i = 0; i < 50; i++) {
const particle = document.createElement("div");
particle.style.cssText = `
position: absolute;
width: 4px;
height: 4px;
background: rgba(94, 53, 177, 0.3);
border-radius: 50%;
top: ${Math.random() * 100}%;
left: ${Math.random() * 100}%;
animation: floatParticle ${
10 + Math.random() * 20
}s linear infinite;
`;
particlesContainer.appendChild(particle);
}
const style = document.createElement("style");
style.textContent = `
@keyframes floatParticle {
0% {
transform: translateY(100vh) rotate(0deg);
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
transform: translateY(-100vh) rotate(720deg);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
const observerOptions = {
threshold: 0.1,
rootMargin: "0px 0px -50px 0px",
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.style.opacity = "1";
entry.target.style.transform = "translateY(0)";
observer.unobserve(entry.target);
}
});
}, observerOptions);
document.querySelectorAll(".card").forEach((card) => {
card.style.opacity = "0";
card.style.transform = "translateY(20px)";
card.style.transition = "opacity 0.6s ease, transform 0.6s ease";
observer.observe(card);
});
document.addEventListener("mousemove", (e) => {
const light = document.createElement("div");
light.style.cssText = `
position: fixed;
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(94, 53, 177, 0.1) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
z-index: 9999;
transform: translate(-50%, -50%);
transition: opacity 0.3s ease;
`;
light.style.left = e.clientX + "px";
light.style.top = e.clientY + "px";
document.body.appendChild(light);
setTimeout(() => {
light.style.opacity = "0";
setTimeout(() => light.remove(), 300);
}, 100);
});
document.querySelectorAll(".status-icon").forEach((icon) => {
if (icon.classList.contains("success")) {
icon.style.animation = "pulse-icon 2s ease-in-out infinite";
}
});
const pulseStyle = document.createElement("style");
pulseStyle.textContent = `
@keyframes pulse-icon {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
`;
document.head.appendChild(pulseStyle);
const originalShowSnackbar = window.showSnackbar;
if (typeof originalShowSnackbar === "function") {
window.showSnackbar = function (message, type = "info") {
originalShowSnackbar(message, type);
if ("vibrate" in navigator) {
if (type === "error") {
navigator.vibrate([100, 50, 100]);
} else {
navigator.vibrate(50);
}
}
const audio = new Audio(
`data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBSuBzvLZiTYIG2m98OScTgwOUarm7blmFgU7k9n1unEiBC13yO/eizEIHWq+8+OWT` +
`BEFS6Xj67xqGAU+lNr1unIiBCx0xvDdiTYIHWu+8+OWT`,
);
if (type === "success") {
audio.volume = 0.1;
audio.play().catch(() => {});
}
};
}
document.querySelectorAll(".text-field").forEach((input) => {
input.addEventListener("focus", (e) => {
const ripple = document.createElement("div");
ripple.style.cssText = `
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
border: 2px solid var(--md-sys-color-primary);
border-radius: var(--md-sys-shape-corner-medium);
opacity: 0;
pointer-events: none;
animation: inputRipple 0.6s ease-out;
`;
const wrapper = input.parentElement;
wrapper.style.position = "relative";
wrapper.appendChild(ripple);
setTimeout(() => ripple.remove(), 600);
});
});
const inputRippleStyle = document.createElement("style");
inputRippleStyle.textContent = `
@keyframes inputRipple {
0% {
transform: scale(0.8);
opacity: 1;
}
100% {
transform: scale(1.2);
opacity: 0;
}
}
`;
document.head.appendChild(inputRippleStyle);
document.addEventListener("DOMContentLoaded", () => {
createParticles();
document.body.classList.add("loaded");
console.log("UI 增强效果已加载 ✨");
new OnekeyWebApp();
});

View File

@@ -0,0 +1,180 @@
class ProjectInfoEnhancer {
constructor() {
this.initializeProjectInfo();
}
initializeProjectInfo() {
this.addProjectLinkTracking();
this.addVersionClickEaster();
this.addLogoClickEffect();
}
addProjectLinkTracking() {
const projectLinks = document.querySelectorAll(".project-link");
projectLinks.forEach((link) => {
link.addEventListener("click", (e) => {
const linkType = link.classList.contains("github")
? "GitHub仓库"
: link.classList.contains("releases")
? "下载发布版"
: link.classList.contains("docs")
? "使用文档"
: link.classList.contains("issues")
? "问题反馈"
: "未知链接";
console.log(`用户点击了 ${linkType} 链接`);
link.style.transform = "scale(0.95)";
setTimeout(() => {
link.style.transform = "";
}, 150);
});
});
}
addVersionClickEaster() {
const versionLabels = document.querySelectorAll(".version-label");
let clickCount = 0;
versionLabels.forEach((label) => {
label.addEventListener("click", () => {
clickCount++;
if (clickCount === 5) {
this.showEasterEgg();
clickCount = 0;
}
label.style.animation = "pulse 0.3s ease";
setTimeout(() => {
label.style.animation = "";
}, 300);
});
});
}
addLogoClickEffect() {
const logos = document.querySelectorAll(".project-logo");
logos.forEach((logo) => {
logo.addEventListener("click", () => {
logo.style.transform = "rotate(360deg)";
logo.style.transition = "transform 0.6s ease";
setTimeout(() => {
logo.style.transform = "";
logo.style.transition = "";
}, 600);
this.showTooltip(logo, "🎮 Onekey - 让Steam解锁变得简单");
});
});
}
showEasterEgg() {
const messages = [
"🎉 你发现了隐藏彩蛋!",
"🚀 感谢你使用Onekey工具",
"⭐ 别忘了给项目点个Star哦",
"🎮 祝你游戏愉快!",
"🔓 一键解锁,畅享游戏!",
];
const randomMessage = messages[Math.floor(Math.random() * messages.length)];
const easterEgg = document.createElement("div");
easterEgg.className = "easter-egg";
easterEgg.textContent = randomMessage;
easterEgg.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: linear-gradient(45deg, #6750a4, #7d5260);
color: white;
padding: 20px 30px;
border-radius: 15px;
font-size: 18px;
font-weight: 500;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
z-index: 9999;
animation: easterEggBounce 0.6s ease-out;
`;
if (!document.getElementById("easter-egg-styles")) {
const style = document.createElement("style");
style.id = "easter-egg-styles";
style.textContent = `
@keyframes easterEggBounce {
0% { transform: translate(-50%, -50%) scale(0); opacity: 0; }
50% { transform: translate(-50%, -50%) scale(1.1); opacity: 1; }
100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
`;
document.head.appendChild(style);
}
document.body.appendChild(easterEgg);
setTimeout(() => {
easterEgg.style.animation = "easterEggBounce 0.3s ease-in reverse";
setTimeout(() => {
if (easterEgg.parentNode) {
easterEgg.parentNode.removeChild(easterEgg);
}
}, 300);
}, 3000);
}
showTooltip(element, message) {
const tooltip = document.createElement("div");
tooltip.className = "custom-tooltip";
tooltip.textContent = message;
tooltip.style.cssText = `
position: absolute;
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
`;
const rect = element.getBoundingClientRect();
tooltip.style.left = rect.left + rect.width / 2 + "px";
tooltip.style.top = rect.bottom + 10 + "px";
tooltip.style.transform = "translateX(-50%)";
document.body.appendChild(tooltip);
setTimeout(() => {
tooltip.style.opacity = "1";
}, 10);
setTimeout(() => {
tooltip.style.opacity = "0";
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
}, 300);
}, 2000);
}
}
document.addEventListener("DOMContentLoaded", () => {
new ProjectInfoEnhancer();
});

View File

@@ -0,0 +1,641 @@
class SettingsManager {
constructor() {
this.currentConfig = {};
this.currentKeyInfo = null;
this.newKeyData = null;
this.initializeEventListeners();
this.loadConfig();
this.loadKeyInfo();
}
initializeEventListeners() {
document.getElementById("saveConfig").addEventListener("click", () => {
this.saveConfig();
});
document.getElementById("resetConfig").addEventListener("click", () => {
this.showConfirmDialog(
"重置配置",
"确定要重置所有配置为默认值吗?此操作不可恢复。",
() => this.resetConfig(),
);
});
document.getElementById("testConfig").addEventListener("click", () => {
this.testConfig();
});
document.getElementById("detectSteamPath").addEventListener("click", () => {
this.detectSteamPath();
});
document.getElementById("steamPath").addEventListener("input", () => {
this.validateSteamPath();
});
document.getElementById("verifyNewKey").addEventListener("click", () => {
this.verifyNewKey();
});
document.getElementById("changeKey").addEventListener("click", () => {
this.showConfirmDialog(
"更换卡密",
"确定要更换为新的卡密吗?更换后需要重新验证。",
() => this.changeKey(),
);
});
document.getElementById("newKey").addEventListener("input", () => {
this.resetNewKeyStatus();
});
document.getElementById("newKey").addEventListener("keypress", (e) => {
if (e.key === "Enter") {
this.verifyNewKey();
}
});
document.getElementById("dialogCancel").addEventListener("click", () => {
this.hideConfirmDialog();
});
document.getElementById("dialogConfirm").addEventListener("click", () => {
this.executeConfirmAction();
});
document.getElementById("snackbarClose").addEventListener("click", () => {
this.hideSnackbar();
});
}
async loadConfig() {
try {
const response = await fetch("/api/config/detailed");
const data = await response.json();
if (data.success) {
this.currentConfig = data.config;
this.populateForm();
this.updateConfigStatus();
} else {
this.showSnackbar("加载配置失败: " + data.message, "error");
}
} catch (error) {
this.showSnackbar("无法连接到服务器", "error");
console.error("Load config error:", error);
}
}
async loadKeyInfo() {
const keyInfoSection = document.getElementById("keyInfoSection");
try {
const configResponse = await fetch("/api/config/detailed");
const configData = await configResponse.json();
if (!configData.success || !configData.config.key) {
keyInfoSection.innerHTML = `
<div class="expiry-warning">
<span class="material-icons">warning</span>
<div>
<strong>未设置卡密</strong><br>
请在下方输入您的授权卡密
</div>
</div>
`;
return;
}
const keyResponse = await fetch("/api/getKeyInfo", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ key: configData.config.key }),
});
const keyData = await keyResponse.json();
if (keyData.key && keyData.info) {
this.currentKeyInfo = keyData.info;
this.displayKeyInfo(keyData.info);
} else {
keyInfoSection.innerHTML = `
<div class="expiry-warning">
<span class="material-icons">error</span>
<div>
<strong>卡密验证失败</strong><br>
当前卡密无效或已过期,请更换新的卡密
</div>
</div>
`;
}
} catch (error) {
keyInfoSection.innerHTML = `
<div class="expiry-warning">
<span class="material-icons">error</span>
<div>
<strong>获取卡密信息失败</strong><br>
请检查网络连接或联系客服
</div>
</div>
`;
console.error("Load key info error:", error);
}
}
displayKeyInfo(keyInfo) {
const keyInfoSection = document.getElementById("keyInfoSection");
const expiresAt = new Date(keyInfo.expiresAt);
const createdAt = new Date(keyInfo.createdAt);
const firstUsedAt = keyInfo.firstUsedAt
? new Date(keyInfo.firstUsedAt)
: null;
const now = new Date();
const isExpired = expiresAt < now;
const daysLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24));
const isExpiringSoon = daysLeft <= 7 && daysLeft > 0;
const typeNames = {
day: "日卡",
week: "周卡",
month: "月卡",
year: "年卡",
permanent: "永久卡",
};
let statusBadge = "";
if (isExpired && keyInfo.type != "permanent") {
statusBadge =
'<span class="key-status-badge expired"><span class="material-icons" style="font-size: 14px;">cancel</span>已过期</span>';
} else if (!keyInfo.isActive) {
statusBadge =
'<span class="key-status-badge inactive"><span class="material-icons" style="font-size: 14px;">pause</span>未激活</span>';
} else {
statusBadge =
'<span class="key-status-badge active"><span class="material-icons" style="font-size: 14px;">check_circle</span>正常</span>';
}
let warningSection = "";
if (isExpiringSoon) {
warningSection = `
<div class="expiry-warning">
<span class="material-icons">schedule</span>
<div>
<strong>即将到期提醒</strong><br>
您的卡密将在 ${daysLeft} 天后到期,请及时续费
</div>
</div>
`;
}
keyInfoSection.innerHTML = `
<div class="key-info-grid">
<div class="key-info-card">
<span class="material-icons key-info-icon">fingerprint</span>
<div class="key-info-content">
<div class="key-info-label">卡密</div>
<div class="key-info-value">${keyInfo.key.substring(
0,
8,
)}...${keyInfo.key.substring(
keyInfo.key.length - 8,
)}</div>
</div>
</div>
<div class="key-info-card">
<span class="material-icons key-info-icon">label</span>
<div class="key-info-content">
<div class="key-info-label">类型</div>
<div class="key-info-value">${
typeNames[keyInfo.type] || keyInfo.type
}</div>
</div>
</div>
<div class="key-info-card">
<span class="material-icons key-info-icon">toggle_on</span>
<div class="key-info-content">
<div class="key-info-label">状态</div>
<div class="key-info-value">${statusBadge}</div>
</div>
</div>
<div class="key-info-card">
<span class="material-icons key-info-icon">event</span>
<div class="key-info-content">
<div class="key-info-label">到期时间</div>
<div class="key-info-value">${expiresAt.toLocaleDateString()} ${expiresAt
.toLocaleTimeString()
.substring(0, 5)}</div>
</div>
</div>
<div class="key-info-card">
<span class="material-icons key-info-icon">analytics</span>
<div class="key-info-content">
<div class="key-info-label">使用次数</div>
<div class="key-info-value">${keyInfo.usageCount} / ${
keyInfo.totalUsage || "∞"
}</div>
</div>
</div>
<div class="key-info-card">
<span class="material-icons key-info-icon">schedule</span>
<div class="key-info-content">
<div class="key-info-label">创建时间</div>
<div class="key-info-value">${createdAt.toLocaleDateString()}</div>
</div>
</div>
</div>
${warningSection}
`;
}
async verifyNewKey() {
const newKeyInput = document.getElementById("newKey");
const key = newKeyInput.value.trim();
if (!key) {
this.showSnackbar("请输入新卡密", "error");
return;
}
if (!key.match(/^[A-Z0-9_-]+$/)) {
this.showSnackbar("卡密格式不正确", "error");
return;
}
const verifyBtn = document.getElementById("verifyNewKey");
const changeBtn = document.getElementById("changeKey");
verifyBtn.disabled = true;
verifyBtn.innerHTML =
'<span class="material-icons">hourglass_empty</span>验证中...';
try {
const response = await fetch("/api/getKeyInfo", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ key: key }),
});
const data = await response.json();
if (data.key && data.info) {
this.newKeyData = data.info;
this.showSnackbar("新卡密验证成功!", "success");
changeBtn.style.display = "flex";
verifyBtn.style.display = "none";
const typeNames = {
day: "日卡",
week: "周卡",
month: "月卡",
year: "年卡",
permanent: "永久卡",
};
const expiresAt = new Date(data.info.expiresAt);
this.showSnackbar(
`验证成功!新卡密类型:${
typeNames[data.info.type]
},有效期至:${expiresAt.toLocaleDateString()}`,
"success",
);
} else {
this.showSnackbar("新卡密无效或已过期", "error");
this.newKeyData = null;
}
} catch (error) {
this.showSnackbar("验证失败,请检查网络连接", "error");
console.error("New key verification error:", error);
} finally {
verifyBtn.disabled = false;
verifyBtn.innerHTML = '<span class="material-icons">check</span>验证';
}
}
async changeKey() {
if (!this.newKeyData) {
this.showSnackbar("请先验证新卡密", "error");
return;
}
try {
const newKey = document.getElementById("newKey").value.trim();
const updateData = {
key: newKey,
steam_path: this.currentConfig.steam_path || "",
debug_mode: this.currentConfig.debug_mode || false,
logging_files: this.currentConfig.logging_files !== false,
show_console: this.currentConfig.show_console !== false,
};
const response = await fetch("/api/config/update", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(updateData),
});
const data = await response.json();
if (data.success) {
this.showSnackbar("卡密更换成功!", "success");
await this.loadKeyInfo();
this.resetNewKeyStatus();
document.getElementById("newKey").value = "";
} else {
this.showSnackbar("更换失败: " + data.message, "error");
}
} catch (error) {
this.showSnackbar("更换卡密时发生错误", "error");
console.error("Change key error:", error);
}
this.hideConfirmDialog();
}
resetNewKeyStatus() {
const verifyBtn = document.getElementById("verifyNewKey");
const changeBtn = document.getElementById("changeKey");
verifyBtn.style.display = "flex";
changeBtn.style.display = "none";
this.newKeyData = null;
}
populateForm() {
document.getElementById("steamPath").value =
this.currentConfig.steam_path || "";
document.getElementById("debugMode").checked =
this.currentConfig.debug_mode || false;
document.getElementById("loggingFiles").checked =
this.currentConfig.logging_files !== false;
document.getElementById("showConsole").checked =
this.currentConfig.show_console !== false;
this.validateSteamPath();
}
async saveConfig() {
try {
const config = {
key: this.currentConfig.key || "",
steam_path: document.getElementById("steamPath").value.trim(),
debug_mode: document.getElementById("debugMode").checked,
logging_files: document.getElementById("loggingFiles").checked,
show_console: document.getElementById("showConsole").checked,
};
const response = await fetch("/api/config/update", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(config),
});
const data = await response.json();
if (data.success) {
this.showSnackbar("配置已保存", "success");
await this.loadConfig();
} else {
this.showSnackbar("保存失败: " + data.message, "error");
}
} catch (error) {
this.showSnackbar("保存配置时发生错误", "error");
console.error("Save config error:", error);
}
}
async resetConfig() {
try {
const response = await fetch("/api/config/reset", {
method: "POST",
});
const data = await response.json();
if (data.success) {
this.showSnackbar("配置已重置(卡密保持不变)", "success");
await this.loadConfig();
} else {
this.showSnackbar("重置失败: " + data.message, "error");
}
} catch (error) {
this.showSnackbar("重置配置时发生错误", "error");
console.error("Reset config error:", error);
}
this.hideConfirmDialog();
}
async testConfig() {
this.showSnackbar("正在测试配置...", "info");
try {
const response = await fetch("/api/config");
const data = await response.json();
if (data.success) {
let messages = [];
if (data.config.steam_path) {
messages.push("✓ Steam 路径配置正常");
} else {
messages.push("✗ Steam 路径配置异常");
}
if (this.currentKeyInfo) {
const expiresAt = new Date(this.currentKeyInfo.expiresAt);
let isExpired = expiresAt < new Date();
if ((this.currentKeyInfo.type = "permanent")) {
isExpired = false;
}
if (this.currentKeyInfo.isActive && !isExpired) {
messages.push("✓ 卡密状态正常");
} else {
messages.push("✗ 卡密状态异常");
}
}
this.showSnackbar(`配置测试完成: ${messages.join(", ")}`, "success");
} else {
this.showSnackbar("配置测试失败: " + data.message, "error");
}
} catch (error) {
this.showSnackbar("配置测试时发生错误", "error");
console.error("Test config error:", error);
}
}
detectSteamPath() {
const commonPaths = [
"C:\\Program Files (x86)\\Steam",
"C:\\Program Files\\Steam",
"D:\\Steam",
"E:\\Steam",
];
const suggestedPath = commonPaths[0];
document.getElementById("steamPath").value = suggestedPath;
this.validateSteamPath();
this.showSnackbar("已设置为常见路径,请确认是否正确", "info");
}
validateSteamPath() {
const steamPath = document.getElementById("steamPath").value.trim();
const statusElement = document.getElementById("steamPathStatus");
if (!steamPath) {
statusElement.className = "status-indicator";
statusElement.innerHTML = `
<span class="material-icons status-icon">info</span>
<span class="status-text">将使用自动检测的路径</span>
`;
} else {
if (steamPath.toLowerCase().includes("steam")) {
statusElement.className = "status-indicator success";
statusElement.innerHTML = `
<span class="material-icons status-icon">check_circle</span>
<span class="status-text">路径格式看起来正确</span>
`;
} else {
statusElement.className = "status-indicator warning";
statusElement.innerHTML = `
<span class="material-icons status-icon">warning</span>
<span class="status-text">路径可能不正确,请确认</span>
`;
}
}
}
updateConfigStatus() {
const statusGrid = document.getElementById("configStatusGrid");
const config = this.currentConfig;
const statusCards = [];
if (config.steam_path && config.steam_path_exists) {
statusCards.push({
type: "success",
icon: "folder",
title: "Steam 路径",
description: `路径有效: ${config.steam_path}`,
});
} else if (config.steam_path) {
statusCards.push({
type: "warning",
icon: "folder_off",
title: "Steam 路径",
description: "路径已设置但可能无效",
});
} else {
statusCards.push({
type: "error",
icon: "error",
title: "Steam 路径",
description: "未设置或自动检测失败",
});
}
if (config.debug_mode) {
statusCards.push({
type: "warning",
icon: "bug_report",
title: "调试模式",
description: "已启用,会输出详细日志",
});
}
if (config.logging_files) {
statusCards.push({
type: "success",
icon: "description",
title: "日志文件",
description: "已启用,日志将保存到文件",
});
}
statusGrid.innerHTML = statusCards
.map(
(card) => `
<div class="status-card ${card.type}">
<span class="material-icons status-card-icon">${card.icon}</span>
<div class="status-card-content">
<div class="status-card-title">${card.title}</div>
<div class="status-card-description">${card.description}</div>
</div>
</div>
`,
)
.join("");
}
showConfirmDialog(title, message, confirmAction) {
document.getElementById("dialogTitle").textContent = title;
document.getElementById("dialogMessage").textContent = message;
this.confirmAction = confirmAction;
const dialog = document.getElementById("confirmDialog");
dialog.classList.add("show");
}
hideConfirmDialog() {
const dialog = document.getElementById("confirmDialog");
dialog.classList.remove("show");
this.confirmAction = null;
}
executeConfirmAction() {
if (this.confirmAction) {
this.confirmAction();
}
this.hideConfirmDialog();
}
showSnackbar(message, type = "info") {
const snackbar = document.getElementById("snackbar");
const snackbarMessage = document.getElementById("snackbarMessage");
snackbarMessage.textContent = message;
snackbar.className = `snackbar ${type}`;
snackbar.offsetHeight;
snackbar.classList.add("show");
setTimeout(() => {
this.hideSnackbar();
}, 4000);
}
hideSnackbar() {
const snackbar = document.getElementById("snackbar");
snackbar.classList.remove("show");
}
}
function goBack() {
window.location.href = "/";
}
document.addEventListener("DOMContentLoaded", () => {
new SettingsManager();
});

178
web/zh/static/js/theme.js Normal file
View File

@@ -0,0 +1,178 @@
class ThemeManager {
constructor() {
this.themeToggle = document.getElementById("themeToggle");
this.currentTheme = this.getStoredTheme() || this.getPreferredTheme();
this.isTransitioning = false;
this.createThemeIndicator();
this.applyTheme(this.currentTheme, false);
this.initializeEventListeners();
}
getStoredTheme() {
return localStorage.getItem("theme");
}
getPreferredTheme() {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
createThemeIndicator() {
const indicator = document.createElement("div");
indicator.className = "theme-indicator";
indicator.id = "themeIndicator";
document.body.appendChild(indicator);
this.themeIndicator = indicator;
}
showThemeIndicator(message) {
this.themeIndicator.textContent = message;
this.themeIndicator.classList.add("show");
setTimeout(() => {
this.themeIndicator.classList.remove("show");
}, 2000);
}
applyTheme(theme, animate = true) {
if (this.isTransitioning) return;
this.isTransitioning = true;
if (animate) {
document.body.classList.add("theme-transitioning");
}
document.documentElement.setAttribute("data-theme", theme);
this.updateToggleButton(theme);
localStorage.setItem("theme", theme);
this.currentTheme = theme;
window.dispatchEvent(
new CustomEvent("themechange", {
detail: { theme, animated: animate },
}),
);
setTimeout(() => {
document.body.classList.remove("theme-transitioning");
this.isTransitioning = false;
}, 600);
}
updateToggleButton(theme) {
if (this.themeToggle) {
const icon = this.themeToggle.querySelector(".material-icons");
icon.textContent = theme === "dark" ? "dark_mode" : "light_mode";
this.themeToggle.title =
theme === "dark" ? "切换到浅色模式" : "切换到深色模式";
icon.style.animation = "none";
icon.offsetHeight;
icon.style.animation = "iconRotate 300ms ease";
}
}
toggleTheme(event) {
const newTheme = this.currentTheme === "dark" ? "light" : "dark";
if (event && event.currentTarget) {
const rect = event.currentTarget.getBoundingClientRect();
const x = ((rect.left + rect.width / 2) / window.innerWidth) * 100;
const y = ((rect.top + rect.height / 2) / window.innerHeight) * 100;
document.documentElement.style.setProperty("--x", `${x}%`);
document.documentElement.style.setProperty("--y", `${y}%`);
}
if ("vibrate" in navigator) {
navigator.vibrate(50);
}
this.applyTheme(newTheme);
this.logThemeSwitch(newTheme);
}
logThemeSwitch(theme) {
console.log(`主题切换到: ${theme}`);
}
initializeEventListeners() {
if (this.themeToggle) {
this.themeToggle.addEventListener("click", (e) => this.toggleTheme(e));
this.themeToggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this.toggleTheme(e);
}
});
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", (e) => {
if (!this.getStoredTheme()) {
this.applyTheme(e.matches ? "dark" : "light");
}
});
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "T") {
e.preventDefault();
this.toggleTheme();
}
});
window.addEventListener("storage", (e) => {
if (e.key === "theme" && e.newValue) {
this.applyTheme(e.newValue, false);
}
});
}
getThemePalette() {
const computedStyle = getComputedStyle(document.documentElement);
return {
primary: computedStyle.getPropertyValue("--md-sys-color-primary").trim(),
secondary: computedStyle
.getPropertyValue("--md-sys-color-secondary")
.trim(),
surface: computedStyle.getPropertyValue("--md-sys-color-surface").trim(),
background: computedStyle
.getPropertyValue("--md-sys-color-background")
.trim(),
onBackground: computedStyle
.getPropertyValue("--md-sys-color-on-background")
.trim(),
};
}
shouldUseDarkMode() {
const hour = new Date().getHours();
return hour >= 18 || hour < 6;
}
enableAutoThemeSwitch() {
const checkTime = () => {
if (!this.getStoredTheme()) {
const shouldBeDark = this.shouldUseDarkMode();
const currentTheme = this.currentTheme;
if (
(shouldBeDark && currentTheme === "light") ||
(!shouldBeDark && currentTheme === "dark")
) {
this.applyTheme(shouldBeDark ? "dark" : "light");
}
}
};
setInterval(checkTime, 60000);
checkTime();
}
}
window.ThemeManager = new ThemeManager();

169
web/zh/templates/about.html Normal file
View File

@@ -0,0 +1,169 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onekey - About</title>
<!-- Material Design 3 -->
<link
href="https://cdn.jsdmirror.com/gh/ikun0014/font@main/LXGWWenKaiMono-Regular/result.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
<!-- 自定义样式 -->
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<div class="footer-content">
<!-- 项目介绍卡片 -->
<div class="project-info-card">
<div class="project-header">
<div class="project-logo">
<span class="material-icons">extension</span>
</div>
<div class="project-details">
<h3 class="project-name">Onekey</h3>
<p class="project-subtitle">直观,优雅的游戏解锁解决方案</p>
</div>
<div class="project-version">
<span class="version-label">v2.1.1</span>
<span class="version-type">Web UI</span>
</div>
</div>
<div class="project-links">
<a
href="https://github.com/ikunshare/Onekey"
target="_blank"
class="project-link github"
>
<span class="material-icons">code</span>
<div class="link-content">
<span class="link-title">GitHub 仓库</span>
<span class="link-url">github.com/ikunshare/Onekey</span>
</div>
</a>
<a
href="https://github.com/ikunshare/Onekey/releases"
target="_blank"
class="project-link releases"
>
<span class="material-icons">file_download</span>
<div class="link-content">
<span class="link-title">下载发布版</span>
<span class="link-url">获取最新版本</span>
</div>
</a>
<a
href="https://shop.ikunshare.com"
target="_blank"
class="project-link buy_cdk"
>
<span class="material-icons">shopping_cart</span>
<div class="link-content">
<span class="link-title">购卡链接</span>
<span class="link-url">购买卡密</span>
</div>
</a>
<a
href="https://github.com/qwq-xinkeng"
target="_blank"
class="project-link author"
>
<span class="material-icons">person</span>
<div class="link-content">
<span class="link-title">作者主页</span>
<span class="link-url">github.com/qwq-xinkeng</span>
</div>
</a>
<a
href="https://github.com/ikun0014"
target="_blank"
class="project-link author"
>
<span class="material-icons">person</span>
<div class="link-content">
<span class="link-title">作者主页</span>
<span class="link-url">github.com/ikun0014</span>
</div>
</a>
<a
href="https://github.com/ikunshare/Onekey/issues"
target="_blank"
class="project-link issues"
>
<span class="material-icons">bug_report</span>
<div class="link-content">
<span class="link-title">问题反馈</span>
<span class="link-url">报告Bug或建议</span>
</div>
</a>
</div>
</div>
<!-- 技术信息 -->
<div class="tech-info">
<div class="tech-header">
<span class="material-icons">code</span>
<h4>技术信息</h4>
</div>
<div class="tech-content">
<div class="tech-item">
<strong>🐍 后端技术</strong>
<span>Python 3.8+ • FastAPI • AsyncIO • HTTPX</span>
</div>
<div class="tech-item">
<strong>🌐 前端技术</strong>
<span>HTML5 • CSS3 • JavaScript ES6+ • Material Design 3.0</span>
</div>
<div class="tech-item">
<strong>🔧 支持工具</strong>
<span>SteamTools • GreenLuma</span>
</div>
</div>
</div>
<!-- 使用须知 -->
<div class="usage-notice">
<div class="notice-header">
<span class="material-icons">info</span>
<h4>使用须知</h4>
</div>
<div class="notice-content">
<p>
<strong>🖥️ 系统要求</strong> - 请确保已安装Windows
10/11并正确配置Steam客户端
</p>
<p>
<strong>🛠️ 工具准备</strong> -
使用前请先安装SteamTools或GreenLuma解锁工具
</p>
<p>
<strong>🔒 免责声明</strong> -
本工具仅供学习交流使用,使用者需自行承担相关风险
</p>
<p>
<strong>⭐ 支持项目</strong> -
如果此工具对您有帮助欢迎在GitHub上给项目点Star支持
</p>
</div>
</div>
<!-- 版权信息 -->
<div class="copyright">
<p>© 2025 Onekey Steam解锁工具 • 作者qwq-xinkeng && ikun0014</p>
<p>
项目地址:<a href="https://github.com/ikunshare/Onekey" target="_blank"
>https://github.com/ikunshare/Onekey</a
>
</p>
</div>
<script src="{{ url_for('static', path='js/theme.js') }}"></script>
</div>
</html>

175
web/zh/templates/index.html Normal file
View File

@@ -0,0 +1,175 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onekey - Home</title>
<!-- Material Design 3 -->
<link
href="https://cdn.jsdmirror.com/gh/ikun0014/font@main/LXGWWenKaiMono-Regular/result.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
<!-- 自定义样式 -->
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="app-container">
<!-- 顶部应用栏 -->
<header class="app-bar">
<div class="app-bar-content">
<span class="material-icons app-icon">games</span>
<h1 class="app-title">Onekey</h1>
<button
type="button"
class="theme-toggle"
id="themeToggle"
title="切换主题"
>
<span class="material-icons">light_mode</span>
</button>
<a href="/settings" class="btn btn-text settings-link">
<span class="material-icons">settings</span>
<span class="settings-text">设置</span>
</a>
<a href="/about" class="btn btn-text about-link">
<span class="material-icons">info</span>
<span class="about-text">关于本项目</span>
</a>
</div>
</header>
<!-- 主内容区域 -->
<main class="main-content">
<!-- 配置状态卡片 -->
<div class="card config-card">
<div class="card-header">
<span class="material-icons">settings</span>
<h2>配置状态</h2>
</div>
<div class="card-content">
<div class="config-status" id="configStatus">
<div class="loading">正在检查配置...</div>
</div>
</div>
</div>
<!-- 游戏解锁卡片 -->
<div class="card unlock-card">
<div class="card-header">
<span class="material-icons">lock_open</span>
<h2>游戏解锁</h2>
</div>
<div class="card-content">
<form id="unlockForm" class="unlock-form">
<div class="input-group">
<label for="appId" class="input-label">Steam App ID</label>
<input
type="text"
id="appId"
name="appId"
class="text-field"
placeholder="请输入游戏的App ID"
inputmode="numeric"
autocomplete="off"
autofocus
required
/>
<div class="input-feedback" id="appIdFeedback"></div>
<div class="input-helper">例如: 730 (CS2), 570 (Dota 2)</div>
</div>
<div class="input-group">
<label class="input-label">解锁工具</label>
<div class="radio-group">
<label class="radio-item">
<input
type="radio"
name="toolType"
value="steamtools"
checked
/>
<span class="radio-button"></span>
<span class="radio-label"
>SteamTools(更新积极, 推荐使用)</span
>
</label>
<label class="radio-item">
<input type="radio" name="toolType" value="greenluma" />
<span class="radio-button"></span>
<span class="radio-label">GreenLuma(一年一更, 无GUI)</span>
</label>
</div>
</div>
<div class="input-group" id="+DLCGroup">
<label class="checkbox-item">
<input type="checkbox" id="+DLC" name="+DLC" />
<span class="checkbox-button"></span>
<span class="checkbox-label">检索并入库所有DLC</span>
</label>
<div class="input-helper">
需要注意: 有些DLC的Depot与游戏本体在一起, 不会分离
</div>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary" id="unlockBtn">
<span class="material-icons">play_arrow</span>
开始解锁
</button>
<button type="button" class="btn btn-secondary" id="resetBtn">
<span class="material-icons">refresh</span>
重置
</button>
</div>
</form>
</div>
</div>
<!-- 进度日志卡片 -->
<div class="card progress-card">
<div class="card-header">
<span class="material-icons">timeline</span>
<h2>执行日志</h2>
<div class="card-actions">
<button class="btn btn-text" id="clearLogBtn">
<span class="material-icons">clear_all</span>
清空
</button>
</div>
</div>
<div class="card-content">
<div class="progress-container" id="progressContainer">
<div class="progress-placeholder">
<span class="material-icons">info</span>
<p>等待任务开始...</p>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 提示框 -->
<div id="snackbar" class="snackbar">
<div class="snackbar-content">
<span id="snackbarMessage"></span>
<button id="snackbarClose" class="snackbar-action">
<span class="material-icons">close</span>
</button>
</div>
</div>
<!-- 脚本 -->
<script src="{{ url_for('static', path='js/app.js') }}"></script>
<script src="{{ url_for('static', path='js/theme.js') }}"></script>
<script src="{{ url_for('static', path='js/project-info.js') }}"></script>
</body>
</html>

475
web/zh/templates/oobe.html Normal file
View File

@@ -0,0 +1,475 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onekey - 首次使用向导</title>
<!-- Material Design 3 -->
<link
href="https://cdn.jsdmirror.com/gh/ikun0014/font@main/style.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
<!-- 自定义样式 -->
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="oobe-container">
<!-- 顶部应用栏 -->
<div class="oobe-card">
<div class="oobe-header">
<button
type="button"
class="theme-toggle"
id="themeToggle"
title="切换主题"
>
<span class="material-icons">light_mode</span>
</button>
<div class="oobe-logo">
<span class="material-icons" style="font-size: inherit"
>extension</span
>
</div>
<h1 class="oobe-title">欢迎使用 Onekey</h1>
<p class="oobe-subtitle">一键解锁,畅享游戏体验</p>
</div>
<div class="oobe-content">
<div class="step-indicator">
<div class="step-dot active" data-step="0"></div>
<div class="step-dot" data-step="1"></div>
<div class="step-dot" data-step="2"></div>
</div>
<!-- 步骤 1: 欢迎 -->
<div class="oobe-step active" data-step="0">
<div class="welcome-text">
<h3>🎮 欢迎来到 Onekey 世界</h3>
<p>
Onekey 是一个强大的 Steam
游戏解锁工具,帮助您轻松管理和解锁游戏。
</p>
<p>在开始使用之前,我们需要验证您的授权卡密。</p>
<p><strong>特点:</strong></p>
<p>• 支持 SteamTools 和 GreenLuma 两种解锁方式</p>
<p>• 直观的 Web 界面,操作简单</p>
<p>• 实时日志显示,过程透明</p>
<p>• 前端代码完全开源, 绝对不盗号/挖矿</p>
<a href="https://shop.ikunshare.com" target="_blank"
>• 点我购买卡密</a
>
</div>
</div>
<!-- 步骤 2: 卡密验证 -->
<div class="oobe-step" data-step="1">
<div class="welcome-text">
<h3>🔑 激活您的卡密</h3>
<p>请输入您的授权卡密以激活 Onekey 工具。</p>
</div>
<div class="key-input-section">
<div class="input-group">
<label for="activationKey" class="input-label">授权卡密</label>
<input
type="text"
id="activationKey"
class="text-field"
placeholder="请输入您的卡密"
autocomplete="off"
/>
<div class="input-helper">
卡密格式:[PREFIX]_XXXXXXXX-XXXXXXXXXXXXXXXX
</div>
</div>
<div class="key-status" id="keyStatus">
<div class="status-header">
<span class="material-icons" id="statusIcon">info</span>
<span id="statusMessage">验证中...</span>
</div>
<div class="key-info" id="keyInfo"></div>
</div>
</div>
</div>
<!-- 步骤 4: 完成 -->
<div class="oobe-step" data-step="3">
<div class="welcome-text">
<h3>🎉 设置完成</h3>
<p>恭喜!您已成功激活 Onekey 工具。</p>
<p>现在您可以开始使用所有功能了。</p>
<div
class="key-info"
id="finalKeyInfo"
style="margin-top: 24px"
></div>
</div>
</div>
<div class="oobe-actions">
<button
type="button"
id="prevBtn"
class="btn btn-text"
style="display: none"
>
<span class="material-icons">arrow_back</span>
上一步
</button>
<button
type="button"
id="nextBtn"
class="btn btn-primary btn-large"
>
<span class="material-icons">arrow_forward</span>
下一步
</button>
<button
type="button"
id="verifyBtn"
class="btn btn-primary btn-large"
style="display: none"
>
<span class="material-icons">verified</span>
验证卡密
</button>
<button
type="button"
id="finishBtn"
class="btn btn-primary btn-large"
style="display: none"
>
<span class="material-icons">check</span>
开始使用
</button>
</div>
</div>
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-spinner"></div>
</div>
</div>
</div>
<!-- 提示框 -->
<div id="snackbar" class="snackbar">
<div class="snackbar-content">
<span id="snackbarMessage"></span>
<button id="snackbarClose" class="snackbar-action">
<span class="material-icons">close</span>
</button>
</div>
</div>
<script>
class OOBEManager {
constructor() {
this.currentStep = 0;
this.totalSteps = 3;
this.keyData = null;
this.initializeEventListeners();
this.updateStepDisplay();
}
initializeEventListeners() {
document.getElementById("nextBtn").addEventListener("click", () => {
this.nextStep();
});
document.getElementById("prevBtn").addEventListener("click", () => {
this.prevStep();
});
document.getElementById("verifyBtn").addEventListener("click", () => {
this.verifyKey();
});
document.getElementById("finishBtn").addEventListener("click", () => {
this.finishSetup();
});
document
.getElementById("activationKey")
.addEventListener("input", () => {
this.resetKeyStatus();
});
document
.getElementById("activationKey")
.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
this.verifyKey();
}
});
document
.getElementById("snackbarClose")
.addEventListener("click", () => {
this.hideSnackbar();
});
}
nextStep() {
if (this.currentStep < this.totalSteps - 1) {
this.currentStep++;
this.updateStepDisplay();
}
}
prevStep() {
if (this.currentStep > 0) {
this.currentStep--;
this.updateStepDisplay();
}
}
updateStepDisplay() {
document.querySelectorAll(".step-dot").forEach((dot, index) => {
dot.classList.remove("active", "completed");
if (index < this.currentStep) {
dot.classList.add("completed");
} else if (index === this.currentStep) {
dot.classList.add("active");
}
});
document.querySelectorAll(".oobe-step").forEach((step, index) => {
step.classList.toggle("active", index === this.currentStep);
});
this.updateButtons();
}
updateButtons() {
const prevBtn = document.getElementById("prevBtn");
const nextBtn = document.getElementById("nextBtn");
const verifyBtn = document.getElementById("verifyBtn");
const finishBtn = document.getElementById("finishBtn");
[prevBtn, nextBtn, verifyBtn, finishBtn].forEach((btn) => {
btn.style.display = "none";
});
if (this.currentStep > 0) {
prevBtn.style.display = "flex";
}
switch (this.currentStep) {
case 0:
nextBtn.style.display = "flex";
break;
case 1:
verifyBtn.style.display = "flex";
break;
case 2:
finishBtn.style.display = "flex";
break;
}
}
resetKeyStatus() {
const keyStatus = document.getElementById("keyStatus");
keyStatus.classList.remove("show", "success", "error");
}
async verifyKey() {
const keyInput = document.getElementById("activationKey");
const key = keyInput.value.trim();
if (!key) {
this.showSnackbar("请输入卡密", "error");
return;
}
if (!key.match(/^[A-Z0-9_-]+$/)) {
this.showKeyStatus("error", "卡密格式不正确", "error");
return;
}
this.showLoading(true);
try {
const response = await fetch("/api/getKeyInfo", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ key: key }),
});
const data = await response.json();
if (data.key && data.info) {
this.keyData = data.info;
this.showKeyStatus("success", "卡密验证成功!", "check_circle");
this.displayKeyInfo(data.info);
setTimeout(() => {
this.nextStep();
this.showFinalKeyInfo(data.info);
}, 2000);
} else {
this.showKeyStatus(
"error",
data.message || "卡密不存在或已过期",
"error",
);
}
} catch (error) {
this.showKeyStatus("error", "验证失败,请检查网络连接", "error");
console.error("Key verification error:", error);
} finally {
this.showLoading(false);
}
}
showKeyStatus(type, message, icon) {
const keyStatus = document.getElementById("keyStatus");
const statusIcon = document.getElementById("statusIcon");
const statusMessage = document.getElementById("statusMessage");
statusIcon.textContent = icon;
statusMessage.textContent = message;
keyStatus.className = `key-status show ${type}`;
}
displayKeyInfo(keyInfo) {
const keyInfoContainer = document.getElementById("keyInfo");
const expiresAt = new Date(keyInfo.expiresAt);
const isExpired = expiresAt < new Date();
const typeNames = {
day: "日卡",
week: "周卡",
month: "月卡",
year: "年卡",
permanent: "永久卡",
};
keyInfoContainer.innerHTML = `
<div class="key-info-item">
<span class="material-icons">label</span>
<span>类型:${typeNames[keyInfo.type] || keyInfo.type}</span>
</div>
<div class="key-info-item">
<span class="material-icons">schedule</span>
<span>到期:${expiresAt.toLocaleDateString()}</span>
</div>
<div class="key-info-item">
<span class="material-icons">analytics</span>
<span>使用次数:${keyInfo.usageCount}</span>
</div>
<div class="key-info-item">
<span class="material-icons">${keyInfo.isActive && !isExpired ? "check_circle" : "cancel"}</span>
<span>状态:${keyInfo.isActive && !isExpired ? "有效" : "无效"}</span>
</div>
`;
}
showFinalKeyInfo(keyInfo) {
const finalKeyInfo = document.getElementById("finalKeyInfo");
const expiresAt = new Date(keyInfo.expiresAt);
const typeNames = {
day: "日卡",
week: "周卡",
month: "月卡",
year: "年卡",
permanent: "永久卡",
};
finalKeyInfo.innerHTML = `
<div class="key-info-item">
<span class="material-icons">verified_user</span>
<span><strong>卡密类型:</strong>${typeNames[keyInfo.type] || keyInfo.type}</span>
</div>
<div class="key-info-item">
<span class="material-icons">event</span>
<span><strong>有效期至:</strong>${expiresAt.toLocaleDateString()} ${expiresAt.toLocaleTimeString()}</span>
</div>
`;
}
async finishSetup() {
if (!this.keyData) {
this.showSnackbar("卡密数据丢失,请重新验证", "error");
this.currentStep = 1;
this.updateStepDisplay();
return;
}
this.showLoading(true);
try {
const response = await fetch("/api/config/update", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: document.getElementById("activationKey").value.trim(),
steam_path: "",
debug_mode: false,
logging_files: true,
show_console: false,
}),
});
const data = await response.json();
if (data.success) {
this.showSnackbar("配置保存成功,正在跳转...", "success");
setTimeout(() => {
window.location.href = "/";
}, 1500);
} else {
throw new Error(data.message || "保存配置失败");
}
} catch (error) {
this.showSnackbar("保存配置失败:" + error.message, "error");
console.error("Save config error:", error);
} finally {
this.showLoading(false);
}
}
showLoading(show) {
const overlay = document.getElementById("loadingOverlay");
overlay.classList.toggle("show", show);
}
showSnackbar(message, type = "info") {
const snackbar = document.getElementById("snackbar");
const snackbarMessage = document.getElementById("snackbarMessage");
snackbarMessage.textContent = message;
snackbar.className = `snackbar ${type} show`;
setTimeout(() => {
this.hideSnackbar();
}, 4000);
}
hideSnackbar() {
const snackbar = document.getElementById("snackbar");
snackbar.classList.remove("show");
}
}
document.addEventListener("DOMContentLoaded", () => {
new OOBEManager();
});
</script>
<script src="{{ url_for('static', path='js/theme.js') }}"></script>
</body>
</html>

View File

@@ -0,0 +1,289 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onekey - Settings</title>
<!-- Material Design 3 -->
<link
href="https://cdn.jsdmirror.com/gh/ikun0014/font@main/style.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
<!-- 自定义样式 -->
<link rel="stylesheet" href="/static/css/style.css" />
<link rel="stylesheet" href="/static/css/settings.css" />
</head>
<body>
<div class="app-container">
<!-- 顶部应用栏 -->
<header class="app-bar">
<div class="app-bar-content">
<button class="btn btn-text" onclick="goBack()">
<span class="material-icons">arrow_back</span>
</button>
<span class="material-icons app-icon">settings</span>
<h1 class="app-title">设置</h1>
</div>
</header>
<!-- 主内容区域 -->
<main class="main-content settings-main">
<!-- 卡密管理卡片 -->
<div class="card">
<div class="card-header">
<span class="material-icons">verified</span>
<h2>卡密管理</h2>
</div>
<div class="card-content">
<div class="settings-section">
<div id="keyInfoSection">
<div class="loading">正在加载卡密信息...</div>
</div>
<div class="key-change-section">
<h4
style="
margin: 0 0 16px 0;
color: var(--md-sys-color-on-surface);
"
>
<span
class="material-icons"
style="vertical-align: middle; margin-right: 8px"
>swap_horiz</span
>
更换卡密
</h4>
<div class="key-input-group">
<div class="input-group" style="flex: 1; margin: 0">
<label for="newKey" class="input-label">新卡密</label>
<input
type="text"
id="newKey"
class="text-field"
placeholder="请输入新的卡密"
autocomplete="off"
/>
<div class="input-helper">
格式:[PREFIX]_XXXXXXXX-XXXXXXXXXXXXXXXX
</div>
</div>
<button
type="button"
id="verifyNewKey"
class="btn btn-secondary"
>
<span class="material-icons">check</span>
验证
</button>
<button
type="button"
id="changeKey"
class="btn btn-primary"
style="display: none"
>
<span class="material-icons">save</span>
保存
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Steam 配置卡片 -->
<div class="card">
<div class="card-header">
<span class="material-icons">games</span>
<h2>Steam 配置</h2>
</div>
<div class="card-content">
<div class="settings-section">
<div class="input-group">
<label for="steamPath" class="input-label"
>Steam 安装路径</label
>
<div class="path-input-group">
<input
type="text"
id="steamPath"
class="text-field"
placeholder="留空自动检测或手动输入Steam安装路径"
/>
<button
type="button"
id="detectSteamPath"
class="btn btn-secondary"
>
<span class="material-icons">search</span>
自动检测
</button>
</div>
<div class="input-helper">
程序会尝试自动检测Steam安装路径如果检测失败请手动输入。
通常位于C:\Program Files (x86)\Steam
</div>
</div>
<div class="status-indicator" id="steamPathStatus">
<span class="material-icons status-icon">info</span>
<span class="status-text">等待检测...</span>
</div>
</div>
</div>
</div>
<!-- 应用程序配置卡片 -->
<div class="card">
<div class="card-header">
<span class="material-icons">tune</span>
<h2>应用程序配置</h2>
</div>
<div class="card-content">
<div class="settings-section">
<div class="setting-item">
<label class="setting-label">语言选择</label>
<div class="radio-group">
<label class="radio-item">
<input
type="radio"
name="language"
value="zh"
id="language-zh"
checked
/>
<span class="radio-button"></span>
<span class="radio-label">简体中文</span>
</label>
<label class="radio-item">
<input
type="radio"
name="language"
value="en"
id="language-en"
/>
<span class="radio-button"></span>
<span class="radio-label">English</span>
</label>
</div>
</div>
<div class="setting-item">
<label class="checkbox-item">
<input type="checkbox" id="debugMode" />
<span class="checkbox-button"></span>
<div class="checkbox-content">
<span class="checkbox-label">调试模式</span>
<span class="checkbox-description"
>启用详细的调试日志输出</span
>
</div>
</label>
</div>
<div class="setting-item">
<label class="checkbox-item">
<input type="checkbox" id="loggingFiles" />
<span class="checkbox-button"></span>
<div class="checkbox-content">
<span class="checkbox-label">保存日志文件</span>
<span class="checkbox-description"
>将日志保存到文件中,便于问题排查</span
>
</div>
</label>
</div>
<div class="setting-item">
<label class="checkbox-item">
<input type="checkbox" id="showConsole" />
<span class="checkbox-button"></span>
<div class="checkbox-content">
<span class="checkbox-label">显示终端窗口</span>
<span class="checkbox-description"
>启动时显示终端窗口和日志输出</span
>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- 操作按钮卡片 -->
<div class="card">
<div class="card-content">
<div class="action-buttons">
<button type="button" id="saveConfig" class="btn btn-primary">
<span class="material-icons">save</span>
保存配置
</button>
<button type="button" id="resetConfig" class="btn btn-secondary">
<span class="material-icons">restore</span>
重置为默认值
</button>
<button type="button" id="testConfig" class="btn btn-secondary">
<span class="material-icons">check_circle</span>
测试配置
</button>
</div>
</div>
</div>
<!-- 配置信息显示卡片 -->
<div class="card">
<div class="card-header">
<span class="material-icons">info</span>
<h2>配置状态</h2>
</div>
<div class="card-content">
<div class="config-status-grid" id="configStatusGrid">
<div class="loading">正在加载配置状态...</div>
</div>
</div>
</div>
</main>
</div>
<!-- 确认对话框 -->
<div id="confirmDialog" class="dialog-overlay">
<div class="dialog">
<div class="dialog-header">
<h3 id="dialogTitle">确认操作</h3>
</div>
<div class="dialog-content">
<p id="dialogMessage">确定要执行此操作吗?</p>
</div>
<div class="dialog-actions">
<button type="button" id="dialogCancel" class="btn btn-text">
取消
</button>
<button type="button" id="dialogConfirm" class="btn btn-primary">
确认
</button>
</div>
</div>
</div>
<!-- 提示框 -->
<div id="snackbar" class="snackbar">
<div class="snackbar-content">
<span id="snackbarMessage"></span>
<button id="snackbarClose" class="snackbar-action">
<span class="material-icons">close</span>
</button>
</div>
</div>
<!-- 脚本 -->
<script src="{{ url_for('static', path='js/settings.js') }}"></script>
<script src="{{ url_for('static', path='js/theme.js') }}"></script>
<script src="{{ url_for('static', path='js/project-info.js') }}"></script>
</body>
</html>