Update 2.0.1

This commit is contained in:
本间白猫 2025-03-03 11:13:52 +08:00
parent 91ffaa9fcf
commit 0a1df0dd25
31 changed files with 4546 additions and 1380 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 691 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -165,15 +165,11 @@ neko=123456
![Clip_2025-02-21_16-23-35](./Operation%20Manual.assets/Clip_2025-02-21_16-23-35.png)
![Clip_2025-02-21_16-32-14](./Operation%20Manual.assets/Clip_2025-02-21_16-32-14.png)
![Clip_2025-03-03_10-54-38](./Operation%20Manual.assets/Clip_2025-03-03_10-54-38.png)
![Clip_2025-02-21_16-28-30](./Operation%20Manual.assets/Clip_2025-02-21_16-28-30-1740126513899-10.png)
![Clip_2025-03-03_10-55-05](./Operation%20Manual.assets/Clip_2025-03-03_10-55-05.png)
![Clip_2025-02-21_16-29-21](./Operation%20Manual.assets/Clip_2025-02-21_16-29-21.png)
![Clip_2025-02-21_16-29-45](./Operation%20Manual.assets/Clip_2025-02-21_16-29-45.png)
![Clip_2025-02-21_16-30-51](./Operation%20Manual.assets/Clip_2025-02-21_16-30-51.png)
![Clip_2025-03-03_10-55-37](./Operation%20Manual.assets/Clip_2025-03-03_10-55-37.png)
## 问题Q&A

View File

@ -1,3 +1,9 @@
### 2025/03/03
- 美化 Web管理界面
- 修复大量 BUG
- 添加更多处理小脚本辅助使用
### 2025/02/21
- 增加 Web 管理界面

View File

