mirror of
https://github.com/ikunshare/Onekey.git
synced 2026-01-15 01:23:02 +08:00
Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b91bf42981 | ||
|
|
dacaa1c62e | ||
|
|
22619df037 | ||
|
|
840a99a1e9 | ||
|
|
ba3c60c775 | ||
|
|
80628ad17a | ||
|
|
9880b90d19 | ||
|
|
3f4d327f6e | ||
|
|
361150b3bc | ||
|
|
4f213f9514 | ||
|
|
56eab758cd | ||
|
|
9c4890db37 | ||
|
|
ddd1cb7b6c | ||
|
|
839e922d79 | ||
|
|
2bd2f23838 | ||
|
|
9e68621e76 | ||
|
|
2a6a9421ca | ||
|
|
1a6cf47882 | ||
|
|
09f94c5f11 | ||
|
|
5307dc0766 | ||
|
|
15b3caddcf | ||
|
|
eefff08159 | ||
|
|
956efef428 | ||
|
|
56901f1b92 | ||
|
|
490dc096c1 | ||
|
|
911f6f39e3 | ||
|
|
343c86808d | ||
|
|
6a0e6e926d | ||
|
|
611edaec04 | ||
|
|
92559710f7 | ||
|
|
03a661f288 | ||
|
|
bb160f9f6d | ||
|
|
e5d4ad55ee | ||
|
|
7968d40491 | ||
|
|
cc9778e537 | ||
|
|
1f09166b49 | ||
|
|
02e139e23e | ||
|
|
3e336e0b65 | ||
|
|
a511ec20df | ||
|
|
00f81b0263 | ||
|
|
63e302b565 | ||
|
|
7a0924cfea | ||
|
|
1072acd698 | ||
|
|
d0ea16de02 | ||
|
|
b1146dd9ff | ||
|
|
df2f66961e | ||
|
|
5a9be8004d | ||
|
|
33d00b3738 | ||
|
|
2c88a769a4 | ||
|
|
5fb2ed26bd | ||
|
|
1a36dc507c | ||
|
|
f207604b0e | ||
|
|
74a74e5fa3 | ||
|
|
f7118f0224 | ||
|
|
a475dcb6b8 | ||
|
|
2ea7c76004 | ||
|
|
14684cf1b7 | ||
|
|
f560dab35f | ||
|
|
8cdd9aa208 | ||
|
|
37f862ba9e | ||
|
|
8612fd0c94 | ||
|
|
7fcbadabdf | ||
|
|
6a21200ccc | ||
|
|
0a384ce114 | ||
|
|
041f8d6a00 | ||
|
|
4db910c8da | ||
|
|
8bf15eda57 | ||
|
|
628b92b86d | ||
|
|
ee8c2242f2 | ||
|
|
76340538b8 | ||
|
|
c693220d73 | ||
|
|
324e537c60 | ||
|
|
50b9f1b724 | ||
|
|
7ba02c4e8f | ||
|
|
b2dada2018 | ||
|
|
5ca4f26242 | ||
|
|
da596964da | ||
|
|
ed8fa1cd7f | ||
|
|
485a9d85e2 | ||
|
|
41cfa244e3 | ||
|
|
09a9e48f7e | ||
|
|
7ef7297119 | ||
|
|
ad26456d6c | ||
|
|
cd18a2f49d | ||
|
|
485fca07f1 | ||
|
|
452be816b1 | ||
|
|
2ba17f1bac | ||
|
|
17e1fea9cf | ||
|
|
2fd7a13bcc | ||
|
|
4edcfa8c8e | ||
|
|
a0536fb4d6 | ||
|
|
0912841e44 | ||
|
|
3b39253d0f | ||
|
|
1ddbf5e02f |
15
.github/FUNDING.yml
vendored
15
.github/FUNDING.yml
vendored
@@ -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']
|
||||
41
.github/workflows/dev.yml
vendored
Normal file
41
.github/workflows/dev.yml
vendored
Normal 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
|
||||
137
.github/workflows/release.yml
vendored
137
.github/workflows/release.yml
vendored
@@ -1,9 +1,7 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -11,89 +9,66 @@ jobs:
|
||||
contents: write
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v4
|
||||
- name: 拉取仓库
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get package version
|
||||
shell: powershell
|
||||
run: |
|
||||
$version = (Get-Content package.json | ConvertFrom-Json).version
|
||||
echo "PACKAGE_VERSION=$version" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: 3.12
|
||||
- name: 获取版本
|
||||
shell: powershell
|
||||
run: |
|
||||
$version = (Get-Content package.json | ConvertFrom-Json).version
|
||||
echo "PACKAGE_VERSION=$version" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install imageio
|
||||
pip install -r requirements.txt
|
||||
- name: 安装Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Build
|
||||
uses: Nuitka/Nuitka-Action@main
|
||||
with:
|
||||
nuitka-version: main
|
||||
script-name: main.py
|
||||
standalone: true
|
||||
onefile: true
|
||||
show-memory: true
|
||||
windows-uac-admin: true
|
||||
windows-icon-from-ico: icon.jpg
|
||||
company-name: ikunshare
|
||||
product-name: Onekey
|
||||
file-version: ${{ env.PACKAGE_VERSION }}
|
||||
product-version: ${{ env.PACKAGE_VERSION }}
|
||||
file-description: 一个Steam仓库清单下载器
|
||||
copyright: Copyright © 2024 ikun0014
|
||||
output-file: Onekey---v${{ env.PACKAGE_VERSION }}.exe
|
||||
assume-yes-for-downloads: true
|
||||
output-dir: build
|
||||
- name: 安装依赖
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install imageio
|
||||
pip install -r requirements.txt
|
||||
pip install nuitka
|
||||
|
||||
- name: Create git tag
|
||||
uses: pkgdeps/git-tag-action@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
github_repo: ${{ github.repository }}
|
||||
version: ${{ env.PACKAGE_VERSION }}
|
||||
git_commit_sha: ${{ github.sha }}
|
||||
git_tag_prefix: "v"
|
||||
- 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: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Onekey_v${{ env.PACKAGE_VERSION }}.exe
|
||||
path: build/Onekey_v${{ env.PACKAGE_VERSION }}.exe
|
||||
- name: 创建标签
|
||||
uses: pkgdeps/git-tag-action@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
github_repo: ${{ github.repository }}
|
||||
version: ${{ env.PACKAGE_VERSION }}
|
||||
git_commit_sha: ${{ github.sha }}
|
||||
git_tag_prefix: "v"
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ env.PACKAGE_VERSION }}
|
||||
files: build/Onekey---v${{ env.PACKAGE_VERSION }}.exe
|
||||
prerelease: false
|
||||
draft: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: 上传包
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Onekey_v${{ env.PACKAGE_VERSION }}.exe
|
||||
path: build/Onekey_v${{ env.PACKAGE_VERSION }}.exe
|
||||
|
||||
- name: Gitee Release
|
||||
uses: nicennnnnnnlee/action-gitee-release@v1.0.5
|
||||
with:
|
||||
gitee_owner: ikun0014
|
||||
gitee_repo: Onekey
|
||||
gitee_token: ${{ secrets.GITEE_TOKEN }}
|
||||
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${{ env.PACKAGE_VERSION }}.exe
|
||||
gitee_file_path: build/Onekey---v${{ env.PACKAGE_VERSION }}.exe
|
||||
- name: 发布
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ env.PACKAGE_VERSION }}
|
||||
files: build/Onekey_v${{ env.PACKAGE_VERSION }}.exe
|
||||
prerelease: false
|
||||
draft: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload to Telegram Channel
|
||||
run: |
|
||||
& curl -F "chat_id=${{ secrets.TELEGRAM_TO }}" `
|
||||
-F "document=@build/Onekey---v${{ env.PACKAGE_VERSION }}.exe" `
|
||||
-F "caption=Onekey's New Update ${{ env.PACKAGE_VERSION }}" `
|
||||
-F "parse_mode=Markdown" `
|
||||
"https://api.telegram.org/bot${{ secrets.TELEGRAM_TOKEN }}/sendDocument"
|
||||
- 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${{ 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${{ env.PACKAGE_VERSION }}.exe
|
||||
gitee_file_path: build/Onekey_v${{ env.PACKAGE_VERSION }}.exe
|
||||
|
||||
25
.github/workflows/sync.yml
vendored
25
.github/workflows/sync.yml
vendored
@@ -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
|
||||
85
.gitignore
vendored
85
.gitignore
vendored
@@ -27,16 +27,9 @@ share/python-wheels/
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Nuitka
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
dist/
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
@@ -88,33 +81,12 @@ profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
# PEP 582
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
@@ -157,18 +129,43 @@ dmypy.json
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
config.json
|
||||
/output
|
||||
*.bat
|
||||
*.xml
|
||||
logs/
|
||||
*.exe
|
||||
*.dll
|
||||
/main.dist
|
||||
/.vscode
|
||||
/.vs
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
coverage.xml
|
||||
*.cover
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.bak
|
||||
*.backup
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# Nuitka
|
||||
*.build
|
||||
*.dist
|
||||
*.onefile-build
|
||||
|
||||
.node_modules/
|
||||
3
.idea/.gitignore
generated
vendored
3
.idea/.gitignore
generated
vendored
@@ -1,3 +0,0 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
10
.idea/Onekey.iml
generated
10
.idea/Onekey.iml
generated
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
929
LICENSE
929
LICENSE
@@ -1,642 +1,304 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
<https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
@@ -644,31 +306,36 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Moe Ghoul>, 1 April 1989
|
||||
Moe Ghoul, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
|
||||
56
README.md
56
README.md
@@ -7,24 +7,35 @@
|
||||
[](https://github.com/ikunshare/Onekey/releases)
|
||||
[](https://github.com/ikunshare/Onekey/blob/main/LICENSE)
|
||||
|
||||
[](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
## Onekey
|
||||
Onekey Steam Depot Manifest Downloader
|
||||
|
||||
Onekey Steam Depot Manifest Downloader
|
||||
对本软件有意见的
|
||||
欢迎拨打中华人民共和国公安部门报警电话: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
|
||||
@@ -36,24 +47,9 @@ git clone https://github.com/ikunshare/Onekey
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 项目协议
|
||||
本项目基于 GPL-3.0 许可证发行,以下协议是对于 GPL-3.0 原协议的补充,如有冲突,以以下协议为准。
|
||||
|
||||
词语约定: “使用者”指签署本协议的使用者;“版权数据”指包括但不限于图像、音频、名字等在内的他人拥有所属版权的数据。
|
||||
|
||||
本项目的数据来源原理是从Steam官方的CDN服务器中拉取游戏清单数据,经过对数据简单地筛选与合并后进行展示,因此本项目不对数据的准确性负责。
|
||||
使用本项目的过程中可能会产生版权数据,对于这些版权数据,本项目不拥有它们的所有权,为了避免造成侵权,使用者务必在24 小时内清除使用本项目的过程中所产生的版权数据。
|
||||
由于使用本项目产生的包括由于本协议或由于使用或无法使用本项目而引起的任何性质的任何直接、间接、特殊、偶然或结果性损害(包括但不限于因商誉损失、停工、计算机故障或故障引起的损害赔偿,或任何及所有其他商业损害或损失)由使用者负责。
|
||||
本项目完全免费,且开源发布于 GitHub 面向全世界人用作对技术的学习交流,本项目不对项目内的技术可能存在违反当地法律法规的行为作保证,禁止在违反当地法律法规的情况下使用本项目,对于使用者在明知或不知当地法律法规不允许的情况下使用本项目所造成的任何违法违规行为由使用者承担,本项目不承担由此造成的任何直接、间接、特殊、偶然或结果性责任。
|
||||
而且,本项目已禁止使用于商业用途,以及不得进行未经允许的二次修改,否则必须同时发布源代码。
|
||||
若你使用了本项目,将代表你接受以上协议。
|
||||
|
||||
Steam正版平台不易,请尊重版权,支持正版。
|
||||
本项目仅用于对技术可行性的探索及研究,不接受任何商业(包括但不限于广告等)合作。
|
||||
|
||||
## Star 趋势图
|
||||
|
||||
[](https://starchart.cc/ikunshare/Onekey)
|
||||
[](https://starchart.cc/ikunshare/Onekey)
|
||||
|
||||
## 贡献者
|
||||
|
||||
@@ -62,9 +58,13 @@ pip install -r requirements.txt
|
||||
</a>
|
||||
|
||||
## 常见问题解答(FAQ)
|
||||
查看 [FAQ](https://ikunshare.com/d/49) 获取常见问题的解答。
|
||||
|
||||
查看 [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)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import time
|
||||
import ujson as json
|
||||
from aiohttp import ClientError, ConnectionTimeoutError
|
||||
from .log import log
|
||||
from .stack_error import stack_error
|
||||
|
||||
|
||||
async def check_github_api_rate_limit(headers, session):
|
||||
url = 'https://api.github.com/rate_limit'
|
||||
|
||||
try:
|
||||
async with session.get(url, headers=headers, ssl=False) as r:
|
||||
r_json = json.loads(await r.read())
|
||||
|
||||
if r.status == 200:
|
||||
rate_limit = r_json.get('rate', {})
|
||||
remaining_requests = rate_limit.get('remaining', 0)
|
||||
reset_time = rate_limit.get('reset', 0)
|
||||
reset_time_formatted = time.strftime(
|
||||
'%Y-%m-%d %H:%M:%S', time.localtime(reset_time))
|
||||
|
||||
log.info(f'剩余请求次数: {remaining_requests}')
|
||||
|
||||
if remaining_requests == 0:
|
||||
log.warning(f'GitHub API 请求数已用尽, 将在 {
|
||||
reset_time_formatted} 重置,建议生成一个填在配置文件里')
|
||||
else:
|
||||
log.error('Github请求数检查失败, 网络错误')
|
||||
|
||||
except KeyboardInterrupt:
|
||||
log.info("程序已退出")
|
||||
except ClientError as e:
|
||||
log.error(f'检查Github API 请求数失败, {stack_error(e)}')
|
||||
except ConnectionTimeoutError as e:
|
||||
log.error(f'检查Github API 请求数超时: {stack_error(e)}')
|
||||
except Exception as e:
|
||||
log.error(f'发生错误: {stack_error(e)}')
|
||||
@@ -1,28 +0,0 @@
|
||||
import os
|
||||
import aiohttp
|
||||
import ujson as json
|
||||
from .log import log
|
||||
from .stack_error import stack_error
|
||||
|
||||
|
||||
async def checkcn(client) -> bool:
|
||||
try:
|
||||
req = await client.get('https://mips.kugou.com/check/iscn?&format=json')
|
||||
body = json.loads(await req.read())
|
||||
scn = bool(body['flag'])
|
||||
if not scn:
|
||||
log.info(
|
||||
f"您在非中国大陆地区({body['country']})上使用了项目, 已自动切换回Github官方下载CDN")
|
||||
os.environ['IS_CN'] = 'no'
|
||||
return False
|
||||
else:
|
||||
os.environ['IS_CN'] = 'yes'
|
||||
return True
|
||||
|
||||
except KeyboardInterrupt:
|
||||
log.info("程序已退出")
|
||||
except aiohttp.ClientError as e:
|
||||
os.environ['IS_CN'] = 'yes'
|
||||
log.warning('检查服务器位置失败,已忽略,自动认为你在中国大陆')
|
||||
log.warning(stack_error(e))
|
||||
return False
|
||||
@@ -1,48 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import ujson as json
|
||||
import aiofiles
|
||||
from .stack_error import stack_error
|
||||
from .log import log
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"Github_Personal_Token": "",
|
||||
"Custom_Steam_Path": "",
|
||||
"QA1": "温馨提示: Github_Personal_Token可在Github设置的最底下开发者选项找到,详情看教程",
|
||||
"教程": "https://ikunshare.com/Onekey_tutorial"
|
||||
}
|
||||
|
||||
|
||||
async def gen_config_file():
|
||||
try:
|
||||
async with aiofiles.open("./config.json", mode="w", encoding="utf-8") as f:
|
||||
await f.write(json.dumps(DEFAULT_CONFIG, indent=2, ensure_ascii=False, escape_forward_slashes=False))
|
||||
|
||||
log.info('程序可能为第一次启动或配置重置,请填写配置文件后重新启动程序')
|
||||
except KeyboardInterrupt:
|
||||
log.info("程序已退出")
|
||||
except Exception as e:
|
||||
log.error(f'配置文件生成失败,{stack_error(e)}')
|
||||
|
||||
|
||||
async def load_config():
|
||||
if not os.path.exists('./config.json'):
|
||||
await gen_config_file()
|
||||
os.system('pause')
|
||||
sys.exit()
|
||||
|
||||
try:
|
||||
async with aiofiles.open("./config.json", mode="r", encoding="utf-8") as f:
|
||||
config = json.loads(await f.read())
|
||||
return config
|
||||
except KeyboardInterrupt:
|
||||
log.info("程序已退出")
|
||||
except Exception as e:
|
||||
log.error(f"配置文件加载失败,原因: {stack_error(e)},重置配置文件中...")
|
||||
os.remove("./config.json")
|
||||
await gen_config_file()
|
||||
os.system('pause')
|
||||
sys.exit()
|
||||
|
||||
config = asyncio.run(load_config())
|
||||
@@ -1,44 +0,0 @@
|
||||
import asyncio
|
||||
import aiofiles
|
||||
import vdf
|
||||
from pathlib import Path
|
||||
from .log import log
|
||||
|
||||
lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def depotkey_merge(config_path: Path, depots_config: dict) -> bool:
|
||||
if not config_path.exists():
|
||||
async with lock:
|
||||
log.error('Steam默认配置不存在, 可能是没有登录账号')
|
||||
return False
|
||||
|
||||
try:
|
||||
async with aiofiles.open(config_path, encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
|
||||
config = vdf.loads(content)
|
||||
steam = config.get('InstallConfigStore', {}).get('Software', {}).get('Valve') or \
|
||||
config.get('InstallConfigStore', {}).get(
|
||||
'Software', {}).get('valve')
|
||||
|
||||
if steam is None:
|
||||
log.error('找不到Steam配置, 请检查配置文件')
|
||||
return False
|
||||
|
||||
depots = steam.setdefault('depots', {})
|
||||
depots.update(depots_config.get('depots', {}))
|
||||
|
||||
async with aiofiles.open(config_path, mode='w', encoding='utf-8') as f:
|
||||
new_context = vdf.dumps(config, pretty=True)
|
||||
await f.write(new_context)
|
||||
|
||||
log.info('成功合并')
|
||||
return True
|
||||
|
||||
except KeyboardInterrupt:
|
||||
log.info("程序已退出")
|
||||
except Exception as e:
|
||||
async with lock:
|
||||
log.error(f'合并失败, 原因: {e}')
|
||||
return False
|
||||
54
common/dl.py
54
common/dl.py
@@ -1,54 +0,0 @@
|
||||
import os
|
||||
from aiohttp import ClientError, ConnectionTimeoutError
|
||||
from rich.progress import Progress, BarColumn, TextColumn, TimeElapsedColumn
|
||||
from .log import log
|
||||
|
||||
async def get(sha: str, path: str, repo: str, session, chunk_size: int = 1024) -> bytearray:
|
||||
if os.environ.get('IS_CN') == 'yes':
|
||||
url_list = [
|
||||
f'https://jsdelivr.pai233.top/gh/{repo}@{sha}/{path}',
|
||||
f'https://cdn.jsdmirror.com/gh/{repo}@{sha}/{path}',
|
||||
f'https://raw.gitmirror.com/{repo}/{sha}/{path}',
|
||||
f'https://raw.dgithub.xyz/{repo}/{sha}/{path}',
|
||||
f'https://gh.akass.cn/{repo}/{sha}/{path}'
|
||||
]
|
||||
else:
|
||||
url_list = [
|
||||
f'https://raw.githubusercontent.com/{repo}/{sha}/{path}'
|
||||
]
|
||||
retry = 3
|
||||
while retry > 0:
|
||||
for url in url_list:
|
||||
try:
|
||||
async with session.get(url, ssl=False) as response:
|
||||
if response.status == 200:
|
||||
total_size = int(response.headers.get('Content-Length', 0))
|
||||
content = bytearray()
|
||||
|
||||
with Progress(
|
||||
TextColumn("[progress.description]{task.description}", style="#66CCFF"),
|
||||
BarColumn(style="#66CCFF", complete_style="#4CE49F", finished_style="#2FE9D9"),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%", style="#66CCFF"),
|
||||
TimeElapsedColumn(),
|
||||
) as progress:
|
||||
task = progress.add_task(f"下载{path}中...", total=total_size)
|
||||
|
||||
async for chunk in response.content.iter_chunked(chunk_size):
|
||||
content.extend(chunk)
|
||||
progress.update(task, advance=len(chunk))
|
||||
|
||||
return content
|
||||
else:
|
||||
log.error(f'获取失败: {path} - 状态码: {response.status}')
|
||||
except KeyboardInterrupt:
|
||||
log.info("程序已退出")
|
||||
except ClientError as e:
|
||||
log.error(f'获取失败: {path} - 连接错误: {str(e)}')
|
||||
except ConnectionTimeoutError as e:
|
||||
log.error(f'连接超时: {url} - 错误: {str(e)}')
|
||||
|
||||
retry -= 1
|
||||
log.warning(f'重试剩余次数: {retry} - {path}')
|
||||
|
||||
log.error(f'超过最大重试次数: {path}')
|
||||
raise Exception(f'无法下载: {path}')
|
||||
@@ -1,45 +0,0 @@
|
||||
from pathlib import Path
|
||||
import aiofiles
|
||||
import vdf
|
||||
|
||||
from .log import log
|
||||
from .dl import get
|
||||
from .stack_error import stack_error
|
||||
|
||||
|
||||
async def get_manifest(sha: str, path: str, steam_path: Path, repo: str, session) -> list:
|
||||
collected_depots = []
|
||||
depot_cache_path = steam_path / 'depotcache'
|
||||
|
||||
try:
|
||||
depot_cache_path.mkdir(exist_ok=True)
|
||||
|
||||
if path.endswith('.manifest'):
|
||||
save_path = depot_cache_path / path
|
||||
if save_path.exists():
|
||||
log.warning(f'已存在清单: {save_path}')
|
||||
return collected_depots
|
||||
|
||||
content = await get(sha, path, repo, session)
|
||||
log.info(f'清单下载成功: {path}')
|
||||
|
||||
async with aiofiles.open(save_path, 'wb') as f:
|
||||
await f.write(content)
|
||||
|
||||
elif path == 'Key.vdf':
|
||||
content = await get(sha, path, repo, session)
|
||||
log.info(f'密钥下载成功: {path}')
|
||||
|
||||
depots_config = vdf.loads(content.decode('utf-8'))
|
||||
collected_depots = [
|
||||
(depot_id, depot_info['DecryptionKey'])
|
||||
for depot_id, depot_info in depots_config['depots'].items()
|
||||
]
|
||||
|
||||
except KeyboardInterrupt:
|
||||
log.info("程序已退出")
|
||||
except Exception as e:
|
||||
log.error(f'处理失败: {path} - {stack_error(e)}')
|
||||
raise
|
||||
|
||||
return collected_depots
|
||||
@@ -1,24 +0,0 @@
|
||||
import os
|
||||
import winreg
|
||||
from pathlib import Path
|
||||
from .log import log
|
||||
from .config import config
|
||||
from .stack_error import stack_error
|
||||
|
||||
|
||||
def get_steam_path() -> Path:
|
||||
try:
|
||||
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r'Software\Valve\Steam')
|
||||
steam_path = Path(winreg.QueryValueEx(key, 'SteamPath')[0])
|
||||
|
||||
custom_steam_path = config.get("Custom_Steam_Path", "").strip()
|
||||
return Path(custom_steam_path) if custom_steam_path else steam_path
|
||||
except KeyboardInterrupt:
|
||||
log.info("程序已退出")
|
||||
except Exception as e:
|
||||
log.error(f'Steam路径获取失败, {stack_error(e)}, 请检查是否正确安装Steam')
|
||||
os.system('pause')
|
||||
return Path()
|
||||
|
||||
|
||||
steam_path = get_steam_path()
|
||||
@@ -1,21 +0,0 @@
|
||||
from .log import log
|
||||
|
||||
def init():
|
||||
banner_lines = [
|
||||
f" _____ __ _ _____ _ _ _____ __ __ ",
|
||||
f" / _ \\ | \\ | | | ____| | | / / | ____| \\ \\ / /",
|
||||
f" | | | | | \\| | | |__ | |/ / | |__ \\ \\/ /",
|
||||
f" | | | | | |\\ | | __| | |\\ \\ | __| \\ / ",
|
||||
f" | |_| | | | \\ | | |___ | | \\ \\ | |___ / /",
|
||||
f" \\_____/ |_| \\_| |_____| |_| \\_\\ |_____| /_/",
|
||||
]
|
||||
for line in banner_lines:
|
||||
log.info(line)
|
||||
|
||||
log.info(f'作者: ikun0014')
|
||||
log.warning(f'本项目采用GNU General Public License v3开源许可证,请勿用于商业用途')
|
||||
log.info(f'版本: 1.3.3')
|
||||
log.info(f'项目Github仓库: https://github.com/ikunshare/Onekey \n Gitee: https://gitee.com/ikun0014/Onekey')
|
||||
log.info(f'官网: ikunshare.com')
|
||||
log.warning(
|
||||
f'本项目完全开源免费, 如果你在淘宝, QQ群内通过购买方式获得, 赶紧回去骂商家死全家\n 交流群组:\n https://t.me/ikunshare_qun')
|
||||
@@ -1,30 +0,0 @@
|
||||
import logging
|
||||
import colorlog
|
||||
|
||||
LOG_FORMAT = '%(log_color)s%(message)s'
|
||||
LOG_COLORS = {
|
||||
'INFO': 'cyan',
|
||||
'WARNING': 'yellow',
|
||||
'ERROR': 'red',
|
||||
'CRITICAL': 'purple',
|
||||
}
|
||||
|
||||
|
||||
def init_log(level=logging.DEBUG) -> logging.Logger:
|
||||
logger = logging.getLogger('Onekey')
|
||||
logger.setLevel(level)
|
||||
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setLevel(level)
|
||||
|
||||
fmt = colorlog.ColoredFormatter(LOG_FORMAT, log_colors=LOG_COLORS)
|
||||
stream_handler.setFormatter(fmt)
|
||||
|
||||
# 避免重复添加处理器
|
||||
if not logger.handlers:
|
||||
logger.addHandler(stream_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
log = init_log()
|
||||
@@ -1,109 +0,0 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientSession, ConnectionTimeoutError
|
||||
from common.checkcn import checkcn
|
||||
from common.config import config
|
||||
from common.dkey_merge import depotkey_merge
|
||||
from common.migration import migrate
|
||||
from common.unlock import stool_add, greenluma_add
|
||||
from common.get_manifest_info import get_manifest
|
||||
from common.check import check_github_api_rate_limit
|
||||
from common.log import log
|
||||
from common.get_steam_path import steam_path
|
||||
from common.stack_error import stack_error
|
||||
|
||||
isGreenLuma = any((steam_path / dll).exists()
|
||||
for dll in ['GreenLuma_2024_x86.dll', 'GreenLuma_2024_x64.dll', 'User32.dll'])
|
||||
isSteamTools = (steam_path / 'config' / 'stUI').is_dir()
|
||||
|
||||
|
||||
async def fetch_branch_info(session, url, headers) -> str | None:
|
||||
try:
|
||||
async with session.get(url, headers=headers, ssl=False) as response:
|
||||
return await response.json()
|
||||
except KeyboardInterrupt:
|
||||
log.info("程序已退出")
|
||||
except Exception as e:
|
||||
log.error(f'获取信息失败: {stack_error(e)}')
|
||||
return None
|
||||
except ConnectionTimeoutError as e:
|
||||
log.error(f'获取信息时超时: {stack_error(e)}')
|
||||
return None
|
||||
|
||||
|
||||
async def get_latest_repo_info(session, repos, app_id, headers) -> Any | None:
|
||||
latest_date = None
|
||||
selected_repo = None
|
||||
|
||||
for repo in repos:
|
||||
url = f'https://api.github.com/repos/{repo}/branches/{app_id}'
|
||||
r_json = await fetch_branch_info(session, url, headers)
|
||||
if r_json and 'commit' in r_json:
|
||||
date = r_json['commit']['commit']['author']['date']
|
||||
if latest_date is None or date > latest_date:
|
||||
latest_date = date
|
||||
selected_repo = repo
|
||||
|
||||
return selected_repo, latest_date
|
||||
|
||||
|
||||
async def main(app_id: str, repos: list) -> bool:
|
||||
app_id_list = list(filter(str.isdecimal, app_id.strip().split('-')))
|
||||
|
||||
if not app_id_list:
|
||||
log.error(f'App ID无效')
|
||||
return False
|
||||
|
||||
app_id = app_id_list[0]
|
||||
|
||||
async with ClientSession() as session:
|
||||
github_token = config.get("Github_Personal_Token", "")
|
||||
headers = {'Authorization': f'Bearer {
|
||||
github_token}'} if github_token else None
|
||||
|
||||
await checkcn(session)
|
||||
await check_github_api_rate_limit(headers, session)
|
||||
|
||||
selected_repo, latest_date = await get_latest_repo_info(session, repos, app_id, headers)
|
||||
|
||||
if selected_repo:
|
||||
log.info(f'选择清单仓库: {selected_repo}')
|
||||
url = f'https://api.github.com/repos/{
|
||||
selected_repo}/branches/{app_id}'
|
||||
r_json = await fetch_branch_info(session, url, headers)
|
||||
|
||||
if r_json and 'commit' in r_json:
|
||||
sha = r_json['commit']['sha']
|
||||
url = r_json['commit']['commit']['tree']['url']
|
||||
r2_json = await fetch_branch_info(session, url, headers)
|
||||
|
||||
if r2_json and 'tree' in r2_json:
|
||||
collected_depots = []
|
||||
for item in r2_json['tree']:
|
||||
result = await get_manifest(sha, item['path'], steam_path, selected_repo, session)
|
||||
collected_depots.extend(result)
|
||||
|
||||
if collected_depots:
|
||||
if isSteamTools:
|
||||
await migrate(st_use=True, session=session)
|
||||
await stool_add(collected_depots, app_id)
|
||||
log.info('找到SteamTools, 已添加解锁文件')
|
||||
|
||||
if isGreenLuma:
|
||||
await migrate(st_use=False, session=session)
|
||||
await greenluma_add([app_id])
|
||||
depot_config = {'depots': {depot_id: {
|
||||
'DecryptionKey': depot_key} for depot_id, depot_key in collected_depots}}
|
||||
await depotkey_merge(steam_path / 'config' / 'config.vdf', depot_config)
|
||||
if await greenluma_add([int(i) for i in depot_config['depots'] if i.isdecimal()]):
|
||||
log.info('找到GreenLuma, 已添加解锁文件')
|
||||
|
||||
log.info(f'清单最后更新时间: {latest_date}')
|
||||
log.info(f'入库成功: {app_id}')
|
||||
os.system('pause')
|
||||
return True
|
||||
|
||||
log.error(f'清单下载或生成失败: {app_id}')
|
||||
os.system('pause')
|
||||
return False
|
||||
@@ -1,73 +0,0 @@
|
||||
import subprocess
|
||||
import aiofiles
|
||||
from aiohttp import ConnectionTimeoutError
|
||||
from pathlib import Path
|
||||
from rich.progress import Progress, BarColumn, TextColumn, TimeElapsedColumn
|
||||
from .log import log
|
||||
from .get_steam_path import steam_path
|
||||
|
||||
directory = Path(steam_path) / "config" / "stplug-in"
|
||||
temp_path = Path('./temp')
|
||||
setup_url = 'https://steamtools.net/res/SteamtoolsSetup.exe'
|
||||
setup_file = temp_path / 'SteamtoolsSetup.exe'
|
||||
|
||||
|
||||
async def download_setup_file(session) -> None:
|
||||
log.info('开始下载 SteamTools 安装程序...')
|
||||
try:
|
||||
async with session.get(setup_url, stream=True) as r:
|
||||
if r.status == 200:
|
||||
total_size = int(r.headers.get('Content-Length', 0))
|
||||
chunk_size = 8192
|
||||
with Progress(
|
||||
TextColumn("[progress.description]{task.description}", style="#66CCFF"),
|
||||
BarColumn(style="#66CCFF", complete_style="#4CE49F", finished_style="#2FE9D9"),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%", style="#66CCFF"),
|
||||
TimeElapsedColumn(),
|
||||
) as progress:
|
||||
|
||||
task = progress.add_task(f"下载安装程序中...", total=total_size)
|
||||
|
||||
async with aiofiles.open(setup_file, mode='wb') as f:
|
||||
async for chunk in r.content.iter_chunked(chunk_size):
|
||||
await f.write(chunk)
|
||||
progress.update(task, advance=len(chunk))
|
||||
|
||||
progress.close()
|
||||
log.info('安装程序下载完成')
|
||||
else:
|
||||
log.error('网络错误,无法下载安装程序')
|
||||
except KeyboardInterrupt:
|
||||
log.info("程序已退出")
|
||||
except Exception as e:
|
||||
log.error(f'下载失败: {e}')
|
||||
except ConnectionTimeoutError as e:
|
||||
log.error(f'下载时超时: {e}')
|
||||
|
||||
|
||||
async def migrate(st_use: bool, session) -> None:
|
||||
if st_use:
|
||||
log.info('检测到你正在使用 SteamTools,尝试迁移旧文件')
|
||||
|
||||
if directory.exists():
|
||||
for file in directory.iterdir():
|
||||
if file.is_file() and file.name.startswith("Onekey_unlock_"):
|
||||
new_filename = file.name[len("Onekey_unlock_"):]
|
||||
|
||||
try:
|
||||
file.rename(directory / new_filename)
|
||||
log.info(f'Renamed: {file.name} -> {new_filename}')
|
||||
except Exception as e:
|
||||
log.error(f'重命名失败 {file.name} -> {new_filename}: {e}')
|
||||
else:
|
||||
log.error('故障,正在重新安装 SteamTools')
|
||||
temp_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
await download_setup_file(session)
|
||||
|
||||
subprocess.run(str(setup_file), check=True)
|
||||
for file in temp_path.iterdir():
|
||||
file.unlink()
|
||||
temp_path.rmdir()
|
||||
else:
|
||||
log.info('未使用 SteamTools,停止迁移')
|
||||
@@ -1,7 +0,0 @@
|
||||
import traceback
|
||||
|
||||
|
||||
def stack_error(exception: Exception) -> str:
|
||||
stack_trace = traceback.format_exception(
|
||||
type(exception), exception, exception.__traceback__)
|
||||
return ''.join(stack_trace)
|
||||
@@ -1,76 +0,0 @@
|
||||
import os
|
||||
import asyncio
|
||||
import subprocess
|
||||
import aiofiles
|
||||
from .get_steam_path import steam_path
|
||||
from .log import log
|
||||
|
||||
lock = asyncio.Lock()
|
||||
|
||||
async def stool_add(depot_data: list, app_id: str) -> bool:
|
||||
lua_filename = f"{app_id}.lua"
|
||||
lua_filepath = steam_path / "config" / "stplug-in" / lua_filename
|
||||
|
||||
async with lock:
|
||||
log.info(f'SteamTools 解锁文件生成: {lua_filepath}')
|
||||
try:
|
||||
async with aiofiles.open(lua_filepath, mode="w", encoding="utf-8") as lua_file:
|
||||
await lua_file.write(f'addappid({app_id}, 1, "None")\n')
|
||||
for depot_id, depot_key in depot_data:
|
||||
await lua_file.write(f'addappid({depot_id}, 1, "{depot_key}")\n')
|
||||
|
||||
luapacka_path = steam_path / "config" / "stplug-in" / "luapacka.exe"
|
||||
log.info(f'正在处理文件: {lua_filepath}')
|
||||
|
||||
result = subprocess.run(
|
||||
[str(luapacka_path), str(lua_filepath)],
|
||||
capture_output=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log.error(f'调用失败: {result.stderr.decode()}')
|
||||
return False
|
||||
|
||||
log.info('处理完成')
|
||||
except KeyboardInterrupt:
|
||||
log.info("程序已退出")
|
||||
except Exception as e:
|
||||
log.error(f'处理过程出现错误: {e}')
|
||||
return False
|
||||
finally:
|
||||
if lua_filepath.exists():
|
||||
os.remove(lua_filepath)
|
||||
log.info(f'删除临时文件: {lua_filepath}')
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def greenluma_add(depot_id_list: list) -> bool:
|
||||
app_list_path = steam_path / 'AppList'
|
||||
|
||||
try:
|
||||
app_list_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for file in app_list_path.glob('*.txt'):
|
||||
file.unlink(missing_ok=True)
|
||||
|
||||
depot_dict = {
|
||||
int(i.stem): int(i.read_text(encoding='utf-8').strip())
|
||||
for i in app_list_path.iterdir() if i.is_file() and i.stem.isdecimal() and i.suffix == '.txt'
|
||||
}
|
||||
|
||||
for depot_id in map(int, depot_id_list):
|
||||
if depot_id not in depot_dict.values():
|
||||
index = max(depot_dict.keys(), default=-1) + 1
|
||||
while index in depot_dict:
|
||||
index += 1
|
||||
|
||||
(app_list_path /
|
||||
f'{index}.txt').write_text(str(depot_id), encoding='utf-8')
|
||||
|
||||
depot_dict[index] = depot_id
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f'处理时出错: {e}')
|
||||
return False
|
||||
207
main.py
207
main.py
@@ -1,65 +1,164 @@
|
||||
import sys
|
||||
import os
|
||||
import asyncio
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import webbrowser
|
||||
|
||||
from colorama import Fore, Back, Style
|
||||
from colorama import init as cinit
|
||||
from common.log import log
|
||||
from common.stack_error import stack_error
|
||||
from common.init_text import init
|
||||
from common.main_func import main
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
# 摘自https://github.com/MeoProject/lx-music-api-server
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
from src.config import ConfigManager
|
||||
from src.utils.i18n import t
|
||||
|
||||
lock = asyncio.Lock()
|
||||
|
||||
init()
|
||||
cinit()
|
||||
|
||||
repos = [
|
||||
'ikun0014/ManifestHub',
|
||||
'Auiowu/ManifestAutoUpdate',
|
||||
'tymolu233/ManifestAutoUpdate',
|
||||
]
|
||||
project_root = Path(__file__)
|
||||
config_manager = ConfigManager()
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
|
||||
def prompt_app_id():
|
||||
while True:
|
||||
app_id = input(f"{Fore.CYAN}{Back.BLACK}{
|
||||
Style.BRIGHT}请输入游戏AppID: {Style.RESET_ALL}").strip()
|
||||
if re.match(r'^\d+$', app_id):
|
||||
return app_id
|
||||
else:
|
||||
print(f"{Fore.RED}无效的AppID, 请输入数字!{Style.RESET_ALL}")
|
||||
|
||||
|
||||
async def main_loop():
|
||||
while True:
|
||||
try:
|
||||
app_id = prompt_app_id()
|
||||
await main(app_id, repos)
|
||||
except KeyboardInterrupt:
|
||||
log.info("程序已退出")
|
||||
except EOFError:
|
||||
break
|
||||
|
||||
|
||||
async def run():
|
||||
def hide_console() -> None:
|
||||
"""隐藏控制台窗口"""
|
||||
try:
|
||||
log.info('App ID可以在SteamDB或Steam商店链接页面查看')
|
||||
await main_loop()
|
||||
except KeyboardInterrupt:
|
||||
log.info("程序已退出")
|
||||
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:
|
||||
log.error(f'发生错误: {stack_error(e)}, 将在5秒后退出')
|
||||
await asyncio.sleep(5)
|
||||
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))
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
def create_system_tray() -> bool:
|
||||
"""创建系统托盘"""
|
||||
try:
|
||||
asyncio.run(run())
|
||||
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:
|
||||
log.info("程序已退出")
|
||||
except SystemExit:
|
||||
sys.exit()
|
||||
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__":
|
||||
main()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "onekey",
|
||||
"version": "1.3.3",
|
||||
"version": "2.1.1",
|
||||
"description": "一个Steam仓库清单下载器",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -14,7 +14,7 @@
|
||||
"Onekey"
|
||||
],
|
||||
"author": "ikun0014",
|
||||
"license": "GPL-3.0",
|
||||
"license": "null",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ikunshare/Onekey/issues"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
aiofiles==24.1.0
|
||||
aiohttp==3.10.10
|
||||
colorama==0.4.6
|
||||
colorlog==6.8.2
|
||||
rich==13.9.4
|
||||
ujson==5.10.0
|
||||
vdf==3.4
|
||||
vdf
|
||||
httpx
|
||||
Pillow
|
||||
pystray
|
||||
uvicorn
|
||||
logzero
|
||||
colorama
|
||||
fastapi[all]
|
||||
steam[client]
|
||||
113
src/config.py
Normal file
113
src/config.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import os
|
||||
import sys
|
||||
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 = {
|
||||
"KEY": "",
|
||||
"Port": 5000,
|
||||
"Debug_Mode": False,
|
||||
"Logging_Files": True,
|
||||
"Show_Console": False,
|
||||
"Custom_Steam_Path": "",
|
||||
"Language": "zh",
|
||||
}
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""配置管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.config_path = CONFIG_FILE
|
||||
self._config_data: Dict = {}
|
||||
self.app_config: AppConfig = AppConfig()
|
||||
self.steam_path: Optional[Path] = None
|
||||
self._load_config()
|
||||
|
||||
def _generate_config(self) -> None:
|
||||
"""生成默认配置文件"""
|
||||
try:
|
||||
with open(self.config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(DEFAULT_CONFIG, f, indent=2, ensure_ascii=False)
|
||||
print(t("config.generated"))
|
||||
os.system("pause")
|
||||
sys.exit(1)
|
||||
except IOError as 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()
|
||||
|
||||
try:
|
||||
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||
self._config_data = json.load(f)
|
||||
|
||||
self.app_config = AppConfig(
|
||||
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(t("config.corrupted"))
|
||||
self._generate_config()
|
||||
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(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) -> Optional[Path]:
|
||||
"""获取Steam安装路径"""
|
||||
try:
|
||||
if self.app_config.custom_steam_path:
|
||||
return Path(self.app_config.custom_steam_path)
|
||||
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER, r"Software\Valve\Steam"
|
||||
) as key:
|
||||
return Path(winreg.QueryValueEx(key, "SteamPath")[0])
|
||||
except Exception as e:
|
||||
print(t("config.steam_path_failed", error=str(e)))
|
||||
print(t("config.continue_partial"))
|
||||
return None
|
||||
50
src/constants.py
Normal file
50
src/constants.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""常量定义"""
|
||||
|
||||
from pathlib import Path
|
||||
from httpx import Client
|
||||
|
||||
LOG_DIR: Path = Path("logs")
|
||||
CONFIG_FILE: Path = Path("config.json")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
IS_CN: bool = check_ip()
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
)
|
||||
67
src/logger.py
Normal file
67
src/logger.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""日志模块"""
|
||||
|
||||
import logging
|
||||
import colorama
|
||||
import logzero
|
||||
from logzero import setup_logger, LogFormatter
|
||||
|
||||
from .constants import LOG_DIR
|
||||
|
||||
|
||||
class Logger:
|
||||
"""统一的日志管理器"""
|
||||
|
||||
def __init__(self, name: str, debug_mode: bool = False, log_file: bool = True):
|
||||
self.name = name
|
||||
self.debug_mode = debug_mode
|
||||
self.log_file = log_file
|
||||
self._logger = self._setup_logger()
|
||||
|
||||
def _setup_logger(self) -> logging.Logger:
|
||||
"""设置日志器"""
|
||||
level = logzero.DEBUG if self.debug_mode else logzero.INFO
|
||||
|
||||
colors = {
|
||||
logzero.DEBUG: colorama.Fore.CYAN,
|
||||
logzero.INFO: colorama.Fore.GREEN,
|
||||
logzero.WARNING: colorama.Fore.YELLOW,
|
||||
logzero.ERROR: colorama.Fore.RED,
|
||||
logzero.CRITICAL: colorama.Fore.MAGENTA,
|
||||
}
|
||||
|
||||
terminal_formatter = LogFormatter(
|
||||
color=True,
|
||||
fmt="%(color)s%(message)s%(end_color)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
colors=colors,
|
||||
)
|
||||
|
||||
logger = setup_logger(self.name, level=level, formatter=terminal_formatter)
|
||||
|
||||
if self.log_file:
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
logfile = LOG_DIR / f"{self.name}.log"
|
||||
file_handler = logging.FileHandler(logfile, encoding="utf-8")
|
||||
file_formatter = logging.Formatter(
|
||||
"[%(asctime)s] | [%(name)s:%(levelname)s] | [%(module)s.%(funcName)s:%(lineno)d] - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
def debug(self, msg: str):
|
||||
self._logger.debug(msg)
|
||||
|
||||
def info(self, msg: str):
|
||||
self._logger.info(msg)
|
||||
|
||||
def warning(self, msg: str):
|
||||
self._logger.warning(msg)
|
||||
|
||||
def error(self, msg: str):
|
||||
self._logger.error(msg)
|
||||
|
||||
def critical(self, msg: str):
|
||||
self._logger.critical(msg)
|
||||
221
src/main.py
Normal file
221
src/main.py
Normal file
@@ -0,0 +1,221 @@
|
||||
from typing import List, Dict, Tuple
|
||||
from .constants import STEAM_API_BASE
|
||||
from .config import ConfigManager
|
||||
from .logger import Logger
|
||||
from .models import DepotInfo, ManifestInfo, SteamAppInfo, SteamAppManifestInfo
|
||||
from .network.client import HttpClient
|
||||
from .manifest_handler import ManifestHandler
|
||||
from .utils.i18n import t
|
||||
|
||||
|
||||
class OnekeyApp:
|
||||
"""Onekey主应用"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = ConfigManager()
|
||||
self.logger = Logger(
|
||||
"Onekey",
|
||||
debug_mode=self.config.app_config.debug_mode,
|
||||
log_file=self.config.app_config.logging_files,
|
||||
)
|
||||
self.client = HttpClient()
|
||||
|
||||
async def fetch_key(self) -> bool:
|
||||
"""获取并验证卡密信息"""
|
||||
try:
|
||||
response = await self.client._client.post(
|
||||
f"{STEAM_API_BASE}/getKeyInfo",
|
||||
json={"key": self.config.app_config.key},
|
||||
)
|
||||
body = response.json()
|
||||
|
||||
if not body["info"]:
|
||||
self.logger.error(t("api.key_not_exist"))
|
||||
return False
|
||||
|
||||
key_type = body["info"]["type"]
|
||||
self.logger.info(t("api.key_type", type=t(f"key_type.{key_type}")))
|
||||
|
||||
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
|
||||
|
||||
async def fetch_app_data(
|
||||
self, app_id: str, and_dlc: bool = True
|
||||
) -> Tuple[SteamAppInfo, SteamAppManifestInfo]:
|
||||
"""
|
||||
从API获取应用数据
|
||||
"""
|
||||
main_app_manifests = []
|
||||
dlc_manifests = []
|
||||
|
||||
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
|
||||
|
||||
tool = SteamTools(self.config.steam_path)
|
||||
success = await tool.setup(depot_data, app_info)
|
||||
elif tool_type == "greenluma":
|
||||
from .tools.greenluma import GreenLuma
|
||||
|
||||
tool = GreenLuma(self.config.steam_path)
|
||||
success = await tool.setup(depot_data, app_id)
|
||||
else:
|
||||
self.logger.error(t("tool.invalid_selection"))
|
||||
return False
|
||||
|
||||
if success:
|
||||
self.logger.info(t("tool.config_success"))
|
||||
self.logger.info(t("tool.restart_steam"))
|
||||
return True
|
||||
else:
|
||||
self.logger.error(t("tool.config_failed"))
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(t("task.run_error", error=str(e)))
|
||||
return False
|
||||
finally:
|
||||
await self.client.close()
|
||||
136
src/manifest_handler.py
Normal file
136
src/manifest_handler.py
Normal 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
|
||||
54
src/models.py
Normal file
54
src/models.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from typing import List
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class DepotInfo:
|
||||
"""仓库信息"""
|
||||
|
||||
depot_id: str
|
||||
decryption_key: str
|
||||
manifest_ids: List[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.manifest_ids is None:
|
||||
self.manifest_ids = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManifestInfo:
|
||||
"""清单信息"""
|
||||
|
||||
app_id: int
|
||||
depot_id: str
|
||||
depot_key: str
|
||||
manifest_id: str
|
||||
url: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SteamAppInfo:
|
||||
appId: int
|
||||
name: str
|
||||
dlcCount: int
|
||||
depotCount: int
|
||||
workshopDecryptionKey: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SteamAppManifestInfo:
|
||||
mainapp: List[ManifestInfo]
|
||||
dlcs: List[ManifestInfo]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
"""应用配置"""
|
||||
|
||||
key: str = ""
|
||||
port: int = 5000
|
||||
custom_steam_path: str = ""
|
||||
debug_mode: bool = False
|
||||
logging_files: bool = True
|
||||
show_console: bool = True
|
||||
language: str = "zh"
|
||||
25
src/network/client.py
Normal file
25
src/network/client.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""HTTP客户端模块"""
|
||||
|
||||
import httpx
|
||||
from typing import Optional, Dict
|
||||
|
||||
|
||||
class HttpClient:
|
||||
"""HTTP客户端封装"""
|
||||
|
||||
def __init__(self):
|
||||
self._client = httpx.AsyncClient(timeout=60.0)
|
||||
|
||||
async def get(self, url: str, headers: Optional[Dict] = None) -> httpx.Response:
|
||||
"""GET请求"""
|
||||
return await self._client.get(url, headers=headers)
|
||||
|
||||
async def close(self):
|
||||
"""关闭客户端"""
|
||||
await self._client.aclose()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.close()
|
||||
17
src/tools/base.py
Normal file
17
src/tools/base.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List
|
||||
from pathlib import Path
|
||||
|
||||
from ..models import DepotInfo
|
||||
|
||||
|
||||
class UnlockTool(ABC):
|
||||
"""解锁工具基类"""
|
||||
|
||||
def __init__(self, steam_path: Path):
|
||||
self.steam_path = steam_path
|
||||
|
||||
@abstractmethod
|
||||
async def setup(self, depot_data: List[DepotInfo], app_id: str, **kwargs) -> bool:
|
||||
"""设置解锁"""
|
||||
pass
|
||||
68
src/tools/greenluma.py
Normal file
68
src/tools/greenluma.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import vdf
|
||||
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:
|
||||
applist_dir = self.steam_path / "AppList"
|
||||
|
||||
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:
|
||||
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 as e:
|
||||
print(f"GreenLuma配置失败: {e}")
|
||||
return False
|
||||
42
src/tools/steamtools.py
Normal file
42
src/tools/steamtools.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
|
||||
from .base import UnlockTool
|
||||
from ..models import DepotInfo, SteamAppInfo
|
||||
|
||||
|
||||
class SteamTools(UnlockTool):
|
||||
"""SteamTools解锁工具实现"""
|
||||
|
||||
async def setup(
|
||||
self,
|
||||
depot_data: List[DepotInfo],
|
||||
app_info: SteamAppInfo,
|
||||
) -> bool:
|
||||
"""设置SteamTools解锁"""
|
||||
st_path = self.steam_path / "config" / "stplug-in"
|
||||
st_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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:
|
||||
lua_content += (
|
||||
f'addappid({depot.depot_id}, "1", "{depot.decryption_key}")\n'
|
||||
)
|
||||
|
||||
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
247
src/utils/i18n.py
Normal 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)
|
||||
17
src/utils/steam.py
Normal file
17
src/utils/steam.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
def parse_manifest_filename(filename: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""解析清单文件名"""
|
||||
if not filename.endswith(".manifest"):
|
||||
return None, None
|
||||
|
||||
name = filename.replace(".manifest", "")
|
||||
if "_" not in name:
|
||||
return None, None
|
||||
|
||||
parts = name.split("_", 1)
|
||||
if len(parts) != 2 or not all(p.isdigit() for p in parts):
|
||||
return None, None
|
||||
|
||||
return parts[0], parts[1]
|
||||
0
web/__init__.py
Normal file
0
web/__init__.py
Normal file
448
web/app.py
Normal file
448
web/app.py
Normal 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))
|
||||
277
web/en/static/css/animations.css
Normal file
277
web/en/static/css/animations.css
Normal 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
165
web/en/static/css/base.css
Normal 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;
|
||||
}
|
||||
459
web/en/static/css/components.css
Normal file
459
web/en/static/css/components.css
Normal 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;
|
||||
}
|
||||
}
|
||||
218
web/en/static/css/layout.css
Normal file
218
web/en/static/css/layout.css
Normal 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
230
web/en/static/css/oobe.css
Normal 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;
|
||||
}
|
||||
}
|
||||
314
web/en/static/css/project-info.css
Normal file
314
web/en/static/css/project-info.css
Normal 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;
|
||||
}
|
||||
299
web/en/static/css/settings.css
Normal file
299
web/en/static/css/settings.css
Normal 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;
|
||||
}
|
||||
8
web/en/static/css/style.css
Normal file
8
web/en/static/css/style.css
Normal 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");
|
||||
222
web/en/static/css/utilities.css
Normal file
222
web/en/static/css/utilities.css
Normal 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);
|
||||
}
|
||||
}
|
||||
206
web/en/static/css/variables.css
Normal file
206
web/en/static/css/variables.css
Normal 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
685
web/en/static/js/app.js
Normal 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();
|
||||
});
|
||||
180
web/en/static/js/project-info.js
Normal file
180
web/en/static/js/project-info.js
Normal 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!",
|
||||
"⭐ Don’t 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();
|
||||
});
|
||||
677
web/en/static/js/settings.js
Normal file
677
web/en/static/js/settings.js
Normal 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
178
web/en/static/js/theme.js
Normal 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
172
web/en/templates/about.html
Normal 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
180
web/en/templates/index.html
Normal 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
501
web/en/templates/oobe.html
Normal 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>
|
||||
292
web/en/templates/settings.html
Normal file
292
web/en/templates/settings.html
Normal 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>
|
||||
277
web/zh/static/css/animations.css
Normal file
277
web/zh/static/css/animations.css
Normal 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
165
web/zh/static/css/base.css
Normal 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;
|
||||
}
|
||||
459
web/zh/static/css/components.css
Normal file
459
web/zh/static/css/components.css
Normal 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;
|
||||
}
|
||||
}
|
||||
218
web/zh/static/css/layout.css
Normal file
218
web/zh/static/css/layout.css
Normal 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
230
web/zh/static/css/oobe.css
Normal 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;
|
||||
}
|
||||
}
|
||||
314
web/zh/static/css/project-info.css
Normal file
314
web/zh/static/css/project-info.css
Normal 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;
|
||||
}
|
||||
299
web/zh/static/css/settings.css
Normal file
299
web/zh/static/css/settings.css
Normal 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;
|
||||
}
|
||||
8
web/zh/static/css/style.css
Normal file
8
web/zh/static/css/style.css
Normal 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");
|
||||
222
web/zh/static/css/utilities.css
Normal file
222
web/zh/static/css/utilities.css
Normal 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);
|
||||
}
|
||||
}
|
||||
206
web/zh/static/css/variables.css
Normal file
206
web/zh/static/css/variables.css
Normal 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
682
web/zh/static/js/app.js
Normal 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();
|
||||
});
|
||||
180
web/zh/static/js/project-info.js
Normal file
180
web/zh/static/js/project-info.js
Normal 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();
|
||||
});
|
||||
641
web/zh/static/js/settings.js
Normal file
641
web/zh/static/js/settings.js
Normal 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
178
web/zh/static/js/theme.js
Normal 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
169
web/zh/templates/about.html
Normal 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
175
web/zh/templates/index.html
Normal 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
475
web/zh/templates/oobe.html
Normal 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>
|
||||
289
web/zh/templates/settings.html
Normal file
289
web/zh/templates/settings.html
Normal 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>
|
||||
Reference in New Issue
Block a user