Update 2.0.1
Before Width: | Height: | Size: 222 KiB |
Before Width: | Height: | Size: 106 KiB |
Before Width: | Height: | Size: 76 KiB |
Before Width: | Height: | Size: 151 KiB |
Before Width: | Height: | Size: 691 KiB |
After Width: | Height: | Size: 2.3 MiB |
After Width: | Height: | Size: 1.5 MiB |
After Width: | Height: | Size: 1.2 MiB |
@ -165,15 +165,11 @@ neko=123456
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## 问题Q&A
|
||||
|
||||
|
@ -1,3 +1,9 @@
|
||||
### 2025/03/03
|
||||
|
||||
- 美化 Web管理界面
|
||||
- 修复大量 BUG
|
||||
- 添加更多处理小脚本辅助使用
|
||||
|
||||
### 2025/02/21
|
||||
|
||||
- 增加 Web 管理界面
|
||||
|
235
ProxyCat.py
@ -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()
|
||||
|
312
README-EN.md
@ -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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
[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
|
||||
|
||||

|
||||
## 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! | (´∀`)♡ |
|
||||
|
||||
---
|
||||

|
||||
|
||||
## Proxy Recommendations
|
||||
|
||||
|
264
app.py
@ -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)
|
@ -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"
|
@ -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}")
|
||||
|
@ -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
|
25
web/static/css/animations.css
Normal 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
@ -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);
|
||||
}
|
45
web/static/css/buttons.css
Normal 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);
|
||||
}
|
61
web/static/css/dark-mode.css
Normal 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
@ -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
@ -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;
|
||||
}
|
33
web/static/css/nav-tabs.css
Normal 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);
|
||||
}
|
44
web/static/css/progress.css
Normal 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%);
|
||||
}
|
||||
}
|
41
web/static/css/responsive.css
Normal 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
@ -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;
|
||||
}
|
80
web/static/css/service-control.css
Normal 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;
|
||||
}
|
||||
}
|
827
web/static/css/status-card.css
Normal 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
After Width: | Height: | Size: 363 KiB |