@ -11,13 +11,12 @@ from configparser import ConfigParser
init(autoreset=True)
log_format = '%(asctime)s - %(levelname)s - %(message)s'
formatter = ColoredFormatter(log_format)
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logging.basicConfig(level=logging.INFO, handlers=[console_handler])
def setup_logging():
log_format = '%(asctime)s - %(levelname)s - %(message)s'
formatter = ColoredFormatter(log_format)
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logging.basicConfig(level=logging.INFO, handlers=[console_handler])
def update_status(server):
def print_proxy_info():
@ -30,69 +29,20 @@ def update_status(server):
old_port = int(server.config.get('port', '1080'))
server.config.update(new_config)
server.port = int(new_config.get('port', '1080'))
server.mode = new_config.get('mode', 'cycle')
server.interval = int(new_config.get('interval', '300'))
server.language = new_config.get('language', 'cn')
server.use_getip = new_config.get('use_getip', 'False').lower() == 'true'
server.check_proxies = new_config.get('check_proxies', 'True').lower() == 'true'
server.username = new_config.get('username', '')
server.password = new_config.get('password', '')
server.proxy_username = new_config.get('proxy_username', '')
server.proxy_password = new_config.get('proxy_password', '')
server.auth_required = bool(server.username and server.password)
server.proxy_file = new_config.get('proxy_file', 'ip.txt')
server.whitelist_file = new_config.get('whitelist_file', '')
server.blacklist_file = new_config.get('blacklist_file', '')
server.ip_auth_priority = new_config.get('ip_auth_priority', 'whitelist')
server.whitelist = load_ip_list(new_config.get('whitelist_file', ''))
server.blacklist = load_ip_list(new_config.get('blacklist_file', ''))
server._update_config_values(new_config)
if old_use_getip != server.use_getip or old_mode != server.mode:
if server.use_getip:
server.proxies = []
server.proxy_cycle = None
server.current_proxy = None
logging.info(get_message('api_mode_notice', server.language))
else:
server.proxies = server._load_file_proxies()
if server.proxies:
server.proxy_cycle = cycle(server.proxies)
server.current_proxy = next(server.proxy_cycle)
if server.check_proxies:
asyncio.run(run_proxy_check(server))
if server.use_getip:
server.getip_url = new_config.get('getip_url', '')
server.last_switch_time = time.time()
nonlocal display_level
display_level = int(new_config.get('display_level', '1'))
if hasattr(server, 'progress_bar'):
if not is_docker:
server.progress_bar.close()
delattr(server, 'progress_bar')
if hasattr(server, 'last_update_time'):
delattr(server, 'last_update_time')
server._handle_mode_change()
if old_port != server.port:
logging.info(get_message('port_changed', server.language, old_port, server.port))
logging.info(get_message('config_updated', server.language))
display_level = int(server.config.get('display_level', '1'))
is_docker = os.path.exists('/.dockerenv')
config_file = 'config/config.ini'
ip_file = server.proxy_file
last_config_modified_time = os.path.getmtime(config_file) if os.path.exists(config_file) else 0
last_ip_modified_time = os.path.getmtime(ip_file) if os.path.exists(ip_file) else 0
display_level = int(server.config.get('display_level', '1'))
is_docker = os.path.exists('/.dockerenv')
while True:
try:
@ -109,12 +59,7 @@ def update_status(server):
current_ip_modified_time = os.path.getmtime(ip_file)
if current_ip_modified_time > last_ip_modified_time:
logging.info(get_message('proxy_file_changed', server.language))
server.proxies = server._load_file_proxies()
if server.proxies:
server.proxy_cycle = cycle(server.proxies)
server.current_proxy = next(server.proxy_cycle)
if server.check_proxies:
asyncio.run(run_proxy_check(server))
server._reload_proxies()
last_ip_modified_time = current_ip_modified_time
continue
@ -141,10 +86,6 @@ def update_status(server):
if not hasattr(server, 'last_proxy') or server.last_proxy != server.current_proxy:
print_proxy_info()
server.last_proxy = server.current_proxy
if display_level >= 2:
logging.info(get_message('proxy_switch_detail', server.language,
getattr(server, 'previous_proxy', 'None'),
server.current_proxy))
server.previous_proxy = server.current_proxy
total_time = int(server.interval)
@ -227,24 +168,44 @@ async def run_proxy_check(server):
class ProxyCat:
def __init__(self):
cpu_count = os.cpu_count() or 1
self.executor = ThreadPoolExecutor(
max_workers=min(32, (os.cpu_count() or 1) * 4),
thread_name_prefix="proxy_worker"
max_workers=min(32, cpu_count + 4),
thread_name_prefix="proxy_worker",
thread_name_format="proxy_worker_%d"
)
loop = asyncio.get_event_loop()
loop.set_default_executor(self.executor)
if hasattr(asyncio, 'WindowsSelectorEventLoopPolicy'):
if os.name == 'nt':
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
if hasattr(loop, 'set_task_factory'):
loop.set_task_factory(None)
socket.setdefaulttimeout(30)
if hasattr(socket, 'TCP_NODELAY'):
socket.TCP_NODELAY = True
if hasattr(socket, 'SO_KEEPALIVE'):
socket.SO_KEEPALIVE = True
self.running = True
if os.name != 'nt':
import resource
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
try:
resource.setrlimit(resource.RLIMIT_NOFILE, (hard, hard))
except ValueError:
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, soft))
soft, hard = resource.getrlimit(resource.RLIMIT_NPROC)
try:
resource.setrlimit(resource.RLIMIT_NPROC, (hard, hard))
except ValueError:
pass
self.running = True
self.tasks = set()
self.max_tasks = 20000
self.task_semaphore = asyncio.Semaphore(self.max_tasks)
signal.signal(signal.SIGINT, self.handle_shutdown)
signal.signal(signal.SIGTERM, self.handle_shutdown)
self.config = load_config('config/config.ini')
@ -256,6 +217,11 @@ class ProxyCat:
if config.has_section('Users'):
self.users = dict(config.items('Users'))
self.auth_required = bool(self.users)
self.proxy_pool = {}
self.max_connections = 1000
self.connection_timeout = 30
self.read_timeout = 60
async def start_server(self):
try:
@ -292,9 +258,9 @@ class ProxyCat:
return
await asyncio.get_event_loop().run_in_executor(
self.executor,
self.process_client_request,
reader,
self.executor,
self._handle_proxy_request,
reader,
writer
)
except Exception as e:
@ -305,6 +271,7 @@ class ProxyCat:
await writer.wait_closed()
except:
pass
self.tasks.remove(task)
def _authenticate(self, auth_header):
if not self.users:
@ -322,7 +289,115 @@ class ProxyCat:
except:
return False
async def _handle_proxy_request(self, reader, writer):
try:
request_line = await reader.readline()
if not request_line:
return
method, target, version = request_line.decode().strip().split(' ')
if method == 'CONNECT':
await self._handle_connect(target, reader, writer)
else:
await self._handle_http(method, target, version, reader, writer)
except Exception as e:
logging.error(get_message('request_handling_error', self.language, e))
try:
writer.close()
except:
pass
async def _handle_connect(self, target, reader, writer):
host, port = target.split(':')
port = int(port)
try:
remote_reader, remote_writer = await asyncio.wait_for(
asyncio.open_connection(host, port),
timeout=self.connection_timeout
)
writer.write(b'HTTP/1.1 200 Connection Established\r\n\r\n')
await writer.drain()
await self._create_pipe(reader, writer, remote_reader, remote_writer)
except Exception as e:
logging.error(get_message('proxy_forward_error', self.language, e))
writer.write(b'HTTP/1.1 502 Bad Gateway\r\n\r\n')
await writer.drain()
async def _handle_http(self, method, target, version, reader, writer):
try:
from urllib.parse import urlparse
url = urlparse(target)
host = url.hostname
port = url.port or 80
remote_reader, remote_writer = await asyncio.wait_for(
asyncio.open_connection(host, port),
timeout=self.connection_timeout
)
path = url.path + ('?' + url.query if url.query else '')
request = f'{method} {path} {version}\r\n'
remote_writer.write(request.encode())
while True:
line = await reader.readline()
if line == b'\r\n':
break
remote_writer.write(line)
remote_writer.write(b'\r\n')
await self._create_pipe(reader, writer, remote_reader, remote_writer)
except Exception as e:
logging.error(get_message('proxy_forward_error', self.language, e))
writer.write(b'HTTP/1.1 502 Bad Gateway\r\n\r\n')
await writer.drain()
async def _create_pipe(self, client_reader, client_writer, remote_reader, remote_writer):
try:
pipe1 = asyncio.create_task(self._pipe(client_reader, remote_writer))
pipe2 = asyncio.create_task(self._pipe(remote_reader, client_writer))
done, pending = await asyncio.wait(
[pipe1, pipe2],
return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
except Exception as e:
logging.error(get_message('data_transfer_error', self.language, e))
finally:
try:
remote_writer.close()
await remote_writer.wait_closed()
except:
pass
async def _pipe(self, reader, writer):
try:
while True:
data = await reader.read(8192)
if not data:
break
writer.write(data)
await writer.drain()
except asyncio.CancelledError:
pass
except Exception as e:
logging.error(get_message('data_transfer_error', self.language, e))
if __name__ == '__main__':
setup_logging()
parser = argparse.ArgumentParser(description=logos())
parser.add_argument('-c', '--config', default='config/config.ini', help='配置文件路径')
args = parser.parse_args()

View File

@ -11,18 +11,11 @@
- [Development Background](#development-background)
- [Features](#features)
- [Installation and Usage](#installation-and-usage)
- [Dependencies Installation](#dependencies-installation)
- [Running the Tool](#running-the-tool)
- [Manual Proxy Entry](#iptxt-manual-proxy-entry)
- [Configuration File](#configuration-file)
- [Demo Effect](#demo-effect)
- [Using API for Automatic Proxy Retrieval](#using-api-for-automatic-proxy-retrieval)
- [Docker Deployment](#docker-deployment)
- [Performance](#performance)
- [Disclaimer](#disclaimer)
- [Change Log](#change-log)
- [Changelog](#changelog)
- [Development Plan](#development-plan)
- [Acknowledgments](#acknowledgments)
- [Special Thanks](#special-thanks)
- [Sponsor](#sponsor)
- [Proxy Recommendations](#proxy-recommendations)
## Development Background
@ -35,269 +28,82 @@ Therefore, **ProxyCat** was born! This tool aims to transform short-term IPs (la
## Features
- **Dual Protocol Support**: Supports both SOCKS5 and HTTP protocol listening, compatible with more tools.
- **Multiple Proxy Protocols**: Supports HTTP/HTTPS/SOCKS5 proxy servers to meet various application needs.
- **Multiple Switching Modes**: Cycles through each proxy in the list sequentially; randomly selects available proxies to distribute traffic load and improve performance. Allows users to customize proxy selection logic for specific needs.
- **Function-based Proxy Retrieval**: Supports dynamic proxy retrieval through GetIP function for real-time availability.
- **Automatic Validity Detection**: Automatically detects proxy availability at startup to filter invalid proxies.
- **Switch Only During Proxy Forwarding**: Changes to new proxy server only when timer reaches zero and new requests arrive.
- **Proxy Failure Switching**: Automatically switches to new proxy if current proxy fails during traffic forwarding.
- **Proxy Pool Authentication**: Supports username/password-based authentication and blacklist/whitelist mechanisms.
- **Real-time Status Updates**: Displays current proxy status and next switch time.
- **Configurable File**: Easily adjust port, mode, authentication info via config.ini.
- **Version Detection**: Built-in version checking for automatic updates.
- **Dual Protocol Listening**: Supports HTTP/SOCKS5 protocol listening, compatible with more tools.
- **Triple Proxy Types**: Supports HTTP/HTTPS/SOCKS5 proxy servers with authentication.
- **Flexible Switching Modes**: Supports sequential, random, and custom proxy selection for optimized traffic distribution.
- **Dynamic Proxy Acquisition**: Get available proxies in real-time through GetIP function, supports API interface calls.
- **Proxy Protection**: When using GetIP method, proxies are only fetched upon receiving requests, not at initial startup.
- **Automatic Proxy Detection**: Automatically checks proxy validity at startup, removing invalid ones.
- **Smart Proxy Switching**: Only obtains new proxies during request execution, reducing resource consumption.
- **Invalid Proxy Handling**: Automatically validates and switches to new proxies when current ones fail.
- **Authentication Support**: Supports username/password authentication and IP blacklist/whitelist management.
- **Real-time Status Display**: Shows proxy status and switching times for dynamic monitoring.
- **Dynamic Configuration**: Updates configuration without service restart.
- **Web UI Interface**: Provides web management interface for convenient operation.
- **Docker Deployment**: One-click Docker deployment with unified web management.
- **Bilingual Support**: Supports Chinese and English language switching.
- **Flexible Configuration**: Customize ports, modes, and authentication through config.ini.
- **Version Check**: Automatic software update checking.
## Installation and Usage
## Tool Usage
### Dependencies Installation
[ProxyCat Operation Manual](../main/ProxyCat-Manual/Operation%20Manual.md)
The tool is based on Python, recommended version **Python 3.8** or above. Install dependencies using:
## Error Troubleshooting
```bash
pip install -r requirements.txt
# Or using Chinese mirror:
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/
```
### Running the Tool
Run the following command in the project directory to view help information:
```bash
python3 ProxyCat.py -h
```
Success is indicated by this response:
```
|\ _,,,---,,_ by honmashironeko
ZZZzz /,`.-'`' -. ;-;;,_
|,4- ) )-,_. ,\ ( `'-'
'---''(_/--' `-'\_) ProxyCat
Usage: ProxyCat.py [-h] [-c]
Parameters:
-h, --help Show this help message and exit
-c C Specify config file name (default: config.ini)
```
### ip.txt Manual Proxy Entry
Add proxies to `ip.txt` in the following format (`socks5://127.0.0.1:7890` or `http://127.0.0.1:7890`), one per line:
```txt
socks5://127.0.0.1:7890
https://127.0.0.1:7890
http://127.0.0.1:7890
...
```
### Configuration File
Configure parameters in `config.ini` (or custom config file):
```ini
[SETTINGS]
# Local server listening port (default: 1080)
port = 1080
# Proxy rotation mode: cycle, custom, or load_balance (default: cycle)
mode = cycle
# Proxy change interval in seconds, 0 means change every request (default: 300)
interval = 300
# Local server authentication username (default: neko) empty means no auth
username = neko
# Local server authentication password (default: 123456) empty means no auth
password = 123456
# Whether to use getip module for proxy retrieval True/False (default: False)
use_getip = False
# Proxy list file (default: ip.txt)
proxy_file = ip.txt
# Enable proxy checking True/False (default: True)
check_proxies = True
# Language setting (cn/en)
language = en
# IP whitelist file path (empty to disable)
whitelist_file = whitelist.txt
# IP blacklist file path (empty to disable)
blacklist_file = blacklist.txt
# IP authentication priority (whitelist/blacklist)
# whitelist: Check whitelist first, allow if in whitelist
# blacklist: Check blacklist first, deny if in blacklist
ip_auth_priority = whitelist
```
After configuration, run:
```bash
python3 ProxyCat.py
```
### Demo Effect
**Fixed proxy address (default)**:
```
http://neko:123456@127.0.0.1:1080
http://127.0.0.1:1080
socks5://neko:123456@127.0.0.1:1080
socks5://127.0.0.1:1080
```
If you're deploying on a public network, simply replace `127.0.0.1` with your public IP.
![主界面图](./assets/主界面图.png)
### Using API for Automatic Proxy Retrieval
The tool supports direct API calls to obtain proxy addresses. When you configure `use_getip = True`, the tool will no longer read from local `ip.txt` but instead execute **getip.py** script to obtain new proxy addresses (ensure your IP is whitelisted, and the format should be IP:port, only one proxy address can be used each time).
In this case, you need to modify the content of **getip.py** to your own API, with format `IP:PORT`. Default protocol is `socks5`, manually change to `http` if needed.
### Docker Deployment
Please install Docker and Docker-compose in advance. You can search for installation methods online.
```
# Clone the project source code locally
git clone https://github.com/honmashironeko/ProxyCat.git
# Modify the content in the config.ini file in the config folder
# Enter the ProxyCat folder, build the image and start the container
docker-compose up -d --build
# Stop and start the service (you need to restart the service after modifying the configuration each time)
docker-compose down | docker-compose up -d
# View log information
docker logs proxycat
```
## Performance
Through actual testing, when proxy server performance is sufficient, ProxyCat can handle **1000** concurrent connections without packet loss, covering most scanning and penetration testing needs.
![性能测试图](./assets/性能测试图.png)
[ProxyCat Investigation Manual](../main/ProxyCat-Manual/Investigation%20Manual.md)
## Disclaimer
- By downloading, installing, using, or modifying this tool and related code, you indicate your trust in this tool.
- We take no responsibility for any form of loss or damage caused to yourself or others while using this tool.
- You are solely responsible for any illegal activities during your use of this tool, and we bear no legal or associated liability.
- Please carefully read and fully understand all terms, especially liability exemption or limitation clauses, and choose to accept or not.
- Unless you have read and accepted all terms of this agreement, you have no right to download, install, or use this tool.
- Your download, installation, and usage actions indicate you have read and agreed to be bound by the above agreement.
- We are not responsible for any form of loss or damage caused to yourself or others while using this tool.
- You are solely responsible for any illegal activities conducted while using this tool.
- Please carefully read and fully understand all terms, especially liability exemption clauses.
- You have no right to download, install, or use this tool unless you have read and accepted all terms.
- Your download, installation, and usage actions indicate your acceptance of this agreement.
## Change Log
## Changelog
### 2025/01/07
- Introduced connection pool mechanism to improve performance.
- Optimized error handling and logging.
- Enhanced proxy switching mechanism.
### 2025/01/03
- Centralized configuration parameters management into config files for better maintenance
- Fixed known bugs and improved stability and concurrency capabilities
### 2025/01/02
- Restructured software architecture for better usability
- Added blacklist/whitelist mechanism for authentication
- GetIP method now only requests proxy after receiving first request to prevent resource waste
- Changed language configuration logic, now controlled via config.ini parameter
- Updated configuration panel, addresses can be copied without username/password
- Added docker deployment support
### 2024/10/23
- Restructured code, split into separate files
- Added automatic proxy switching when current proxy fails during forwarding
### 2024/09/29
- Removed less-used single cycle mode, replaced with custom mode for customizable proxy switching logic
- Changed proxy validity checking to asynchronous for better speed
- Removed problematic SOCKS4 protocol support
- Beautified logging system
- Improved exception handling logic
- Added proxy format validation
### 2024/09/10
- Optimized concurrency efficiency, supporting next request before receiving response
- Added load balancing mode for random proxy selection and concurrent proxy usage
- Changed proxy validity checking to asynchronous for better efficiency
### 2024/09/09
- Added option to validate proxies in ip.txt at startup
- Function downgrade to support lower Python versions
### 2024/09/03
- Added local SOCKS5 listening for wider software compatibility
- Changed some functions to support lower Python versions
- Beautified output display
### 2024/08/31
- Major project structure adjustment
- Beautified display with continuous proxy switch time indication
- Added Ctrl+C support for stopping
- Major adjustment to async requests, improved concurrency efficiency
- Changed from runtime parameters to local ini config file
- Added support for local authentication-free mode
- Added version detection
- Added proxy server authentication
- Added GetIP update only on request feature
- Added proxy protocol auto-detection
- Added HTTPS protocol support
- Changed asyncio.timeout() to asyncio.wait_for() for lower Python version support
[Changelog Records](../main/ProxyCat-Manual/logs.md)
## Development Plan
- [x] Added local server authentication
- [x] Added IP change per request feature
- [x] Added static proxy auto-update module
- [x] Added load balancing mode
- [x] Added version detection
- [x] Added proxy authentication support
- [x] Added request-triggered getip updates
- [x] Added initial proxy validity check
- [x] Added SOCKS protocol support
- [ ] Add detailed logging with multi-user support
- [ ] Add Web UI interface
- [x] Add docker deployment
- [ ] Develop babycat module
- [x] Add detailed logging to record all IP identities connecting to ProxyCat, supporting multiple users.
- [x] Add Web UI for a more powerful and user-friendly interface.
- [ ] Develop babycat module that can run on any server or host to turn it into a proxy server.
- [ ] Add request blacklist/whitelist to specify URLs, IPs, or domains to be forcibly dropped or bypassed.
- [ ] Package to PyPi for easier installation and use.
For feedback or suggestions, please contact via WeChat Official Account: **樱花庄的本间白猫**
If you have good ideas or encounter bugs during use, please contact the author through:
## Acknowledgments
WeChat Official Account: **樱花庄的本间白猫**
In no particular order, thanks to:
## Special Thanks
- [AabyssZG](https://github.com/AabyssZG)
- [ProbiusOfficial](https://github.com/ProbiusOfficial)
- [gh0stkey](https://github.com/gh0stkey)
In no particular order, thanks to all contributors who helped with this project:
- [AabyssZG (曾哥)](https://github.com/AabyssZG)
- [ProbiusOfficial (探姬)](https://github.com/ProbiusOfficial)
- [gh0stkey (EvilChen)](https://github.com/gh0stkey)
- [huangzheng2016(HydrogenE7)](https://github.com/huangzheng2016)
- chars6
- qianzai
- qianzai千载
- ziwindlu
![Star History Chart](https://api.star-history.com/svg?repos=honmashironeko/ProxyCat&type=Date)
## Sponsor
Open source development isn't easy. If you find this tool helpful, consider sponsoring the author's development!
---
| Rank | ID | Amount (CNY) |
| :--: | :-----------------: | :----------: |
| 1 | **陆沉** | 1266.62 |
| 2 | **柯林斯.民间新秀** | 696 |
| 3 | **taffy** | 150 |
| [Sponsor List](https://github.com/honmashironeko/Thanks-for-sponsorship) | Every sponsorship is a motivation for the author! | (´∀`)♡ |
---
![Sponsor](./assets/赞助.png)
## Proxy Recommendations

264
app.py
View File

@ -1,9 +1,8 @@
from flask import Flask, render_template, jsonify, request, redirect, url_for
from flask import Flask, render_template, jsonify, request, redirect, url_for, send_from_directory
import sys
import os
import logging
from datetime import datetime
from enum import Enum
import json
from configparser import ConfigParser
from itertools import cycle
@ -20,18 +19,20 @@ import threading
import time
app = Flask(__name__,
template_folder='web/templates')
template_folder='web/templates',
static_folder='web/static')
werkzeug.serving.WSGIRequestHandler.log = lambda self, type, message, *args: None
logging.getLogger('werkzeug').setLevel(logging.ERROR)
config = load_config('config/config.ini')
server = AsyncProxyServer(config)
def get_config_path(filename):
return os.path.join('config', filename)
log_file = 'logs/proxycat.log'
os.makedirs('logs', exist_ok=True)
log_messages = []
max_log_messages = 10000
@ -39,9 +40,25 @@ class CustomFormatter(logging.Formatter):
def formatTime(self, record, datefmt=None):
return datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S')
file_formatter = CustomFormatter('%(asctime)s - %(levelname)s - %(message)s')
file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_handler.setFormatter(file_formatter)
def setup_logging():
file_formatter = CustomFormatter('%(asctime)s - %(levelname)s - %(message)s')
file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_handler.setFormatter(file_formatter)
console_handler = logging.StreamHandler()
console_formatter = CustomFormatter('%(asctime)s - %(levelname)s - %(message)s')
console_handler.setFormatter(console_formatter)
memory_handler = MemoryHandler()
memory_handler.setFormatter(CustomFormatter('%(message)s'))
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
root_logger.addHandler(memory_handler)
class MemoryHandler(logging.Handler):
def emit(self, record):
@ -54,21 +71,6 @@ class MemoryHandler(logging.Handler):
if len(log_messages) > max_log_messages:
log_messages = log_messages[-max_log_messages:]
console_handler = logging.StreamHandler()
console_formatter = CustomFormatter('%(asctime)s - %(levelname)s - %(message)s')
console_handler.setFormatter(console_formatter)
memory_handler = MemoryHandler()
memory_handler.setFormatter(CustomFormatter('%(message)s'))
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
root_logger.addHandler(memory_handler)
def require_token(f):
@wraps(f)
def decorated_function(*args, **kwargs):
@ -110,8 +112,15 @@ def get_status():
server_config = dict(config.items('Server')) if config.has_section('Server') else {}
current_proxy = server.current_proxy
if not current_proxy and not server.use_getip:
if hasattr(server, 'proxies') and server.proxies:
current_proxy = server.proxies[0]
else:
current_proxy = get_message('no_proxy', server.language)
return jsonify({
'current_proxy': server.current_proxy,
'current_proxy': current_proxy,
'mode': server.mode,
'port': int(server_config.get('port', '1080')),
'interval': server.interval,
@ -121,6 +130,7 @@ def get_status():
'getip_url': getattr(server, 'getip_url', '') if getattr(server, 'use_getip', False) else '',
'auth_required': server.auth_required,
'display_level': int(config.get('DEFAULT', 'display_level', fallback='1')),
'service_status': 'running' if server.running else 'stopped',
'config': {
'port': server_config.get('port', ''),
'mode': server_config.get('mode', 'cycle'),
@ -142,90 +152,48 @@ def get_status():
}
})
@app.route('/api/config', methods=['GET', 'POST'])
def handle_config():
if request.method == 'POST':
new_config = request.json
try:
with open('config/config.ini', 'r', encoding='utf-8') as f:
lines = f.readlines()
@app.route('/api/config', methods=['POST'])
def save_config():
try:
new_config = request.get_json()
current_config = load_config('config/config.ini')
port_changed = str(new_config.get('port', '')) != str(current_config.get('port', ''))
config_parser = ConfigParser()
config_parser.read('config/config.ini', encoding='utf-8')
if not config_parser.has_section('Server'):
config_parser.add_section('Server')
current_section = None
updated_lines = []
i = 0
while i < len(lines):
line = lines[i].strip()
if line.startswith('['):
current_section = line[1:-1]
updated_lines.append(lines[i])
i += 1
continue
if line.startswith('#') or not line:
updated_lines.append(lines[i])
i += 1
continue
if '=' in line:
key = line.split('=')[0].strip()
if key in new_config:
updated_lines.append(f"{key} = {new_config[key]}\n")
else:
updated_lines.append(lines[i])
i += 1
continue
updated_lines.append(lines[i])
i += 1
for key, value in new_config.items():
if key != 'users':
config_parser.set('Server', key, str(value))
with open('config/config.ini', 'w', encoding='utf-8') as f:
config_parser.write(f)
with open('config/config.ini', 'w', encoding='utf-8') as f:
f.writelines(updated_lines)
config = load_config('config/config.ini')
server.config = config
server.mode = config.get('mode', 'cycle')
server.interval = int(config.get('interval', '300'))
server.language = config.get('language', 'cn')
server.use_getip = config.get('use_getip', 'False').lower() == 'true'
server.check_proxies = config.get('check_proxies', 'True').lower() == 'true'
server.username = config.get('username', '')
server.password = config.get('password', '')
server.proxy_username = config.get('proxy_username', '')
server.proxy_password = config.get('proxy_password', '')
server.auth_required = bool(server.username and server.password)
server.proxy_file = config.get('proxy_file')
server.whitelist_file = config.get('whitelist_file', '')
server.blacklist_file = config.get('blacklist_file', '')
if server.use_getip:
server.getip_url = config.get('getip_url', '')
old_port = int(server.config.get('port', '1080'))
new_port = int(new_config.get('port', '1080'))
needs_restart = old_port != new_port
return jsonify({
'status': 'success',
'needs_restart': needs_restart,
'message': '配置已更新,需要重启服务器' if needs_restart else '配置已更新'
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
else:
with open('config/config.ini', 'r', encoding='utf-8') as f:
config_content = f.read()
return jsonify({'config': config_content})
server.config = load_config('config/config.ini')
server._update_config_values(server.config)
return jsonify({
'status': 'success',
'port_changed': port_changed
})
except Exception as e:
logging.error(f"Error saving config: {e}")
return jsonify({
'status': 'error',
'message': str(e)
})
@app.route('/api/proxies', methods=['GET', 'POST'])
def handle_proxies():
if request.method == 'POST':
try:
proxies = request.json.get('proxies', [])
with open(server.proxy_file, 'w', encoding='utf-8') as f:
proxy_file = get_config_path(os.path.basename(server.proxy_file))
with open(proxy_file, 'w', encoding='utf-8') as f:
f.write('\n'.join(proxies))
server.proxies = server._load_file_proxies()
if server.proxies:
@ -242,10 +210,11 @@ def handle_proxies():
})
else:
try:
with open(server.proxy_file, 'r', encoding='utf-8') as f:
proxy_file = get_config_path(os.path.basename(server.proxy_file))
with open(proxy_file, 'r', encoding='utf-8') as f:
proxies = f.read().splitlines()
return jsonify({'proxies': proxies})
except Exception as e:
except Exception:
return jsonify({'proxies': []})
@app.route('/api/check_proxies')
@ -272,7 +241,8 @@ def handle_ip_lists():
try:
list_type = request.json.get('type')
ip_list = request.json.get('list', [])
filename = server.whitelist_file if list_type == 'whitelist' else server.blacklist_file
base_filename = os.path.basename(server.whitelist_file if list_type == 'whitelist' else server.blacklist_file)
filename = get_config_path(base_filename)
with open(filename, 'w', encoding='utf-8') as f:
f.write('\n'.join(ip_list))
@ -292,9 +262,11 @@ def handle_ip_lists():
'message': get_message('ip_list_save_failed', server.language, str(e))
})
else:
whitelist_file = get_config_path(os.path.basename(server.whitelist_file))
blacklist_file = get_config_path(os.path.basename(server.blacklist_file))
return jsonify({
'whitelist': list(load_ip_list(server.whitelist_file)),
'blacklist': list(load_ip_list(server.blacklist_file))
'whitelist': list(load_ip_list(whitelist_file)),
'blacklist': list(load_ip_list(blacklist_file))
})
@app.route('/api/logs')
@ -359,7 +331,7 @@ def switch_proxy():
new_proxy = newip()
server.current_proxy = new_proxy
server.last_switch_time = time.time()
logging.info(get_message('manual_switch', server.language, old_proxy, new_proxy))
server._log_proxy_switch(old_proxy, new_proxy)
return jsonify({
'status': 'success',
'current_proxy': server.current_proxy,
@ -380,7 +352,7 @@ def switch_proxy():
old_proxy = server.current_proxy
server.current_proxy = next(server.proxy_cycle)
server.last_switch_time = time.time()
logging.info(get_message('manual_switch', server.language, old_proxy, server.current_proxy))
server._log_proxy_switch(old_proxy, server.current_proxy)
return jsonify({
'status': 'success',
'current_proxy': server.current_proxy,
@ -418,41 +390,46 @@ def control_service():
if server.running:
return jsonify({
'status': 'success',
'message': get_message('service_start_success', server.language)
'message': get_message('service_start_success', server.language),
'service_status': 'running'
})
else:
return jsonify({
'status': 'error',
'message': get_message('service_start_failed', server.language)
'message': get_message('service_start_failed', server.language),
'service_status': 'stopped'
})
return jsonify({
'status': 'success',
'message': get_message('service_already_running', server.language)
'message': get_message('service_already_running', server.language),
'service_status': 'running'
})
elif action == 'stop':
if server.running:
server.stop_server = True
server.running = False
if server.server_instance:
server.server_instance.close()
for _ in range(10):
if not server.running:
break
time.sleep(0.5)
if hasattr(server, 'proxy_thread') and server.proxy_thread:
server.proxy_thread = None
if server.running:
if hasattr(server, 'proxy_thread') and server.proxy_thread:
server.proxy_thread = None
server.running = False
for _ in range(5):
if server.server_instance is None:
break
time.sleep(0.2)
return jsonify({
'status': 'success',
'message': get_message('service_stop_success', server.language)
'message': get_message('service_stop_success', server.language),
'service_status': 'stopped'
})
return jsonify({
'status': 'success',
'message': get_message('service_not_running', server.language)
'message': get_message('service_not_running', server.language),
'service_status': 'stopped'
})
elif action == 'restart':
@ -546,32 +523,33 @@ def check_version():
original_level = httpx_logger.level
httpx_logger.setLevel(logging.WARNING)
CURRENT_VERSION = "ProxyCat-V2.0.0"
CURRENT_VERSION = "ProxyCat-V2.0.1"
try:
client = httpx.Client(transport=httpx.HTTPTransport(retries=3))
response = client.get("https://y.shironekosan.cn/1.html", timeout=10)
response.raise_for_status()
content = response.text
match = re.search(r'<p>(ProxyCat-V\d+\.\d+\.\d+)</p>', content)
if match:
latest_version = match.group(1)
is_latest = version.parse(latest_version.split('-V')[1]) <= version.parse(CURRENT_VERSION.split('-V')[1])
return jsonify({
'status': 'success',
'is_latest': is_latest,
'current_version': CURRENT_VERSION,
'latest_version': latest_version
})
else:
return jsonify({
'status': 'error',
'message': get_message('version_info_not_found', server.language)
})
finally:
httpx_logger.setLevel(original_level)
match = re.search(r'<p>(ProxyCat-V\d+\.\d+\.\d+)</p>', content)
if match:
latest_version = match.group(1)
is_latest = version.parse(latest_version.split('-V')[1]) <= version.parse(CURRENT_VERSION.split('-V')[1])
return jsonify({
'status': 'success',
'is_latest': is_latest,
'current_version': CURRENT_VERSION,
'latest_version': latest_version
})
else:
return jsonify({
'status': 'error',
'message': get_message('version_info_not_found', server.language)
})
except Exception as e:
return jsonify({
'status': 'error',
@ -635,10 +613,19 @@ def handle_users():
logging.error(f"Error getting users: {e}")
return jsonify({'users': {}})
@app.route('/static/<path:path>')
def send_static(path):
return send_from_directory('web/static', path)
def run_proxy_server():
asyncio.run(run_server(server))
asyncio.run(run_server(server))
except KeyboardInterrupt:
logging.info(get_message('user_interrupt', server.language))
except Exception as e:
logging.error(f"Proxy server error: {e}")
if __name__ == '__main__':
setup_logging()
web_port = int(config.get('web_port', '5000'))
web_url = f"http://127.0.0.1:{web_port}"
if config.get('token'):
@ -649,4 +636,5 @@ if __name__ == '__main__':
proxy_thread = threading.Thread(target=run_proxy_server, daemon=True)
proxy_thread.start()
app.run(host='0.0.0.0', port=web_port)

View File

@ -2,10 +2,12 @@ version: '3'
services:
proxycat:
build: .
environment:
- TZ=Asia/Shanghai
ports:
- "1080:1080"
- "5000:5000"
volumes:
- ./config:/app/config
restart: unless-stopped
network_mode: "bridge"
network_mode: "bridge"

View File

@ -25,12 +25,10 @@ MESSAGES = {
'valid_proxies': '有效代理地址: {}',
'no_valid_proxies': '没有有效的代理地址',
'proxy_check_failed': '{}代理 {} 检测失败: {}',
'proxy_switch': '切换到新的代理: {}',
'proxy_switch_detail': '已切换代理: {} -> {}',
'proxy_switch': '切换代理: {} -> {}',
'proxy_consecutive_fails': '代理 {} 连续失败 {} 次,正在切换新代理',
'proxy_invalid': '代理 {} 已失效,立即切换新代理',
'proxy_invalid': '代理 {} 无效,立即切换代理',
'connection_timeout': '连接超时',
'proxy_invalid_switching': '代理地址失效,切换代理地址',
'data_transfer_timeout': '数据传输超时,正在重试...',
'connection_reset': '连接被重置',
'transfer_cancelled': '传输被取消',
@ -81,7 +79,6 @@ MESSAGES = {
'response_write_error': '写入响应时出错: {}',
'consecutive_failures': '检测到连续代理失败: {}',
'invalid_proxy': '当前代理无效: {}',
'proxy_switched': '已从代理 {} 切换到 {}',
'whitelist_error': '添加白名单失败: {}',
'api_mode_notice': '当前为API模式收到请求将自动获取代理地址',
'server_running': '代理服务器运行在 {}:{}',
@ -100,14 +97,11 @@ MESSAGES = {
2: 显示所有详细信息''',
'new_client_connect': '新客户端连接 - IP: {}, 用户: {}',
'no_auth': '无认证',
'proxy_changed': '代理变更: {} -> {}',
'connection_error': '连接处理错误: {}',
'cleanup_error': '清理IP错误: {}',
'port_changed': '端口已更改: {} -> {},需要重启服务器生效',
'config_updated': '服务器配置已更新',
'load_proxy_file_error': '加载代理文件失败: {}',
'manual_switch': '手动切换代理: {} -> {}',
'auto_switch': '自动切换代理: {} -> {}',
'proxy_check_result': '代理检查完成,有效代理:{}',
'no_proxy': '无代理',
'cycle_mode': '循环模式',
@ -196,12 +190,10 @@ MESSAGES = {
'valid_proxies': 'Valid proxies: {}',
'no_valid_proxies': 'No valid proxies found',
'proxy_check_failed': '{} proxy {} check failed: {}',
'proxy_switch': 'Switching to new proxy: {}',
'proxy_switch_detail': 'Switched proxy: {} -> {}',
'proxy_switch': 'Switch proxy: {} -> {}',
'proxy_consecutive_fails': 'Proxy {} failed {} times consecutively, switching to new proxy',
'proxy_invalid': 'Proxy {} is invalid, switching immediately',
'proxy_invalid': 'Proxy {} is invalid, switching proxy immediately',
'connection_timeout': 'Connection timeout',
'proxy_invalid_switching': 'Proxy invalid, switching to new proxy',
'data_transfer_timeout': 'Data transfer timeout, retrying...',
'connection_reset': 'Connection reset',
'transfer_cancelled': 'Transfer cancelled',
@ -254,7 +246,6 @@ MESSAGES = {
'response_write_error': 'Error writing response: {}',
'consecutive_failures': 'Consecutive proxy failures detected for {}',
'invalid_proxy': 'Current proxy is invalid: {}',
'proxy_switched': 'Switched from proxy {} to {}',
'whitelist_error': 'Failed to add whitelist: {}',
'api_mode_notice': 'Currently in API mode, proxy address will be automatically obtained upon request',
'server_running': 'Proxy server running at {}:{}',
@ -273,14 +264,11 @@ MESSAGES = {
2: Show all detailed information''',
'new_client_connect': 'New client connection - IP: {}, User: {}',
'no_auth': 'No authentication',
'proxy_changed': 'Proxy changed: {} -> {}',
'connection_error': 'Connection handling error: {}',
'cleanup_error': 'IP cleanup error: {}',
'port_changed': 'Port changed: {} -> {}, server restart required',
'config_updated': 'Server configuration updated',
'load_proxy_file_error': 'Failed to load proxy file: {}',
'manual_switch': 'Manual proxy switch: {} -> {}',
'auto_switch': 'Auto switch proxy: {} -> {}',
'proxy_check_result': 'Proxy check completed, valid proxies: {}',
'no_proxy': 'No proxy',
'cycle_mode': 'Cycle Mode',
@ -479,29 +467,37 @@ DEFAULT_CONFIG = {
}
def load_config(config_file='config/config.ini'):
config = ConfigParser()
config.read(config_file, encoding='utf-8')
settings = {}
if config.has_section('Server'):
settings.update(dict(config.items('Server')))
try:
config = ConfigParser()
config.read(config_file, encoding='utf-8')
config_dir = os.path.dirname(config_file)
for key in ['proxy_file', 'whitelist_file', 'blacklist_file']:
if key in settings and settings[key]:
settings[key] = os.path.join(config_dir, settings[key])
if config.has_section('DEFAULT'):
settings.update(dict(config.items('DEFAULT')))
return {**DEFAULT_CONFIG, **settings}
if not config.has_section('Server'):
config.add_section('Server')
for key, value in DEFAULT_CONFIG.items():
config.set('Server', key, str(value))
with open(config_file, 'w', encoding='utf-8') as f:
config.write(f)
result = dict(config.items('Server'))
# 添加用户配置
if config.has_section('Users'):
result['Users'] = dict(config.items('Users'))
return result
except Exception as e:
logging.error(f"Error loading config: {e}")
return DEFAULT_CONFIG.copy()
def load_ip_list(file_path):
if not file_path or not os.path.exists(file_path):
return set()
with open(file_path, 'r') as f:
return {line.strip() for line in f if line.strip()}
try:
config_path = os.path.join('config', os.path.basename(file_path))
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
return set(line.strip() for line in f if line.strip())
except Exception as e:
logging.error(f"Error loading IP list: {e}")
return set()
_proxy_check_cache = {}
_proxy_check_ttl = 10
@ -640,7 +636,7 @@ async def check_for_updates(language='cn'):
match = re.search(r'<p>(ProxyCat-V\d+\.\d+\.\d+)</p>', content)
if match:
latest_version = match.group(1)
CURRENT_VERSION = "ProxyCat-V2.0.0"
CURRENT_VERSION = "ProxyCat-V2.0.1"
if version.parse(latest_version.split('-V')[1]) > version.parse(CURRENT_VERSION.split('-V')[1]):
print(f"{Fore.YELLOW}{get_message('new_version_found', language)} 当前版本: {CURRENT_VERSION}, 最新版本: {latest_version}{Style.RESET_ALL}")
print(f"{Fore.YELLOW}{get_message('visit_quark', language)}{Style.RESET_ALL}")

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,8 @@
colorama==0.4.6
httpx==0.27.2
httpx[http2,socks]==0.27.2
packaging==24.1
Requests==2.32.3
tqdm>=4.65.0
flask>=2.0.1
flask>=2.0.1
werkzeug>=2.0.0

View File

@ -0,0 +1,25 @@
@keyframes slideIn {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeInUp 0.3s ease;
}

48
web/static/css/base.css Normal file
View File

@ -0,0 +1,48 @@
:root {
--primary-color: #3498db;
--secondary-color: #2ecc71;
--warning-color: #f1c40f;
--danger-color: #e74c3c;
--background-color: #f5f6fa;
--text-color: #2c3e50;
--border-color: #dcdde1;
--card-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
body {
background-color: var(--background-color);
color: var(--text-color);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
margin: 0;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.card {
background: #fff;
border-radius: 12px;
box-shadow: var(--card-shadow);
margin-bottom: 20px;
padding: 20px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
}
.section-title {
color: var(--text-color);
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--border-color);
}

View File

@ -0,0 +1,45 @@
.btn {
border: none;
border-radius: 8px;
padding: 10px 20px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn i {
font-size: 1.1em;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-success {
background-color: var(--secondary-color);
color: white;
}
.btn-warning {
background-color: var(--warning-color);
color: white;
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn:hover {
transform: translateY(-1px);
filter: brightness(1.1);
}
.btn:active {
transform: translateY(1px);
}

View File

@ -0,0 +1,61 @@
@media (prefers-color-scheme: dark) {
:root {
--background-color: #1a1a1a;
--text-color: #ffffff;
--border-color: #2d2d2d;
--card-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
body {
background-color: var(--background-color);
color: var(--text-color);
}
.card {
background: #2d2d2d;
}
.status-card {
background: linear-gradient(145deg, #2d2d2d, #252525);
}
.status-card .card-title {
color: #e0e0e0;
}
.status-card .card-value {
color: #3498db;
}
.status-card .card-footer {
color: #888;
}
.btn {
border: 1px solid rgba(255, 255, 255, 0.1);
}
input, select, textarea {
background-color: #2d2d2d;
border-color: #3d3d3d;
color: #ffffff;
}
input:focus, select:focus, textarea:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
.progress {
background: rgba(255, 255, 255, 0.1);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2);
}
.progress-bar {
background: linear-gradient(45deg, #64b5f6, #4a90e2);
}
.card:hover {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
}
}

210
web/static/css/forms.css Normal file
View File

@ -0,0 +1,210 @@
.form-control, .form-select {
border-radius: 8px;
border: 2px solid #e9ecef;
padding: 10px 15px;
transition: all 0.3s ease;
}
.form-control:focus, .form-select:focus {
border-color: #4a90e2;
box-shadow: 0 0 0 0.2rem rgba(74, 144, 226, 0.25);
}
.table {
background: white;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
}
.table thead th {
background: #f8f9fa;
border-bottom: 2px solid #dee2e6;
color: #2c3e50;
font-weight: 600;
padding: 15px;
}
.table tbody td {
padding: 12px 15px;
vertical-align: middle;
border-bottom: 1px solid #eee;
}
@media (prefers-color-scheme: dark) {
.form-control, .form-select {
background-color: #2d2d2d;
color: #ffffff;
border-color: #3d3d3d;
}
.table {
background-color: #2d2d2d;
color: #ffffff;
}
.table thead th {
background-color: #3d3d3d;
color: #ffffff;
}
}
/* 浮动保存按钮样式 */
.floating-save-container {
position: fixed;
bottom: 30px;
right: 30px;
z-index: 1000;
}
.floating-save-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-radius: 30px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
background: linear-gradient(135deg, #4a90e2, #357abd);
}
.floating-save-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.floating-save-btn:active {
transform: translateY(1px);
}
.floating-save-btn i {
font-size: 1.1em;
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.floating-save-btn {
background: linear-gradient(135deg, #2980b9, #2c3e50);
}
}
/* 移动端适配 */
@media (max-width: 768px) {
.floating-save-container {
bottom: 20px;
right: 20px;
}
.floating-save-btn {
padding: 10px 20px;
}
}
/* Toast 样式优化 */
.toast {
position: fixed;
top: 20px;
right: 20px;
min-width: 300px;
padding: 16px 20px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 12px;
color: white;
transform: translateX(120%);
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1100;
font-weight: 500;
}
.toast.show {
transform: translateX(0);
opacity: 1;
}
/* 优化不同类型的 Toast 样式 */
.toast-success {
background: linear-gradient(135deg, #00b09b, #96c93d);
border-left: 4px solid #96c93d;
}
.toast-error {
background: linear-gradient(135deg, #ff5f6d, #ffc371);
border-left: 4px solid #ff5f6d;
}
.toast-info {
background: linear-gradient(135deg, #2193b0, #6dd5ed);
border-left: 4px solid #2193b0;
}
.toast-warning {
background: linear-gradient(135deg, #f2994a, #f2c94c);
border-left: 4px solid #f2994a;
}
/* Toast 图标样式 */
.toast i {
font-size: 1.4em;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
/* Toast 堆叠效果 */
.toast + .toast {
margin-top: 16px;
}
/* Toast 文本样式 */
.toast span {
flex: 1;
font-size: 14px;
line-height: 1.4;
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.toast {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.toast-success {
background: linear-gradient(135deg, #004d40, #1b5e20);
border-left: 4px solid #00c853;
}
.toast-error {
background: linear-gradient(135deg, #b71c1c, #c62828);
border-left: 4px solid #ff1744;
}
.toast-info {
background: linear-gradient(135deg, #0d47a1, #1976d2);
border-left: 4px solid #2196f3;
}
.toast-warning {
background: linear-gradient(135deg, #e65100, #f57c00);
border-left: 4px solid #ffd600;
}
}
/* 移动端适配 */
@media (max-width: 768px) {
.toast {
min-width: auto;
width: calc(100% - 32px);
margin: 0 16px;
padding: 12px 16px;
}
.toast + .toast {
margin-top: 12px;
}
}

107
web/static/css/logs.css Normal file
View File

@ -0,0 +1,107 @@
.log-container {
height: 500px;
position: relative;
margin: 1rem 0;
}
.log-INFO {
background-color: #e8f5e9;
color: #1b5e20;
border-left: 6px solid #2e7d32;
font-size: 17px;
font-weight: 600;
}
.log-WARNING {
background-color: #fff3e0;
color: #e65100;
border-left: 6px solid #f57c00;
font-size: 17px;
font-weight: 700;
}
.log-ERROR {
background-color: #ffebee;
color: #b71c1c;
border-left: 6px solid #c62828;
font-size: 18px;
font-weight: 800;
}
.log-CRITICAL {
background-color: #ffebee;
color: #7f0000;
border-left: 6px solid #b71c1c;
font-size: 18px;
font-weight: 900;
text-transform: uppercase;
}
.log-message {
font-weight: 600;
line-height: 1.5;
}
/* 搜索高亮样式 */
.log-highlight {
background-color: #fff176;
color: #000000;
padding: 2px 4px;
border-radius: 3px;
font-weight: bold;
margin: 0 2px;
}
/* 暗色模式下的高亮样式 */
@media (prefers-color-scheme: dark) {
.log-highlight {
background-color: #ffd600;
color: #000000;
}
.log-INFO {
background-color: rgba(232, 245, 233, 0.1);
color: #81c784;
border-left-color: #2e7d32;
}
.log-WARNING {
background-color: rgba(255, 243, 224, 0.1);
color: #ffb74d;
border-left-color: #f57c00;
}
.log-ERROR {
background-color: rgba(255, 235, 238, 0.1);
color: #e57373;
border-left-color: #c62828;
}
.log-CRITICAL {
background-color: rgba(255, 235, 238, 0.1);
color: #ef5350;
border-left-color: #b71c1c;
}
}
/* 日志条目样式 */
.log-entry {
padding: 8px 12px;
margin-bottom: 4px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.log-entry:hover {
filter: brightness(0.95);
}
.log-time {
font-family: monospace;
opacity: 0.8;
}
.log-level {
font-weight: bold;
margin: 0 4px;
}

View File

@ -0,0 +1,33 @@
.nav-tabs {
border-bottom: 2px solid #dee2e6;
margin-bottom: 20px;
}
.nav-tabs .nav-link {
border: none;
color: #6c757d;
padding: 10px 20px;
font-weight: 500;
transition: all 0.2s;
}
.nav-tabs .nav-link:hover {
border: none;
color: var(--primary-color);
}
.nav-tabs .nav-link.active {
border: none;
color: var(--primary-color);
position: relative;
}
.nav-tabs .nav-link.active:after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 100%;
height: 2px;
background-color: var(--primary-color);
}

View File

@ -0,0 +1,44 @@
.progress-container {
margin-top: auto;
}
.progress {
height: 8px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.05);
overflow: hidden;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.progress-bar {
background: linear-gradient(45deg, #4a90e2, #357abd);
box-shadow: 0 0 10px rgba(74, 144, 226, 0.5);
transition: width 0.3s ease;
position: relative;
overflow: hidden;
}
.progress-bar::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.3) 50%,
rgba(255, 255, 255, 0) 100%
);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}

View File

@ -0,0 +1,41 @@
@media (max-width: 768px) {
.status-card {
margin-bottom: 20px;
}
.btn {
width: 100%;
margin-bottom: 10px;
}
.table-responsive {
margin-bottom: 20px;
}
.log-container {
height: 300px;
}
.log-search input {
font-size: 14px;
padding: 10px 40px;
}
}
.language-switch {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
background: white;
padding: 8px 16px;
border-radius: 20px;
box-shadow: var(--card-shadow);
}
@media (max-width: 768px) {
.language-switch {
top: 10px;
right: 10px;
}
}

44
web/static/css/search.css Normal file
View File

@ -0,0 +1,44 @@
.log-search {
position: relative;
margin-bottom: 20px;
}
.log-search input {
padding: 12px 45px;
border-radius: 25px;
font-size: 15px;
font-weight: 500;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
}
.log-search input:focus {
border-color: #4a90e2;
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.2);
}
.log-search .clear-search {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #6c757d;
cursor: pointer;
padding: 5px;
transition: all 0.2s ease;
}
.log-search .clear-search:hover {
color: #dc3545;
transform: translateY(-50%) scale(1.1);
}
.log-search i {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
color: #6c757d;
}

View File

@ -0,0 +1,80 @@
.service-control {
height: 100%;
display: flex;
flex-direction: column;
}
.service-control .card-body {
display: flex;
flex-direction: column;
height: 100%;
}
.service-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
}
.service-description {
font-size: 0.9rem;
color: #666;
margin-bottom: 1rem;
}
.service-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: auto;
}
.service-btn {
border: none;
border-radius: 6px;
padding: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.service-btn .btn-content {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.service-btn:hover {
transform: translateY(-1px);
filter: brightness(1.1);
}
.service-btn:active {
transform: translateY(1px);
}
@media (max-width: 768px) {
.service-actions {
grid-template-columns: 1fr;
}
.service-btn {
padding: 10px;
}
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.service-title {
color: #e0e0e0;
}
.service-description {
color: #888;
}
}

View File

@ -0,0 +1,827 @@
.status-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 25px;
margin-bottom: 30px;
}
/* 统一卡片基础样式 */
.card.status-card, .card.service-control {
height: 150px;
background: linear-gradient(165deg, #ffffff, #f8f9fa);
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.8);
padding: 15px;
margin-bottom: 15px;
}
.card-body {
height: 100%;
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
padding: 0;
}
.card.status-card:hover, .card.service-control:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* 统一卡片标题样式 */
.card-title, .service-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.7rem;
display: flex;
align-items: center;
gap: 8px;
}
.card-title i, .service-title i {
font-size: 1.1em;
color: var(--primary-color);
background: rgba(52, 152, 219, 0.1);
padding: 4px;
border-radius: 8px;
}
/* 统一卡片内容样式 */
.card-text {
color: var(--text-color);
font-size: 1.1rem;
font-weight: 500;
margin-bottom: 0.7rem;
flex-grow: 1;
display: flex;
align-items: center;
}
/* 统一按钮容器样式 */
.button-container, .service-actions, .progress-container {
margin-top: auto;
}
/* 统一按钮样式 */
.btn-sm, .service-btn {
padding: 6px 10px;
height: 32px;
font-size: 0.8rem;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 4px 6px rgba(50, 50, 93, 0.11);
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
color: white;
white-space: nowrap;
}
.btn-sm i, .service-btn i {
font-size: 1rem;
}
/* 按钮颜色统一为蓝色 */
.btn-primary,
.service-btn.start-btn,
.service-btn.stop-btn,
.service-btn.restart-btn {
background: linear-gradient(135deg, #3498db, #2980b9);
}
/* 按钮悬停效果 */
.btn-primary:hover,
.service-btn:hover {
transform: translateY(-2px);
box-shadow: 0 7px 14px rgba(50, 50, 93, 0.18);
filter: brightness(1.1);
}
/* 按钮点击效果 */
.btn-primary:active,
.service-btn:active {
transform: translateY(1px);
}
/* 服务控制按钮组样式 */
.service-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
/* 进度条样式 */
.progress {
height: 5px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.progress-bar {
background: linear-gradient(90deg, var(--primary-color), #5dade2);
box-shadow: 0 0 10px rgba(52, 152, 219, 0.5);
}
/* 卡片图标样式 */
.card-icon {
position: absolute;
top: 15px;
right: 15px;
font-size: 1.2rem;
color: var(--primary-color);
opacity: 0.15;
transform: scale(2);
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.card.status-card, .card.service-control {
background: linear-gradient(165deg, #2d2d2d, #252525);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.card-title, .service-title {
color: #e0e0e0;
}
.card-title i, .service-title i {
background: rgba(52, 152, 219, 0.15);
}
.card-text {
color: #ffffff;
}
.card-icon {
color: var(--primary-color);
opacity: 0.1;
}
.progress {
background: rgba(255, 255, 255, 0.1);
}
.btn-sm, .service-btn {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
.btn-sm:hover, .service-btn:hover {
box-shadow: 0 7px 14px rgba(0, 0, 0, 0.3);
}
}
/* 响应式布局 */
@media (max-width: 768px) {
.status-cards {
grid-template-columns: 1fr;
gap: 20px;
}
.card.status-card, .card.service-control {
height: auto;
min-height: 140px;
}
.service-actions {
margin-top: 0.5rem;
}
.btn-sm, .service-btn {
padding: 6px;
}
}
/* 服务控制卡片特殊样式 */
.service-control .card-body {
padding: 0;
justify-content: space-between;
}
.service-control .service-title {
margin-bottom: 1rem;
font-size: 1.1rem;
}
.service-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
margin-top: auto;
}
/* 服务控制按钮样式优化 */
.service-btn {
padding: 6px 10px;
min-height: 32px;
font-size: 0.8rem;
}
.service-btn .btn-content {
white-space: nowrap;
}
.service-btn i {
font-size: 1rem;
}
/* 添加信息卡片样式 */
.info-card {
background: linear-gradient(165deg, #ffffff, #f8f9fa);
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.8);
padding: 10px;
height: 100%;
min-height: 120px;
}
.info-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* 地址容器样式 */
.address-container {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 0.4rem;
}
.address-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.05);
min-height: 28px;
}
.address-label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
color: var(--text-color);
min-width: 80px;
font-size: 0.8rem;
}
.address-label i {
color: var(--primary-color);
font-size: 1rem;
}
.address-value {
flex-grow: 1;
font-family: monospace;
font-size: 0.75rem;
color: var(--text-color);
padding: 2px 4px;
background: rgba(0, 0, 0, 0.03);
border-radius: 4px;
}
.copy-btn {
background: none;
border: none;
color: var(--primary-color);
padding: 3px;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 6px;
font-size: 0.85rem;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.copy-btn:hover {
background: rgba(52, 152, 219, 0.1);
transform: scale(1.1);
}
.copy-btn.copy-success {
color: #2ecc71;
background: rgba(46, 204, 113, 0.1);
}
/* 关于信息样式 */
.about-container {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 0.4rem;
}
.about-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.05);
min-height: 28px;
}
.about-item i {
font-size: 1.1rem;
width: 20px;
}
.about-content {
font-size: 0.85rem;
}
.github-link {
color: var(--primary-color);
text-decoration: none;
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
font-size: 0.85rem;
}
.github-link:hover {
text-decoration: underline;
}
.wechat-name {
font-weight: 500;
color: var(--text-color);
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.info-card {
background: linear-gradient(165deg, #2d2d2d, #252525);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.address-item, .about-item {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.address-value {
background: rgba(0, 0, 0, 0.2);
color: #ffffff;
}
.copy-btn:hover {
background: rgba(52, 152, 219, 0.2);
}
.wechat-name {
color: #e0e0e0;
}
}
/* 响应式布局 */
@media (max-width: 768px) {
.info-card {
margin-bottom: 12px;
min-height: auto;
padding: 10px;
}
.address-item, .about-item {
padding: 6px 8px;
}
}
/* 卡片标题样式优化 */
.info-card .card-title {
font-size: 0.95rem;
margin-bottom: 0.6rem;
gap: 6px;
}
.info-card .card-title i {
font-size: 1rem;
padding: 4px;
}
/* 背景图标优化 */
.info-card .card-icon {
font-size: 1.1rem;
transform: scale(1.8);
top: 12px;
right: 12px;
}
/* 标签页卡片统一样式 */
.tab-pane .card {
background: linear-gradient(165deg, #ffffff, #f8f9fa);
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.8);
padding: 20px;
}
.tab-pane .card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* 标签页标题样式 */
.tab-pane .card-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 1.2rem;
display: flex;
align-items: center;
gap: 8px;
}
.tab-pane .card-title i {
font-size: 1.2em;
color: var(--primary-color);
background: rgba(52, 152, 219, 0.1);
padding: 6px;
border-radius: 8px;
}
/* 标签页背景图标 */
.tab-pane .card-icon {
position: absolute;
top: 20px;
right: 20px;
font-size: 1.5rem;
color: var(--primary-color);
opacity: 0.15;
transform: scale(2.5);
}
/* 按钮统一样式 */
.tab-pane .btn {
padding: 8px 16px;
height: 36px;
font-size: 0.85rem;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 4px 6px rgba(50, 50, 93, 0.11);
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
color: white;
white-space: nowrap;
background: linear-gradient(135deg, #3498db, #2980b9);
}
.tab-pane .btn:hover {
transform: translateY(-2px);
box-shadow: 0 7px 14px rgba(50, 50, 93, 0.18);
filter: brightness(1.1);
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.tab-pane .card {
background: linear-gradient(165deg, #2d2d2d, #252525);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.tab-pane .card-title {
color: #e0e0e0;
}
.tab-pane .card-title i {
background: rgba(52, 152, 219, 0.15);
}
}
/* 代理管理标签页样式 */
.proxy-management {
display: flex;
flex-direction: column;
gap: 1rem;
}
.proxy-actions {
display: flex;
gap: 12px;
margin-top: 1rem;
}
.proxy-actions .btn {
padding: 10px 20px;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 120px;
justify-content: center;
border-radius: 8px;
transition: all 0.3s ease;
}
.proxy-actions .btn i {
font-size: 1rem;
}
/* IP名单标签页样式 */
.ip-lists-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.ip-lists-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 1rem;
}
.ip-list-section {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.ip-list-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.5rem;
}
.ip-auth-settings {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.ip-lists-actions {
display: flex;
gap: 12px;
margin-top: 1rem;
}
.ip-lists-actions .btn {
padding: 10px 20px;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 120px;
justify-content: center;
border-radius: 8px;
transition: all 0.3s ease;
}
/* 按钮悬停效果 */
.proxy-actions .btn:hover,
.ip-lists-actions .btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.ip-list-title {
color: #e0e0e0;
}
.ip-auth-settings {
border-top-color: rgba(255, 255, 255, 0.1);
}
}
/* 代理列表区域样式 */
.proxy-lists {
background: rgba(255, 255, 255, 0.5);
border-radius: 12px;
padding: 15px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.proxy-list-section {
margin-bottom: 1rem;
}
.proxy-list-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-weight: 500;
color: var(--text-color);
}
.proxy-list-header i {
color: var(--primary-color);
font-size: 1.1rem;
}
.proxy-list-help {
margin-left: auto;
font-size: 0.85rem;
color: #666;
font-weight: normal;
}
.proxy-list-section textarea {
font-family: monospace;
font-size: 0.9rem;
resize: vertical;
background: rgba(255, 255, 255, 0.8);
}
.proxy-list-section textarea:focus {
background: white;
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.proxy-lists {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.proxy-list-header {
color: #e0e0e0;
}
.proxy-list-help {
color: #888;
}
.proxy-list-section textarea {
background: rgba(0, 0, 0, 0.2);
color: #e0e0e0;
}
.proxy-list-section textarea:focus {
background: rgba(0, 0, 0, 0.3);
}
}
/* 地址显示样式 */
.address-value {
font-family: monospace;
background: rgba(0, 0, 0, 0.05);
padding: 6px 10px;
border-radius: 4px;
font-size: 0.9rem;
color: var(--text-color);
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.address-value {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.copy-btn:hover {
background: rgba(52, 152, 219, 0.2);
}
.copy-btn.copy-success {
background: rgba(46, 204, 113, 0.2);
}
}
/* 服务状态样式 */
.service-status {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 0.7rem;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
transition: background-color 0.3s ease;
}
.status-dot.running {
background-color: #2ecc71;
box-shadow: 0 0 10px rgba(46, 204, 113, 0.5);
animation: pulse 2s infinite;
}
.status-dot.stopped {
background-color: #e74c3c;
box-shadow: 0 0 10px rgba(231, 76, 60, 0.5);
animation: none;
}
.status-text {
font-weight: 600;
color: var(--text-color);
transition: color 0.3s ease;
}
/* 添加脉冲动画 */
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.2); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
/* 服务控制卡片的按钮容器 */
.status-card .service-actions {
display: flex;
gap: 8px;
justify-content: center;
margin-top: auto;
}
/* 服务控制按钮基础样式 */
.status-card .service-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 12px;
border: none;
border-radius: 6px;
color: white;
transition: all 0.3s ease;
font-size: 0.85rem;
min-width: 70px;
height: 32px;
background: linear-gradient(135deg, #3498db, #2980b9);
}
/* 服务控制按钮交互效果 */
.status-card .service-btn:hover {
transform: translateY(-2px);
filter: brightness(1.1);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
}
.status-card .service-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
background: linear-gradient(135deg, #95a5a6, #7f8c8d);
}
/* 响应式布局 */
@media (max-width: 768px) {
.status-card .service-actions {
flex-direction: column;
gap: 8px;
}
.status-card .service-btn {
width: 100%;
}
}
/* 添加过渡效果 */
.status-text {
transition: color 0.3s ease;
}
/* 优化按钮状态过渡 */
.service-btn {
transition: all 0.3s ease;
}
.service-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}

BIN
web/static/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

File diff suppressed because it is too large Load Diff