工具助手添加
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,50 @@
|
|||||||
|
"""
|
||||||
|
HTTP 接口测试工具
|
||||||
|
Flask 应用入口
|
||||||
|
|
||||||
|
@author huazm
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# 添加项目根目录到 Python 路径
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from flask import Flask, render_template
|
||||||
|
from flask_cors import CORS
|
||||||
|
from config import Config
|
||||||
|
from models import db
|
||||||
|
from routes import api_bp
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
"""创建 Flask 应用"""
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(Config)
|
||||||
|
|
||||||
|
# 初始化扩展
|
||||||
|
CORS(app)
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
# 注册蓝图
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
|
||||||
|
# 创建数据库表
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
# 主页路由
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# 创建应用实例
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='0.0.0.0', port=5050, debug=True)
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
配置文件
|
||||||
|
HTTP 接口测试工具的配置项
|
||||||
|
|
||||||
|
@author huazm
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# 基础路径
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DATA_DIR = os.path.join(BASE_DIR, 'data')
|
||||||
|
|
||||||
|
# 确保数据目录存在
|
||||||
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""基础配置类"""
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY') or 'api-tester-secret-key-2024'
|
||||||
|
SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(DATA_DIR, "api_tester.db")}'
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
|
# HTTP 客户端配置
|
||||||
|
REQUEST_TIMEOUT = 30 # 请求超时时间(秒)
|
||||||
|
MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 最大响应大小(10MB)
|
||||||
|
|
||||||
|
# 历史记录配置
|
||||||
|
MAX_HISTORY_RECORDS = 1000 # 最大历史记录数
|
||||||
|
|
||||||
|
# 批量请求配置
|
||||||
|
MAX_BATCH_SIZE = 100 # 最大批量请求数
|
||||||
|
DEFAULT_BATCH_INTERVAL = 100 # 默认请求间隔(毫秒)
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
数据模型模块
|
||||||
|
|
||||||
|
@author huazm
|
||||||
|
"""
|
||||||
|
from .database import db, RequestHistory, Collection, Environment
|
||||||
|
|
||||||
|
__all__ = ['db', 'RequestHistory', 'Collection', 'Environment']
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
数据库模型定义
|
||||||
|
包含请求历史、收藏夹、环境变量等模型
|
||||||
|
|
||||||
|
@author huazm
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
|
||||||
|
class RequestHistory(db.Model):
|
||||||
|
"""请求历史记录模型"""
|
||||||
|
__tablename__ = 'request_history'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||||
|
method = db.Column(db.String(10), nullable=False)
|
||||||
|
url = db.Column(db.Text, nullable=False)
|
||||||
|
headers = db.Column(db.Text) # JSON 格式
|
||||||
|
params = db.Column(db.Text) # JSON 格式
|
||||||
|
body = db.Column(db.Text)
|
||||||
|
body_type = db.Column(db.String(20)) # json, form, xml, raw
|
||||||
|
auth_type = db.Column(db.String(20)) # bearer, basic, apikey, oauth2
|
||||||
|
auth_config = db.Column(db.Text) # JSON 格式
|
||||||
|
|
||||||
|
# 响应信息
|
||||||
|
response_body = db.Column(db.Text)
|
||||||
|
response_headers = db.Column(db.Text) # JSON 格式
|
||||||
|
status_code = db.Column(db.Integer)
|
||||||
|
duration = db.Column(db.Float) # 请求耗时(毫秒)
|
||||||
|
|
||||||
|
# 元信息
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""转换为字典"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'method': self.method,
|
||||||
|
'url': self.url,
|
||||||
|
'headers': self.headers,
|
||||||
|
'params': self.params,
|
||||||
|
'body': self.body,
|
||||||
|
'bodyType': self.body_type,
|
||||||
|
'authType': self.auth_type,
|
||||||
|
'authConfig': self.auth_config,
|
||||||
|
'responseBody': self.response_body,
|
||||||
|
'responseHeaders': self.response_headers,
|
||||||
|
'statusCode': self.status_code,
|
||||||
|
'duration': self.duration,
|
||||||
|
'createdAt': self.created_at.isoformat() if self.created_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Collection(db.Model):
|
||||||
|
"""收藏夹模型"""
|
||||||
|
__tablename__ = 'collections'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
description = db.Column(db.Text)
|
||||||
|
folder = db.Column(db.String(100)) # 文件夹分组
|
||||||
|
|
||||||
|
method = db.Column(db.String(10), nullable=False)
|
||||||
|
url = db.Column(db.Text, nullable=False)
|
||||||
|
headers = db.Column(db.Text)
|
||||||
|
params = db.Column(db.Text)
|
||||||
|
body = db.Column(db.Text)
|
||||||
|
body_type = db.Column(db.String(20))
|
||||||
|
auth_type = db.Column(db.String(20))
|
||||||
|
auth_config = db.Column(db.Text)
|
||||||
|
|
||||||
|
tags = db.Column(db.Text) # JSON 数组
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""转换为字典"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'description': self.description,
|
||||||
|
'folder': self.folder,
|
||||||
|
'method': self.method,
|
||||||
|
'url': self.url,
|
||||||
|
'headers': self.headers,
|
||||||
|
'params': self.params,
|
||||||
|
'body': self.body,
|
||||||
|
'bodyType': self.body_type,
|
||||||
|
'authType': self.auth_type,
|
||||||
|
'authConfig': self.auth_config,
|
||||||
|
'tags': self.tags,
|
||||||
|
'createdAt': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'updatedAt': self.updated_at.isoformat() if self.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Environment(db.Model):
|
||||||
|
"""环境变量模型"""
|
||||||
|
__tablename__ = 'environments'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = db.Column(db.String(50), nullable=False, unique=True)
|
||||||
|
variables = db.Column(db.Text) # JSON 格式 {"key": "value"}
|
||||||
|
is_active = db.Column(db.Boolean, default=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""转换为字典"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'variables': self.variables,
|
||||||
|
'isActive': self.is_active,
|
||||||
|
'createdAt': self.created_at.isoformat() if self.created_at else None
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Flask 核心
|
||||||
|
Flask==3.0.0
|
||||||
|
Flask-CORS==4.0.0
|
||||||
|
|
||||||
|
# HTTP 客户端
|
||||||
|
requests==2.31.0
|
||||||
|
httpx==0.25.2
|
||||||
|
|
||||||
|
# 数据库
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
|
||||||
|
# 工具
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
路由模块
|
||||||
|
|
||||||
|
@author huazm
|
||||||
|
"""
|
||||||
|
from .api import api_bp
|
||||||
|
|
||||||
|
__all__ = ['api_bp']
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,324 @@
|
|||||||
|
"""
|
||||||
|
API 路由
|
||||||
|
处理 HTTP 请求、历史记录、收藏夹等 API
|
||||||
|
|
||||||
|
@author huazm
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from models import db, RequestHistory, Collection, Environment
|
||||||
|
from services import HttpClient
|
||||||
|
|
||||||
|
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||||
|
http_client = HttpClient()
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/request', methods=['POST'])
|
||||||
|
def send_request():
|
||||||
|
"""发送 HTTP 请求"""
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or not data.get('url'):
|
||||||
|
return jsonify({'success': False, 'error': '请提供 URL'}), 400
|
||||||
|
|
||||||
|
# 解析请求参数
|
||||||
|
method = data.get('method', 'GET')
|
||||||
|
url = data.get('url')
|
||||||
|
headers = data.get('headers')
|
||||||
|
params = data.get('params')
|
||||||
|
body = data.get('body')
|
||||||
|
body_type = data.get('bodyType', 'json')
|
||||||
|
auth_type = data.get('authType')
|
||||||
|
auth_config = data.get('authConfig')
|
||||||
|
|
||||||
|
# 解析 headers 和 params(如果是字符串)
|
||||||
|
if isinstance(headers, str):
|
||||||
|
try:
|
||||||
|
headers = json.loads(headers)
|
||||||
|
except:
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
if isinstance(params, str):
|
||||||
|
try:
|
||||||
|
params = json.loads(params)
|
||||||
|
except:
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
if isinstance(auth_config, str):
|
||||||
|
try:
|
||||||
|
auth_config = json.loads(auth_config)
|
||||||
|
except:
|
||||||
|
auth_config = {}
|
||||||
|
|
||||||
|
# 发送请求
|
||||||
|
result = http_client.send_request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
params=params,
|
||||||
|
body=body,
|
||||||
|
body_type=body_type,
|
||||||
|
auth_type=auth_type,
|
||||||
|
auth_config=auth_config
|
||||||
|
)
|
||||||
|
|
||||||
|
# 保存到历史记录
|
||||||
|
if data.get('saveHistory', True):
|
||||||
|
history = RequestHistory(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
headers=json.dumps(headers) if headers else None,
|
||||||
|
params=json.dumps(params) if params else None,
|
||||||
|
body=body,
|
||||||
|
body_type=body_type,
|
||||||
|
auth_type=auth_type,
|
||||||
|
auth_config=json.dumps(auth_config) if auth_config else None,
|
||||||
|
response_body=result.get('body'),
|
||||||
|
response_headers=json.dumps(result.get('headers')) if result.get('headers') else None,
|
||||||
|
status_code=result.get('statusCode'),
|
||||||
|
duration=result.get('duration')
|
||||||
|
)
|
||||||
|
db.session.add(history)
|
||||||
|
db.session.commit()
|
||||||
|
result['historyId'] = history.id
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/batch', methods=['POST'])
|
||||||
|
def batch_request():
|
||||||
|
"""批量发送请求"""
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or not data.get('requests'):
|
||||||
|
return jsonify({'success': False, 'error': '请提供请求列表'}), 400
|
||||||
|
|
||||||
|
requests_list = data.get('requests', [])
|
||||||
|
interval = data.get('interval', 100)
|
||||||
|
|
||||||
|
results = http_client.batch_request(requests_list, interval)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'results': results,
|
||||||
|
'total': len(results),
|
||||||
|
'successCount': sum(1 for r in results if r.get('success')),
|
||||||
|
'failCount': sum(1 for r in results if not r.get('success'))
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/history', methods=['GET'])
|
||||||
|
def get_history():
|
||||||
|
"""获取历史记录"""
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
size = request.args.get('size', 20, type=int)
|
||||||
|
search = request.args.get('search', '')
|
||||||
|
method = request.args.get('method', '')
|
||||||
|
|
||||||
|
query = RequestHistory.query
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query = query.filter(RequestHistory.url.contains(search))
|
||||||
|
if method:
|
||||||
|
query = query.filter(RequestHistory.method == method.upper())
|
||||||
|
|
||||||
|
query = query.order_by(RequestHistory.created_at.desc())
|
||||||
|
pagination = query.paginate(page=page, per_page=size, error_out=False)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': [h.to_dict() for h in pagination.items],
|
||||||
|
'total': pagination.total,
|
||||||
|
'page': page,
|
||||||
|
'size': size
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/history/<int:id>', methods=['DELETE'])
|
||||||
|
def delete_history(id):
|
||||||
|
"""删除历史记录"""
|
||||||
|
history = RequestHistory.query.get(id)
|
||||||
|
if not history:
|
||||||
|
return jsonify({'success': False, 'error': '记录不存在'}), 404
|
||||||
|
|
||||||
|
db.session.delete(history)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/history/clear', methods=['DELETE'])
|
||||||
|
def clear_history():
|
||||||
|
"""清空历史记录"""
|
||||||
|
RequestHistory.query.delete()
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 收藏夹 API ====================
|
||||||
|
|
||||||
|
@api_bp.route('/collections', methods=['GET'])
|
||||||
|
def get_collections():
|
||||||
|
"""获取收藏夹列表"""
|
||||||
|
folder = request.args.get('folder', '')
|
||||||
|
search = request.args.get('search', '')
|
||||||
|
|
||||||
|
query = Collection.query
|
||||||
|
|
||||||
|
if folder:
|
||||||
|
query = query.filter(Collection.folder == folder)
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
(Collection.name.contains(search)) |
|
||||||
|
(Collection.url.contains(search))
|
||||||
|
)
|
||||||
|
|
||||||
|
collections = query.order_by(Collection.updated_at.desc()).all()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': [c.to_dict() for c in collections]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/collections', methods=['POST'])
|
||||||
|
def save_collection():
|
||||||
|
"""保存到收藏夹"""
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or not data.get('name') or not data.get('url'):
|
||||||
|
return jsonify({'success': False, 'error': '请提供名称和 URL'}), 400
|
||||||
|
|
||||||
|
collection = Collection(
|
||||||
|
name=data.get('name'),
|
||||||
|
description=data.get('description'),
|
||||||
|
folder=data.get('folder'),
|
||||||
|
method=data.get('method', 'GET'),
|
||||||
|
url=data.get('url'),
|
||||||
|
headers=json.dumps(data.get('headers')) if data.get('headers') else None,
|
||||||
|
params=json.dumps(data.get('params')) if data.get('params') else None,
|
||||||
|
body=data.get('body'),
|
||||||
|
body_type=data.get('bodyType'),
|
||||||
|
auth_type=data.get('authType'),
|
||||||
|
auth_config=json.dumps(data.get('authConfig')) if data.get('authConfig') else None,
|
||||||
|
tags=json.dumps(data.get('tags')) if data.get('tags') else None
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(collection)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'data': collection.to_dict()})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/collections/<int:id>', methods=['DELETE'])
|
||||||
|
def delete_collection(id):
|
||||||
|
"""删除收藏"""
|
||||||
|
collection = Collection.query.get(id)
|
||||||
|
if not collection:
|
||||||
|
return jsonify({'success': False, 'error': '收藏不存在'}), 404
|
||||||
|
|
||||||
|
db.session.delete(collection)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 环境变量 API ====================
|
||||||
|
|
||||||
|
@api_bp.route('/environments', methods=['GET'])
|
||||||
|
def get_environments():
|
||||||
|
"""获取环境变量列表"""
|
||||||
|
environments = Environment.query.all()
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': [e.to_dict() for e in environments]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/environments', methods=['POST'])
|
||||||
|
def save_environment():
|
||||||
|
"""保存环境变量"""
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or not data.get('name'):
|
||||||
|
return jsonify({'success': False, 'error': '请提供环境名称'}), 400
|
||||||
|
|
||||||
|
env = Environment(
|
||||||
|
name=data.get('name'),
|
||||||
|
variables=json.dumps(data.get('variables', {})),
|
||||||
|
is_active=data.get('isActive', False)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 如果设为激活,取消其他环境的激活状态
|
||||||
|
if env.is_active:
|
||||||
|
Environment.query.update({Environment.is_active: False})
|
||||||
|
|
||||||
|
db.session.add(env)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'data': env.to_dict()})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/environments/<int:id>/activate', methods=['POST'])
|
||||||
|
def activate_environment(id):
|
||||||
|
"""激活环境"""
|
||||||
|
env = Environment.query.get(id)
|
||||||
|
if not env:
|
||||||
|
return jsonify({'success': False, 'error': '环境不存在'}), 404
|
||||||
|
|
||||||
|
Environment.query.update({Environment.is_active: False})
|
||||||
|
env.is_active = True
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 导入功能 API ====================
|
||||||
|
|
||||||
|
@api_bp.route('/import/curl', methods=['POST'])
|
||||||
|
def import_curl():
|
||||||
|
"""导入 cURL 命令"""
|
||||||
|
data = request.get_json()
|
||||||
|
curl_command = data.get('curl', '')
|
||||||
|
|
||||||
|
if not curl_command:
|
||||||
|
return jsonify({'success': False, 'error': '请提供 cURL 命令'}), 400
|
||||||
|
|
||||||
|
parsed = parse_curl(curl_command)
|
||||||
|
return jsonify({'success': True, 'data': parsed})
|
||||||
|
|
||||||
|
|
||||||
|
def parse_curl(curl_command: str) -> dict:
|
||||||
|
"""解析 cURL 命令"""
|
||||||
|
result = {
|
||||||
|
'method': 'GET',
|
||||||
|
'url': '',
|
||||||
|
'headers': {},
|
||||||
|
'body': None
|
||||||
|
}
|
||||||
|
|
||||||
|
# 提取 URL
|
||||||
|
url_match = re.search(r"curl\s+['\"]?([^'\"\s]+)['\"]?", curl_command)
|
||||||
|
if url_match:
|
||||||
|
result['url'] = url_match.group(1)
|
||||||
|
|
||||||
|
# 提取方法
|
||||||
|
method_match = re.search(r'-X\s+(\w+)', curl_command)
|
||||||
|
if method_match:
|
||||||
|
result['method'] = method_match.group(1).upper()
|
||||||
|
|
||||||
|
# 提取 headers
|
||||||
|
header_matches = re.findall(r"-H\s+['\"]([^'\"]+)['\"]", curl_command)
|
||||||
|
for header in header_matches:
|
||||||
|
if ':' in header:
|
||||||
|
key, value = header.split(':', 1)
|
||||||
|
result['headers'][key.strip()] = value.strip()
|
||||||
|
|
||||||
|
# 提取 body
|
||||||
|
body_match = re.search(r"(?:-d|--data|--data-raw)\s+['\"]([^'\"]+)['\"]", curl_command)
|
||||||
|
if body_match:
|
||||||
|
result['body'] = body_match.group(1)
|
||||||
|
if result['method'] == 'GET':
|
||||||
|
result['method'] = 'POST'
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
服务模块
|
||||||
|
|
||||||
|
@author huazm
|
||||||
|
"""
|
||||||
|
from .http_client import HttpClient
|
||||||
|
|
||||||
|
__all__ = ['HttpClient']
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,194 @@
|
|||||||
|
"""
|
||||||
|
HTTP 客户端服务
|
||||||
|
处理 HTTP 请求发送和响应处理
|
||||||
|
|
||||||
|
@author huazm
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import httpx
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
|
||||||
|
class HttpClient:
|
||||||
|
"""HTTP 客户端,支持多种请求方法和认证方式"""
|
||||||
|
|
||||||
|
def __init__(self, timeout: int = 30):
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def send_request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
params: Optional[Dict[str, str]] = None,
|
||||||
|
body: Optional[str] = None,
|
||||||
|
body_type: str = 'json',
|
||||||
|
auth_type: Optional[str] = None,
|
||||||
|
auth_config: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
发送 HTTP 请求
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: 请求方法 (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)
|
||||||
|
url: 请求 URL
|
||||||
|
headers: 请求头
|
||||||
|
params: URL 参数
|
||||||
|
body: 请求体
|
||||||
|
body_type: 请求体类型 (json, form, xml, raw)
|
||||||
|
auth_type: 认证类型 (bearer, basic, apikey, oauth2)
|
||||||
|
auth_config: 认证配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含响应信息的字典
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# 初始化请求头
|
||||||
|
request_headers = dict(headers) if headers else {}
|
||||||
|
|
||||||
|
# 处理认证
|
||||||
|
self._apply_auth(request_headers, auth_type, auth_config, params)
|
||||||
|
|
||||||
|
# 处理请求体
|
||||||
|
content = None
|
||||||
|
data = None
|
||||||
|
|
||||||
|
if body and method.upper() not in ['GET', 'HEAD', 'OPTIONS']:
|
||||||
|
if body_type == 'json':
|
||||||
|
request_headers.setdefault('Content-Type', 'application/json')
|
||||||
|
content = body
|
||||||
|
elif body_type == 'form':
|
||||||
|
request_headers.setdefault('Content-Type', 'application/x-www-form-urlencoded')
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
except:
|
||||||
|
data = body
|
||||||
|
elif body_type == 'xml':
|
||||||
|
request_headers.setdefault('Content-Type', 'application/xml')
|
||||||
|
content = body
|
||||||
|
else:
|
||||||
|
content = body
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=self.timeout, verify=False, follow_redirects=True) as client:
|
||||||
|
response = client.request(
|
||||||
|
method=method.upper(),
|
||||||
|
url=url,
|
||||||
|
headers=request_headers,
|
||||||
|
params=params,
|
||||||
|
content=content,
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
duration = (time.time() - start_time) * 1000 # 转换为毫秒
|
||||||
|
|
||||||
|
# 尝试解析响应体
|
||||||
|
try:
|
||||||
|
response_body = response.text
|
||||||
|
except:
|
||||||
|
response_body = str(response.content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'statusCode': response.status_code,
|
||||||
|
'headers': dict(response.headers),
|
||||||
|
'body': response_body,
|
||||||
|
'duration': round(duration, 2),
|
||||||
|
'size': len(response.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': '请求超时',
|
||||||
|
'duration': round((time.time() - start_time) * 1000, 2)
|
||||||
|
}
|
||||||
|
except httpx.ConnectError as e:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': f'连接失败: {str(e)}',
|
||||||
|
'duration': round((time.time() - start_time) * 1000, 2)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': f'请求错误: {str(e)}',
|
||||||
|
'duration': round((time.time() - start_time) * 1000, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
def _apply_auth(
|
||||||
|
self,
|
||||||
|
headers: Dict[str, str],
|
||||||
|
auth_type: Optional[str],
|
||||||
|
auth_config: Optional[Dict[str, Any]],
|
||||||
|
params: Optional[Dict[str, str]]
|
||||||
|
):
|
||||||
|
"""应用认证配置到请求头或参数"""
|
||||||
|
if not auth_type or not auth_config:
|
||||||
|
return
|
||||||
|
|
||||||
|
if auth_type == 'bearer':
|
||||||
|
token = auth_config.get('token', '')
|
||||||
|
headers['Authorization'] = f'Bearer {token}'
|
||||||
|
|
||||||
|
elif auth_type == 'basic':
|
||||||
|
username = auth_config.get('username', '')
|
||||||
|
password = auth_config.get('password', '')
|
||||||
|
credentials = base64.b64encode(f'{username}:{password}'.encode()).decode()
|
||||||
|
headers['Authorization'] = f'Basic {credentials}'
|
||||||
|
|
||||||
|
elif auth_type == 'apikey':
|
||||||
|
key = auth_config.get('key', '')
|
||||||
|
value = auth_config.get('value', '')
|
||||||
|
location = auth_config.get('location', 'header') # header 或 query
|
||||||
|
|
||||||
|
if location == 'header':
|
||||||
|
headers[key] = value
|
||||||
|
elif location == 'query' and params is not None:
|
||||||
|
params[key] = value
|
||||||
|
|
||||||
|
elif auth_type == 'oauth2':
|
||||||
|
token = auth_config.get('accessToken', '')
|
||||||
|
headers['Authorization'] = f'Bearer {token}'
|
||||||
|
|
||||||
|
def batch_request(
|
||||||
|
self,
|
||||||
|
requests: List[Dict[str, Any]],
|
||||||
|
interval: int = 100
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
批量发送请求
|
||||||
|
|
||||||
|
Args:
|
||||||
|
requests: 请求配置列表
|
||||||
|
interval: 请求间隔(毫秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
响应结果列表
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for i, req in enumerate(requests):
|
||||||
|
result = self.send_request(
|
||||||
|
method=req.get('method', 'GET'),
|
||||||
|
url=req.get('url', ''),
|
||||||
|
headers=req.get('headers'),
|
||||||
|
params=req.get('params'),
|
||||||
|
body=req.get('body'),
|
||||||
|
body_type=req.get('bodyType', 'json'),
|
||||||
|
auth_type=req.get('authType'),
|
||||||
|
auth_config=req.get('authConfig')
|
||||||
|
)
|
||||||
|
result['index'] = i
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
# 请求间隔
|
||||||
|
if interval > 0 and i < len(requests) - 1:
|
||||||
|
time.sleep(interval / 1000)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
@@ -0,0 +1,894 @@
|
|||||||
|
/**
|
||||||
|
* API Tester - 科技感未来主题样式
|
||||||
|
* @author huazm
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== CSS 变量和主题 ==================== */
|
||||||
|
:root {
|
||||||
|
/* 默认主题: 深蓝科技 */
|
||||||
|
--bg-primary: #0a0e17;
|
||||||
|
--bg-secondary: #111827;
|
||||||
|
--bg-tertiary: #1f2937;
|
||||||
|
--bg-hover: #374151;
|
||||||
|
|
||||||
|
--text-primary: #f3f4f6;
|
||||||
|
--text-secondary: #9ca3af;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
|
||||||
|
--accent-primary: #3b82f6;
|
||||||
|
--accent-secondary: #60a5fa;
|
||||||
|
--accent-glow: rgba(59, 130, 246, 0.5);
|
||||||
|
|
||||||
|
--success: #10b981;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--error: #ef4444;
|
||||||
|
--info: #06b6d4;
|
||||||
|
|
||||||
|
--border-color: #374151;
|
||||||
|
--border-glow: rgba(59, 130, 246, 0.3);
|
||||||
|
|
||||||
|
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
|
||||||
|
--shadow-glow: 0 0 20px var(--accent-glow);
|
||||||
|
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题: 紫色星云 */
|
||||||
|
[data-theme="nebula"] {
|
||||||
|
--bg-primary: #0f0a1a;
|
||||||
|
--bg-secondary: #1a1028;
|
||||||
|
--bg-tertiary: #2d1f4a;
|
||||||
|
--accent-primary: #a855f7;
|
||||||
|
--accent-secondary: #c084fc;
|
||||||
|
--accent-glow: rgba(168, 85, 247, 0.5);
|
||||||
|
--border-glow: rgba(168, 85, 247, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题: 翠绿矩阵 */
|
||||||
|
[data-theme="matrix"] {
|
||||||
|
--bg-primary: #0a0f0a;
|
||||||
|
--bg-secondary: #0f1a0f;
|
||||||
|
--bg-tertiary: #1a2f1a;
|
||||||
|
--accent-primary: #22c55e;
|
||||||
|
--accent-secondary: #4ade80;
|
||||||
|
--accent-glow: rgba(34, 197, 94, 0.5);
|
||||||
|
--border-glow: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题: 赛博橙 */
|
||||||
|
[data-theme="cyber"] {
|
||||||
|
--bg-primary: #0f0a05;
|
||||||
|
--bg-secondary: #1a1008;
|
||||||
|
--bg-tertiary: #2f1f0a;
|
||||||
|
--accent-primary: #f97316;
|
||||||
|
--accent-secondary: #fb923c;
|
||||||
|
--accent-glow: rgba(249, 115, 22, 0.5);
|
||||||
|
--border-glow: rgba(249, 115, 22, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题: 冰霜白 */
|
||||||
|
[data-theme="frost"] {
|
||||||
|
--bg-primary: #f0f4f8;
|
||||||
|
--bg-secondary: #e2e8f0;
|
||||||
|
--bg-tertiary: #cbd5e1;
|
||||||
|
--bg-hover: #94a3b8;
|
||||||
|
--text-primary: #1e293b;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--accent-primary: #0ea5e9;
|
||||||
|
--accent-secondary: #38bdf8;
|
||||||
|
--border-color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 基础样式 ==================== */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 粒子背景 ==================== */
|
||||||
|
#particles-canvas {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 应用容器 ==================== */
|
||||||
|
.app-container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 头部导航 ==================== */
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: linear-gradient(180deg, var(--bg-secondary) 0%, transparent 100%);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--accent-primary),
|
||||||
|
var(--accent-secondary)
|
||||||
|
);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn.active {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn,
|
||||||
|
.voice-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn:hover,
|
||||||
|
.voice-btn:hover {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-btn.recording {
|
||||||
|
animation: recording 1s infinite;
|
||||||
|
border-color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes recording {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 10px rgba(239, 68, 68, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 主内容区 ==================== */
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 1800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.app-main {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 面板通用样式 ==================== */
|
||||||
|
.panel {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== URL 栏 ==================== */
|
||||||
|
.url-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--bg-tertiary) 0%,
|
||||||
|
var(--bg-secondary) 100%
|
||||||
|
);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-select {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 100px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 发送按钮 ==================== */
|
||||||
|
.send-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--accent-primary),
|
||||||
|
var(--accent-secondary)
|
||||||
|
);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.2),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
transition: 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:hover::before {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 20px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn.loading {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn.loading .btn-text {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn.loading::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid white;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 标签页 ==================== */
|
||||||
|
.request-tabs,
|
||||||
|
.response-tabs {
|
||||||
|
display: flex;
|
||||||
|
padding: 0 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
border-bottom-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 内容区 ==================== */
|
||||||
|
.request-content,
|
||||||
|
.response-content {
|
||||||
|
padding: 20px;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Key-Value 编辑器 ==================== */
|
||||||
|
.kv-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-key,
|
||||||
|
.kv-value {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-key:focus,
|
||||||
|
.kv-value:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-remove {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-remove:hover {
|
||||||
|
background: var(--error);
|
||||||
|
border-color: var(--error);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-row-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-row-btn:hover {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Header 预设 ==================== */
|
||||||
|
.header-presets-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-btn {
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-btn:hover {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-btn.auth-preset {
|
||||||
|
border-color: var(--warning);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-btn.auth-preset:hover {
|
||||||
|
background: var(--warning);
|
||||||
|
border-color: var(--warning);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Body 编辑器 ==================== */
|
||||||
|
.body-type-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-type-selector label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-type-selector input[type="radio"] {
|
||||||
|
accent-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-editor-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-editor {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
resize: vertical;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-editor:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-btn:hover {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Auth 配置 ==================== */
|
||||||
|
.auth-type-selector {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-type-selector select {
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-config {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input-group label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input-group input {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 响应面板 ==================== */
|
||||||
|
.response-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-code.success {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
.status-code.redirect {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
.status-code.error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-body,
|
||||||
|
.response-headers-view {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 历史记录面板 ==================== */
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--error);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-method {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-method.get {
|
||||||
|
background: #10b98120;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
.history-method.post {
|
||||||
|
background: #3b82f620;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
.history-method.put {
|
||||||
|
background: #f59e0b20;
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
.history-method.delete {
|
||||||
|
background: #ef444420;
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
.history-method.patch {
|
||||||
|
background: #a855f720;
|
||||||
|
color: #a855f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-url {
|
||||||
|
flex: 1;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-status {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 滚动条样式 ==================== */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
/**
|
||||||
|
* API Tester - 前端应用主逻辑
|
||||||
|
* @author huazm
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== 粒子背景 ====================
|
||||||
|
class ParticleBackground {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
this.particles = [];
|
||||||
|
this.particleCount = 80;
|
||||||
|
this.resize();
|
||||||
|
this.init();
|
||||||
|
this.animate();
|
||||||
|
window.addEventListener('resize', () => this.resize());
|
||||||
|
}
|
||||||
|
|
||||||
|
resize() {
|
||||||
|
this.canvas.width = window.innerWidth;
|
||||||
|
this.canvas.height = window.innerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.particles = [];
|
||||||
|
for (let i = 0; i < this.particleCount; i++) {
|
||||||
|
this.particles.push({
|
||||||
|
x: Math.random() * this.canvas.width,
|
||||||
|
y: Math.random() * this.canvas.height,
|
||||||
|
vx: (Math.random() - 0.5) * 0.5,
|
||||||
|
vy: (Math.random() - 0.5) * 0.5,
|
||||||
|
size: Math.random() * 2 + 1,
|
||||||
|
opacity: Math.random() * 0.5 + 0.2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animate() {
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
// 获取当前主题色
|
||||||
|
const style = getComputedStyle(document.documentElement);
|
||||||
|
const accentColor = style.getPropertyValue('--accent-primary').trim() || '#3b82f6';
|
||||||
|
|
||||||
|
this.particles.forEach((p, i) => {
|
||||||
|
// 更新位置
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
|
||||||
|
// 边界检测
|
||||||
|
if (p.x < 0 || p.x > this.canvas.width) p.vx *= -1;
|
||||||
|
if (p.y < 0 || p.y > this.canvas.height) p.vy *= -1;
|
||||||
|
|
||||||
|
// 绘制粒子
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
this.ctx.fillStyle = accentColor.replace(')', `, ${p.opacity})`).replace('rgb', 'rgba');
|
||||||
|
this.ctx.fill();
|
||||||
|
|
||||||
|
// 连接附近粒子
|
||||||
|
for (let j = i + 1; j < this.particles.length; j++) {
|
||||||
|
const p2 = this.particles[j];
|
||||||
|
const dx = p.x - p2.x;
|
||||||
|
const dy = p.y - p2.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (dist < 150) {
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(p.x, p.y);
|
||||||
|
this.ctx.lineTo(p2.x, p2.y);
|
||||||
|
this.ctx.strokeStyle = accentColor.replace(')', `, ${0.1 * (1 - dist / 150)})`).replace('rgb', 'rgba');
|
||||||
|
this.ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
requestAnimationFrame(() => this.animate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 主题管理 ====================
|
||||||
|
const themes = ['default', 'nebula', 'matrix', 'cyber', 'frost'];
|
||||||
|
let currentThemeIndex = 0;
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
currentThemeIndex = (currentThemeIndex + 1) % themes.length;
|
||||||
|
const theme = themes[currentThemeIndex];
|
||||||
|
|
||||||
|
if (theme === 'default') {
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('api-tester-theme', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTheme() {
|
||||||
|
const saved = localStorage.getItem('api-tester-theme');
|
||||||
|
if (saved && themes.includes(saved)) {
|
||||||
|
currentThemeIndex = themes.indexOf(saved);
|
||||||
|
if (saved !== 'default') {
|
||||||
|
document.documentElement.setAttribute('data-theme', saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== API 请求 ====================
|
||||||
|
async function sendRequest() {
|
||||||
|
const sendBtn = document.getElementById('send-btn');
|
||||||
|
const method = document.getElementById('method-select').value;
|
||||||
|
const url = document.getElementById('url-input').value.trim();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
showToast('请输入 URL', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集请求数据
|
||||||
|
const headers = collectKeyValues('headers-editor');
|
||||||
|
const params = collectKeyValues('params-editor');
|
||||||
|
const bodyType = document.querySelector('input[name="bodyType"]:checked')?.value || 'none';
|
||||||
|
const body = bodyType !== 'none' ? document.getElementById('body-editor').value : null;
|
||||||
|
const authType = document.getElementById('auth-type').value;
|
||||||
|
const authConfig = collectAuthConfig(authType);
|
||||||
|
|
||||||
|
// 显示加载状态
|
||||||
|
sendBtn.classList.add('loading');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/request', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
method, url, headers, params, body,
|
||||||
|
bodyType: bodyType !== 'none' ? bodyType : null,
|
||||||
|
authType: authType !== 'none' ? authType : null,
|
||||||
|
authConfig
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
displayResponse(result);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
displayResponse({ success: false, error: error.message });
|
||||||
|
} finally {
|
||||||
|
sendBtn.classList.remove('loading');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 辅助函数 ====================
|
||||||
|
function collectKeyValues(editorId) {
|
||||||
|
const editor = document.getElementById(editorId);
|
||||||
|
const rows = editor.querySelectorAll('.kv-row');
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const key = row.querySelector('.kv-key')?.value.trim();
|
||||||
|
const value = row.querySelector('.kv-value')?.value.trim();
|
||||||
|
if (key) result[key] = value || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.keys(result).length > 0 ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAuthConfig(authType) {
|
||||||
|
if (authType === 'none') return null;
|
||||||
|
|
||||||
|
const config = {};
|
||||||
|
const container = document.getElementById('auth-config');
|
||||||
|
const inputs = container.querySelectorAll('input');
|
||||||
|
|
||||||
|
inputs.forEach(input => {
|
||||||
|
if (input.dataset.key) {
|
||||||
|
config[input.dataset.key] = input.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayResponse(result) {
|
||||||
|
const statusCode = document.getElementById('status-code');
|
||||||
|
const statusText = document.getElementById('status-text');
|
||||||
|
const responseTime = document.getElementById('response-time');
|
||||||
|
const responseSize = document.getElementById('response-size');
|
||||||
|
const responseBody = document.getElementById('response-body');
|
||||||
|
const responseHeaders = document.getElementById('response-headers-view');
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const code = result.statusCode;
|
||||||
|
statusCode.textContent = code;
|
||||||
|
statusCode.className = 'status-code ' + (code < 300 ? 'success' : code < 400 ? 'redirect' : 'error');
|
||||||
|
statusText.textContent = getStatusText(code);
|
||||||
|
responseTime.textContent = `${result.duration}ms`;
|
||||||
|
responseSize.textContent = formatBytes(result.size || 0);
|
||||||
|
|
||||||
|
// 格式化响应体
|
||||||
|
let body = result.body;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body);
|
||||||
|
body = JSON.stringify(parsed, null, 2);
|
||||||
|
} catch {}
|
||||||
|
responseBody.querySelector('code').textContent = body;
|
||||||
|
|
||||||
|
// 响应头
|
||||||
|
if (result.headers) {
|
||||||
|
responseHeaders.querySelector('code').textContent =
|
||||||
|
JSON.stringify(result.headers, null, 2);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statusCode.textContent = 'ERR';
|
||||||
|
statusCode.className = 'status-code error';
|
||||||
|
statusText.textContent = result.error || '请求失败';
|
||||||
|
responseTime.textContent = result.duration ? `${result.duration}ms` : '--';
|
||||||
|
responseSize.textContent = '--';
|
||||||
|
responseBody.querySelector('code').textContent = result.error || '请求失败';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusText(code) {
|
||||||
|
const statusTexts = {
|
||||||
|
200: 'OK', 201: 'Created', 204: 'No Content',
|
||||||
|
301: 'Moved Permanently', 302: 'Found', 304: 'Not Modified',
|
||||||
|
400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden',
|
||||||
|
404: 'Not Found', 405: 'Method Not Allowed', 500: 'Internal Server Error',
|
||||||
|
502: 'Bad Gateway', 503: 'Service Unavailable'
|
||||||
|
};
|
||||||
|
return statusTexts[code] || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast-${type}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--${type === 'error' ? 'error' : 'accent-primary'});
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
z-index: 9999;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 认证配置 UI ====================
|
||||||
|
function updateAuthUI(authType) {
|
||||||
|
const container = document.getElementById('auth-config');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
const configs = {
|
||||||
|
bearer: [{ key: 'token', label: 'Token', type: 'password' }],
|
||||||
|
basic: [
|
||||||
|
{ key: 'username', label: '用户名', type: 'text' },
|
||||||
|
{ key: 'password', label: '密码', type: 'password' }
|
||||||
|
],
|
||||||
|
apikey: [
|
||||||
|
{ key: 'key', label: 'Key Name', type: 'text' },
|
||||||
|
{ key: 'value', label: 'Key Value', type: 'password' },
|
||||||
|
{ key: 'location', label: '位置 (header/query)', type: 'text', default: 'header' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const fields = configs[authType] || [];
|
||||||
|
|
||||||
|
fields.forEach(field => {
|
||||||
|
const group = document.createElement('div');
|
||||||
|
group.className = 'auth-input-group';
|
||||||
|
group.innerHTML = `
|
||||||
|
<label>${field.label}</label>
|
||||||
|
<input type="${field.type}" data-key="${field.key}"
|
||||||
|
value="${field.default || ''}" placeholder="${field.label}">
|
||||||
|
`;
|
||||||
|
container.appendChild(group);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Key-Value 编辑器 ====================
|
||||||
|
function addKVRow(editorId) {
|
||||||
|
const editor = document.getElementById(editorId);
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'kv-row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<input type="text" class="kv-key" placeholder="Key">
|
||||||
|
<input type="text" class="kv-value" placeholder="Value">
|
||||||
|
<button class="kv-remove">×</button>
|
||||||
|
`;
|
||||||
|
editor.appendChild(row);
|
||||||
|
|
||||||
|
row.querySelector('.kv-remove').addEventListener('click', () => row.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 标签页切换 ====================
|
||||||
|
function setupTabs() {
|
||||||
|
// 请求配置标签页
|
||||||
|
document.querySelectorAll('.request-tabs .tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.request-tabs .tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.request-content .tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById(`${btn.dataset.tab}-content`).classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 响应标签页
|
||||||
|
document.querySelectorAll('.response-tabs .tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.response-tabs .tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
|
||||||
|
const isBody = btn.dataset.tab === 'response-body';
|
||||||
|
document.getElementById('response-body').classList.toggle('hidden', !isBody);
|
||||||
|
document.getElementById('response-headers-view').classList.toggle('hidden', isBody);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 主导航标签页
|
||||||
|
document.querySelectorAll('.header-nav .nav-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.header-nav .nav-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
|
||||||
|
// 切换面板显示
|
||||||
|
const tab = btn.dataset.tab;
|
||||||
|
document.getElementById('request-panel').classList.toggle('hidden', tab !== 'request');
|
||||||
|
document.getElementById('response-panel').classList.toggle('hidden', tab !== 'request');
|
||||||
|
document.getElementById('history-panel').classList.toggle('hidden', tab !== 'history');
|
||||||
|
|
||||||
|
if (tab === 'history') loadHistory();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== 历史记录 ====================
|
||||||
|
async function loadHistory() {
|
||||||
|
const list = document.getElementById('history-list');
|
||||||
|
list.innerHTML = '<div style="text-align:center;color:var(--text-muted);">加载中...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/history?size=50');
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.data.length > 0) {
|
||||||
|
list.innerHTML = result.data.map(item => `
|
||||||
|
<div class="history-item" data-id="${item.id}" onclick="loadHistoryItem(${item.id})">
|
||||||
|
<span class="history-method ${item.method.toLowerCase()}">${item.method}</span>
|
||||||
|
<span class="history-url">${item.url}</span>
|
||||||
|
<span class="history-status">${item.statusCode || '--'}</span>
|
||||||
|
<span class="history-time">${new Date(item.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} else {
|
||||||
|
list.innerHTML = '<div style="text-align:center;color:var(--text-muted);">暂无历史记录</div>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
list.innerHTML = '<div style="text-align:center;color:var(--error);">加载失败</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistoryItem(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/history?size=1000`);
|
||||||
|
const result = await response.json();
|
||||||
|
const item = result.data.find(h => h.id === id);
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
document.getElementById('method-select').value = item.method;
|
||||||
|
document.getElementById('url-input').value = item.url;
|
||||||
|
|
||||||
|
// 切换回请求面板
|
||||||
|
document.querySelectorAll('.header-nav .nav-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelector('.header-nav .nav-btn[data-tab="request"]').classList.add('active');
|
||||||
|
document.getElementById('request-panel').classList.remove('hidden');
|
||||||
|
document.getElementById('response-panel').classList.remove('hidden');
|
||||||
|
document.getElementById('history-panel').classList.add('hidden');
|
||||||
|
|
||||||
|
showToast('已加载历史记录', 'info');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('加载失败', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 语音输入 ====================
|
||||||
|
function setupVoiceInput() {
|
||||||
|
const voiceBtn = document.getElementById('voice-input');
|
||||||
|
|
||||||
|
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
|
||||||
|
voiceBtn.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
const recognition = new SpeechRecognition();
|
||||||
|
recognition.lang = 'zh-CN';
|
||||||
|
recognition.continuous = false;
|
||||||
|
|
||||||
|
let isRecording = false;
|
||||||
|
|
||||||
|
voiceBtn.addEventListener('click', () => {
|
||||||
|
if (isRecording) {
|
||||||
|
recognition.stop();
|
||||||
|
voiceBtn.classList.remove('recording');
|
||||||
|
isRecording = false;
|
||||||
|
} else {
|
||||||
|
recognition.start();
|
||||||
|
voiceBtn.classList.add('recording');
|
||||||
|
isRecording = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
recognition.onresult = (event) => {
|
||||||
|
const text = event.results[0][0].transcript;
|
||||||
|
const urlInput = document.getElementById('url-input');
|
||||||
|
urlInput.value = text;
|
||||||
|
voiceBtn.classList.remove('recording');
|
||||||
|
isRecording = false;
|
||||||
|
showToast('语音识别成功', 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = () => {
|
||||||
|
voiceBtn.classList.remove('recording');
|
||||||
|
isRecording = false;
|
||||||
|
showToast('语音识别失败', 'error');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== JSON 格式化 ====================
|
||||||
|
function setupFormatButton() {
|
||||||
|
document.getElementById('format-json-btn').addEventListener('click', () => {
|
||||||
|
const editor = document.getElementById('body-editor');
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(editor.value);
|
||||||
|
editor.value = JSON.stringify(parsed, null, 2);
|
||||||
|
showToast('格式化成功', 'info');
|
||||||
|
} catch {
|
||||||
|
showToast('JSON 格式错误', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 预设按钮 ====================
|
||||||
|
function setupPresets() {
|
||||||
|
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const key = btn.dataset.key;
|
||||||
|
const value = btn.dataset.value;
|
||||||
|
|
||||||
|
// 添加到 headers 编辑器
|
||||||
|
const editor = document.getElementById('headers-editor');
|
||||||
|
const rows = editor.querySelectorAll('.kv-row');
|
||||||
|
|
||||||
|
// 检查是否已存在该 key
|
||||||
|
let existingRow = null;
|
||||||
|
for (const row of rows) {
|
||||||
|
const keyInput = row.querySelector('.kv-key');
|
||||||
|
if (keyInput.value === key) {
|
||||||
|
existingRow = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingRow) {
|
||||||
|
// 更新现有行
|
||||||
|
const valueInput = existingRow.querySelector('.kv-value');
|
||||||
|
valueInput.value = value;
|
||||||
|
valueInput.focus();
|
||||||
|
// 如果是鉴权类型,光标定位到值末尾方便输入 token
|
||||||
|
if (btn.classList.contains('auth-preset')) {
|
||||||
|
valueInput.setSelectionRange(value.length, value.length);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 添加新行
|
||||||
|
addKVRow('headers-editor');
|
||||||
|
const newRow = editor.lastElementChild;
|
||||||
|
newRow.querySelector('.kv-key').value = key;
|
||||||
|
const valueInput = newRow.querySelector('.kv-value');
|
||||||
|
valueInput.value = value;
|
||||||
|
|
||||||
|
// 如果是鉴权或空值类型,自动聚焦到值输入框
|
||||||
|
if (btn.classList.contains('auth-preset') || value === '') {
|
||||||
|
valueInput.focus();
|
||||||
|
valueInput.setSelectionRange(value.length, value.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(`已添加 ${key}`, 'info');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 初始化 ====================
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// 初始化粒子背景
|
||||||
|
new ParticleBackground(document.getElementById('particles-canvas'));
|
||||||
|
|
||||||
|
// 加载主题
|
||||||
|
loadTheme();
|
||||||
|
|
||||||
|
// 设置事件监听
|
||||||
|
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
|
||||||
|
document.getElementById('send-btn').addEventListener('click', sendRequest);
|
||||||
|
document.getElementById('auth-type').addEventListener('change', (e) => updateAuthUI(e.target.value));
|
||||||
|
|
||||||
|
// 添加行按钮
|
||||||
|
document.querySelectorAll('.add-row-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => addKVRow(btn.dataset.target));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除行按钮
|
||||||
|
document.querySelectorAll('.kv-remove').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => btn.parentElement.remove());
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清空历史
|
||||||
|
document.getElementById('clear-history')?.addEventListener('click', async () => {
|
||||||
|
if (confirm('确定要清空所有历史记录吗?')) {
|
||||||
|
await fetch('/api/history/clear', { method: 'DELETE' });
|
||||||
|
loadHistory();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置标签页
|
||||||
|
setupTabs();
|
||||||
|
setupVoiceInput();
|
||||||
|
setupFormatButton();
|
||||||
|
setupPresets();
|
||||||
|
|
||||||
|
// 键盘快捷键
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
sendRequest();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>API Tester - 未来科技接口测试工具</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/main.css">
|
||||||
|
<!-- 使用国内 CDN 加载字体,避免 Google Fonts 超时 -->
|
||||||
|
<link href="https://fonts.loli.net/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
/* 备用字体,防止 CDN 也超时 */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
src: local('JetBrains Mono'), local('Menlo'), local('Monaco'), local('Consolas');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- 动态粒子背景 -->
|
||||||
|
<canvas id="particles-canvas"></canvas>
|
||||||
|
|
||||||
|
<!-- 主容器 -->
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- 头部导航 -->
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="logo">
|
||||||
|
<span class="logo-icon">⚡</span>
|
||||||
|
<span class="logo-text">API Tester</span>
|
||||||
|
<span class="logo-badge">Pro</span>
|
||||||
|
</div>
|
||||||
|
<nav class="header-nav">
|
||||||
|
<button class="nav-btn active" data-tab="request">请求</button>
|
||||||
|
<button class="nav-btn" data-tab="history">历史</button>
|
||||||
|
<button class="nav-btn" data-tab="collections">收藏</button>
|
||||||
|
<button class="nav-btn" data-tab="environments">环境</button>
|
||||||
|
</nav>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="theme-btn" id="theme-toggle" title="切换主题">🎨</button>
|
||||||
|
<button class="voice-btn" id="voice-input" title="语音输入">🎤</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<main class="app-main">
|
||||||
|
<!-- 请求面板 -->
|
||||||
|
<section class="panel request-panel" id="request-panel">
|
||||||
|
<!-- URL 输入区 -->
|
||||||
|
<div class="url-bar">
|
||||||
|
<select class="method-select" id="method-select">
|
||||||
|
<option value="GET">GET</option>
|
||||||
|
<option value="POST">POST</option>
|
||||||
|
<option value="PUT">PUT</option>
|
||||||
|
<option value="DELETE">DELETE</option>
|
||||||
|
<option value="PATCH">PATCH</option>
|
||||||
|
<option value="HEAD">HEAD</option>
|
||||||
|
<option value="OPTIONS">OPTIONS</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" class="url-input" id="url-input"
|
||||||
|
placeholder="输入请求 URL,例如: https://api.example.com/users">
|
||||||
|
<button class="send-btn" id="send-btn">
|
||||||
|
<span class="btn-text">发送</span>
|
||||||
|
<span class="btn-icon">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 请求配置标签页 -->
|
||||||
|
<div class="request-tabs">
|
||||||
|
<button class="tab-btn active" data-tab="params">Params</button>
|
||||||
|
<button class="tab-btn" data-tab="headers">Headers</button>
|
||||||
|
<button class="tab-btn" data-tab="body">Body</button>
|
||||||
|
<button class="tab-btn" data-tab="auth">Auth</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 请求配置内容 -->
|
||||||
|
<div class="request-content">
|
||||||
|
<!-- Params 面板 -->
|
||||||
|
<div class="tab-content active" id="params-content">
|
||||||
|
<div class="kv-editor" id="params-editor">
|
||||||
|
<div class="kv-row">
|
||||||
|
<input type="text" class="kv-key" placeholder="Key">
|
||||||
|
<input type="text" class="kv-value" placeholder="Value">
|
||||||
|
<button class="kv-remove">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="add-row-btn" data-target="params-editor">+ 添加参数</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Headers 面板 -->
|
||||||
|
<div class="tab-content" id="headers-content">
|
||||||
|
<div class="kv-editor" id="headers-editor">
|
||||||
|
<div class="kv-row">
|
||||||
|
<input type="text" class="kv-key" placeholder="Header Name">
|
||||||
|
<input type="text" class="kv-value" placeholder="Header Value">
|
||||||
|
<button class="kv-remove">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="add-row-btn" data-target="headers-editor">+ 添加请求头</button>
|
||||||
|
|
||||||
|
<!-- 常用请求头预设 -->
|
||||||
|
<div class="header-presets-container">
|
||||||
|
<div class="preset-group">
|
||||||
|
<span class="preset-label">Content-Type:</span>
|
||||||
|
<button class="preset-btn" data-key="Content-Type" data-value="application/json">JSON</button>
|
||||||
|
<button class="preset-btn" data-key="Content-Type" data-value="application/x-www-form-urlencoded">Form</button>
|
||||||
|
<button class="preset-btn" data-key="Content-Type" data-value="multipart/form-data">Multipart</button>
|
||||||
|
<button class="preset-btn" data-key="Content-Type" data-value="text/plain">Text</button>
|
||||||
|
<button class="preset-btn" data-key="Content-Type" data-value="application/xml">XML</button>
|
||||||
|
</div>
|
||||||
|
<div class="preset-group">
|
||||||
|
<span class="preset-label">Accept:</span>
|
||||||
|
<button class="preset-btn" data-key="Accept" data-value="application/json">JSON</button>
|
||||||
|
<button class="preset-btn" data-key="Accept" data-value="*/*">Any</button>
|
||||||
|
<button class="preset-btn" data-key="Accept" data-value="text/html">HTML</button>
|
||||||
|
</div>
|
||||||
|
<div class="preset-group">
|
||||||
|
<span class="preset-label">鉴权:</span>
|
||||||
|
<button class="preset-btn auth-preset" data-key="Authorization" data-value="Bearer ">Bearer Token</button>
|
||||||
|
<button class="preset-btn auth-preset" data-key="Authorization" data-value="Basic ">Basic Auth</button>
|
||||||
|
<button class="preset-btn" data-key="X-API-Key" data-value="">API Key</button>
|
||||||
|
<button class="preset-btn" data-key="X-Access-Token" data-value="">Access Token</button>
|
||||||
|
</div>
|
||||||
|
<div class="preset-group">
|
||||||
|
<span class="preset-label">其他常用:</span>
|
||||||
|
<button class="preset-btn" data-key="User-Agent" data-value="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36">User-Agent</button>
|
||||||
|
<button class="preset-btn" data-key="Cache-Control" data-value="no-cache">No Cache</button>
|
||||||
|
<button class="preset-btn" data-key="Origin" data-value="">Origin</button>
|
||||||
|
<button class="preset-btn" data-key="Referer" data-value="">Referer</button>
|
||||||
|
<button class="preset-btn" data-key="Cookie" data-value="">Cookie</button>
|
||||||
|
<button class="preset-btn" data-key="X-Requested-With" data-value="XMLHttpRequest">AJAX</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body 面板 -->
|
||||||
|
<div class="tab-content" id="body-content">
|
||||||
|
<div class="body-type-selector">
|
||||||
|
<label><input type="radio" name="bodyType" value="none" checked> none</label>
|
||||||
|
<label><input type="radio" name="bodyType" value="json"> JSON</label>
|
||||||
|
<label><input type="radio" name="bodyType" value="form"> form-data</label>
|
||||||
|
<label><input type="radio" name="bodyType" value="xml"> XML</label>
|
||||||
|
<label><input type="radio" name="bodyType" value="raw"> raw</label>
|
||||||
|
</div>
|
||||||
|
<div class="body-editor-container">
|
||||||
|
<textarea class="body-editor" id="body-editor" placeholder="请求体内容..."></textarea>
|
||||||
|
<button class="format-btn" id="format-json-btn" title="格式化 JSON">{ }</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auth 面板 -->
|
||||||
|
<div class="tab-content" id="auth-content">
|
||||||
|
<div class="auth-type-selector">
|
||||||
|
<select id="auth-type">
|
||||||
|
<option value="none">No Auth</option>
|
||||||
|
<option value="bearer">Bearer Token</option>
|
||||||
|
<option value="basic">Basic Auth</option>
|
||||||
|
<option value="apikey">API Key</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="auth-config" id="auth-config">
|
||||||
|
<!-- 动态填充认证配置 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 响应面板 -->
|
||||||
|
<section class="panel response-panel" id="response-panel">
|
||||||
|
<div class="response-header">
|
||||||
|
<div class="response-status">
|
||||||
|
<span class="status-code" id="status-code">--</span>
|
||||||
|
<span class="status-text" id="status-text">等待请求</span>
|
||||||
|
</div>
|
||||||
|
<div class="response-meta">
|
||||||
|
<span class="meta-item"><span class="meta-label">耗时:</span> <span id="response-time">--</span></span>
|
||||||
|
<span class="meta-item"><span class="meta-label">大小:</span> <span id="response-size">--</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="response-tabs">
|
||||||
|
<button class="tab-btn active" data-tab="response-body">Body</button>
|
||||||
|
<button class="tab-btn" data-tab="response-headers">Headers</button>
|
||||||
|
</div>
|
||||||
|
<div class="response-content">
|
||||||
|
<pre class="response-body" id="response-body"><code>// 响应将显示在这里</code></pre>
|
||||||
|
<pre class="response-headers hidden" id="response-headers-view"><code></code></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 历史记录面板 (隐藏) -->
|
||||||
|
<section class="panel history-panel hidden" id="history-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>请求历史</h2>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<input type="text" class="search-input" id="history-search" placeholder="搜索...">
|
||||||
|
<button class="clear-btn" id="clear-history">清空历史</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="history-list" id="history-list">
|
||||||
|
<!-- 动态填充 -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# AI助手Web客户端环境配置示例
|
||||||
|
# 复制此文件为 .env 并根据实际情况修改
|
||||||
|
|
||||||
|
# 服务端口
|
||||||
|
PORT=5000
|
||||||
|
|
||||||
|
# 调试模式 (True/False)
|
||||||
|
DEBUG=True
|
||||||
|
|
||||||
|
# Flask密钥(生产环境请使用随机字符串)
|
||||||
|
SECRET_KEY=ai-assistant-web-client-secret-key
|
||||||
|
|
||||||
|
# API基础URL(如果需要代理到其他服务器)
|
||||||
|
# API_BASE_URL=http://localhost:8082
|
||||||
|
|
||||||
|
# 日志级别 (DEBUG/INFO/WARNING/ERROR)
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# 默认应用ID
|
||||||
|
DEFAULT_APP_ID=15
|
||||||
|
|
||||||
|
# CORS允许的源(多个用逗号分隔)
|
||||||
|
# CORS_ORIGINS=http://localhost:3000,http://localhost:8080
|
||||||
|
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
# 🚀 部署指南
|
||||||
|
|
||||||
|
## 生产环境部署
|
||||||
|
|
||||||
|
### 使用 Gunicorn(推荐)
|
||||||
|
|
||||||
|
#### 1. 安装 Gunicorn
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip3 install gunicorn
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 创建 Gunicorn 配置文件
|
||||||
|
|
||||||
|
创建 `gunicorn.conf.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Gunicorn配置
|
||||||
|
bind = "0.0.0.0:5000"
|
||||||
|
workers = 4
|
||||||
|
worker_class = "sync"
|
||||||
|
timeout = 120
|
||||||
|
keepalive = 5
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
accesslog = "logs/access.log"
|
||||||
|
errorlog = "logs/error.log"
|
||||||
|
loglevel = "info"
|
||||||
|
|
||||||
|
# 进程命名
|
||||||
|
proc_name = "ai-assistant-web-client"
|
||||||
|
|
||||||
|
# 后台运行
|
||||||
|
daemon = False
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建日志目录
|
||||||
|
mkdir -p logs
|
||||||
|
|
||||||
|
# 启动Gunicorn
|
||||||
|
gunicorn -c gunicorn.conf.py app:app
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 uWSGI
|
||||||
|
|
||||||
|
#### 1. 安装 uWSGI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip3 install uwsgi
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 创建 uWSGI 配置文件
|
||||||
|
|
||||||
|
创建 `uwsgi.ini`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[uwsgi]
|
||||||
|
module = app:app
|
||||||
|
master = true
|
||||||
|
processes = 4
|
||||||
|
socket = 0.0.0.0:5000
|
||||||
|
protocol = http
|
||||||
|
chmod-socket = 660
|
||||||
|
vacuum = true
|
||||||
|
die-on-term = true
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uwsgi --ini uwsgi.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker 部署
|
||||||
|
|
||||||
|
### 1. 创建 Dockerfile
|
||||||
|
|
||||||
|
创建 `Dockerfile`:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
||||||
|
|
||||||
|
# 复制应用文件
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# 启动命令
|
||||||
|
CMD ["gunicorn", "-b", "0.0.0.0:5000", "-w", "4", "app:app"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 创建 docker-compose.yml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
web-client:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
- DEBUG=False
|
||||||
|
- PORT=5000
|
||||||
|
volumes:
|
||||||
|
- ./logs:/app/logs
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 构建和运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建镜像
|
||||||
|
docker-compose build
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nginx 反向代理
|
||||||
|
|
||||||
|
### 配置示例
|
||||||
|
|
||||||
|
创建 `/etc/nginx/sites-available/ai-assistant-web`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
# 静态文件
|
||||||
|
location /static {
|
||||||
|
alias /path/to/web_client/static;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# 代理到Flask应用
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket支持(如果需要)
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
启用配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/ai-assistant-web /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Systemd 服务
|
||||||
|
|
||||||
|
### 创建服务文件
|
||||||
|
|
||||||
|
创建 `/etc/systemd/system/ai-assistant-web.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=AI Assistant Web Client
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=notify
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/path/to/web_client
|
||||||
|
Environment="PATH=/path/to/venv/bin"
|
||||||
|
ExecStart=/path/to/venv/bin/gunicorn -c gunicorn.conf.py app:app
|
||||||
|
ExecReload=/bin/kill -s HUP $MAINPID
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=5
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### 管理服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 重载systemd配置
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
sudo systemctl start ai-assistant-web
|
||||||
|
|
||||||
|
# 开机自启
|
||||||
|
sudo systemctl enable ai-assistant-web
|
||||||
|
|
||||||
|
# 查看状态
|
||||||
|
sudo systemctl status ai-assistant-web
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
sudo journalctl -u ai-assistant-web -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
### 1. 启用 Gzip 压缩
|
||||||
|
|
||||||
|
在 Nginx 配置中添加:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 静态文件缓存
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 使用 CDN
|
||||||
|
|
||||||
|
将静态资源(CSS、JS、字体)托管到CDN。
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
### 1. 使用 HTTPS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 Let's Encrypt
|
||||||
|
sudo certbot --nginx -d your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 设置安全头
|
||||||
|
|
||||||
|
在 Nginx 配置中添加:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 限制请求速率
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 监控和日志
|
||||||
|
|
||||||
|
### 1. 日志轮转
|
||||||
|
|
||||||
|
创建 `/etc/logrotate.d/ai-assistant-web`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/path/to/web_client/logs/*.log {
|
||||||
|
daily
|
||||||
|
rotate 14
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
notifempty
|
||||||
|
create 0640 www-data www-data
|
||||||
|
sharedscripts
|
||||||
|
postrotate
|
||||||
|
systemctl reload ai-assistant-web > /dev/null 2>&1 || true
|
||||||
|
endscript
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 健康检查
|
||||||
|
|
||||||
|
使用 `/api/health` 端点进行健康检查。
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 查看日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Gunicorn日志
|
||||||
|
tail -f logs/error.log
|
||||||
|
|
||||||
|
# Nginx日志
|
||||||
|
tail -f /var/log/nginx/error.log
|
||||||
|
|
||||||
|
# Systemd日志
|
||||||
|
journalctl -u ai-assistant-web -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
1. **502 Bad Gateway** - 检查Flask应用是否运行
|
||||||
|
2. **静态文件404** - 检查Nginx静态文件路径配置
|
||||||
|
3. **CORS错误** - 检查Flask-CORS配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**部署成功后,记得测试所有功能!** ✅
|
||||||
|
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# 🚀 快速启动指南
|
||||||
|
|
||||||
|
## 一键启动
|
||||||
|
|
||||||
|
### 方法1:使用启动脚本(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd test-client/web_client
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法2:手动启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd test-client/web_client
|
||||||
|
|
||||||
|
# 安装依赖(首次运行)
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
python3 app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法3:指定端口启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 如果5000端口被占用,可以指定其他端口
|
||||||
|
PORT=5001 python3 app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 访问应用
|
||||||
|
|
||||||
|
启动成功后,在浏览器中打开:
|
||||||
|
|
||||||
|
- **默认地址**: http://localhost:5000
|
||||||
|
- **自定义端口**: http://localhost:5001 (如果使用了PORT=5001)
|
||||||
|
|
||||||
|
## 🎨 界面预览
|
||||||
|
|
||||||
|
启动后你将看到:
|
||||||
|
|
||||||
|
### 左侧面板
|
||||||
|
- 👤 **用户画像** - 显示用户信息和个性化推荐
|
||||||
|
- ✅ **待办事项** - 任务管理功能
|
||||||
|
- 🔔 **提醒设置** - 定时提醒展示
|
||||||
|
|
||||||
|
### 右侧主区域
|
||||||
|
- 🎨 **主题切换** - 4种配色方案(深空黑、极光蓝、霓虹紫、科技绿)
|
||||||
|
- 💬 **对话区域** - AI助手聊天界面
|
||||||
|
- ⚡ **快捷回复** - 常用问题快速输入
|
||||||
|
- 🎤 **语音输入** - 语音波形动画效果
|
||||||
|
|
||||||
|
## 💡 使用技巧
|
||||||
|
|
||||||
|
### 发送消息
|
||||||
|
1. 在底部输入框输入文字
|
||||||
|
2. 按 `Enter` 键或点击发送按钮
|
||||||
|
3. 等待AI回复(会显示打字动画)
|
||||||
|
|
||||||
|
### 快捷回复
|
||||||
|
点击顶部的快捷回复按钮,自动填充常用问题:
|
||||||
|
- 💡 今日天气如何?
|
||||||
|
- 📅 明天的会议安排
|
||||||
|
- ✅ 帮我制定工作计划
|
||||||
|
|
||||||
|
### 切换主题
|
||||||
|
点击顶部导航栏的主题按钮:
|
||||||
|
- **深空黑** - 默认深色主题
|
||||||
|
- **极光蓝** - 蓝色科技风
|
||||||
|
- **霓虹紫** - 紫色梦幻风
|
||||||
|
- **科技绿** - 绿色矩阵风
|
||||||
|
|
||||||
|
### 管理待办
|
||||||
|
在左侧面板的待办事项区域:
|
||||||
|
1. 输入新任务
|
||||||
|
2. 按 `Enter` 或点击 `+` 按钮添加
|
||||||
|
3. 勾选复选框标记完成
|
||||||
|
|
||||||
|
## 🔧 常见问题
|
||||||
|
|
||||||
|
### Q: 端口被占用怎么办?
|
||||||
|
A: 使用环境变量指定其他端口:
|
||||||
|
```bash
|
||||||
|
PORT=5001 python3 app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 样式显示不正常?
|
||||||
|
A: 检查以下几点:
|
||||||
|
1. 确保 `static/css/style.css` 文件存在
|
||||||
|
2. 清除浏览器缓存后刷新
|
||||||
|
3. 检查浏览器控制台是否有错误
|
||||||
|
|
||||||
|
### Q: 无法发送消息?
|
||||||
|
A: 检查:
|
||||||
|
1. 浏览器控制台的Network标签查看请求
|
||||||
|
2. 确认后端API服务是否启动
|
||||||
|
3. 检查CORS配置
|
||||||
|
|
||||||
|
### Q: 如何连接真实的AI助手API?
|
||||||
|
A: 编辑 `app.py`,确保以下导入正确:
|
||||||
|
```python
|
||||||
|
from api_client import AIAssistantClient
|
||||||
|
from config import APPLICATIONS, AI_ASSISTANT_CONFIG
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 技术栈
|
||||||
|
|
||||||
|
- **后端**: Flask 2.3+
|
||||||
|
- **前端**: Tailwind CSS + Vanilla JS
|
||||||
|
- **字体**: Pacifico + Inter
|
||||||
|
- **图标**: Font Awesome 6.4.0
|
||||||
|
|
||||||
|
## 🎯 下一步
|
||||||
|
|
||||||
|
1. ✅ 启动应用
|
||||||
|
2. ✅ 体验界面效果
|
||||||
|
3. ✅ 测试对话功能
|
||||||
|
4. 🔄 集成真实API
|
||||||
|
5. 🚀 部署到生产环境
|
||||||
|
|
||||||
|
## 📝 开发模式
|
||||||
|
|
||||||
|
当前运行在开发模式,支持:
|
||||||
|
- ✅ 热重载(修改代码自动重启)
|
||||||
|
- ✅ 详细错误信息
|
||||||
|
- ✅ 调试工具
|
||||||
|
|
||||||
|
**注意**: 生产环境请使用 WSGI 服务器(如 Gunicorn)
|
||||||
|
|
||||||
|
## 🆘 获取帮助
|
||||||
|
|
||||||
|
如遇问题,请检查:
|
||||||
|
1. 终端输出的错误信息
|
||||||
|
2. 浏览器控制台的错误
|
||||||
|
3. Flask日志输出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**享受使用AI助手Web客户端!** 🎉
|
||||||
|
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# AI助手Web客户端
|
||||||
|
|
||||||
|
这是一个100%还原 `docs/ai-assistant.html` 原型设计的Web版本AI助手对话客户端。
|
||||||
|
|
||||||
|
## ✨ 特性
|
||||||
|
|
||||||
|
### 视觉效果
|
||||||
|
- ✅ 完全还原原型的双栏布局(左侧用户信息面板 + 右侧对话区域)
|
||||||
|
- ✅ 精确匹配的颜色方案和渐变效果
|
||||||
|
- ✅ 动态粒子背景效果
|
||||||
|
- ✅ 消息滑入动画
|
||||||
|
- ✅ AI打字指示器动画
|
||||||
|
- ✅ 按钮悬停发光效果
|
||||||
|
- ✅ 语音波形动画
|
||||||
|
|
||||||
|
### 功能特性
|
||||||
|
- ✅ 实时对话交互
|
||||||
|
- ✅ 智能回复快捷按钮
|
||||||
|
- ✅ 主题切换(深空黑、极光蓝、霓虹紫、科技绿)
|
||||||
|
- ✅ 字体选择
|
||||||
|
- ✅ 待办事项管理
|
||||||
|
- ✅ 提醒设置展示
|
||||||
|
- ✅ 连接状态指示
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
- **后端**: Flask 2.3+
|
||||||
|
- **前端**: HTML5 + Tailwind CSS + Vanilla JavaScript
|
||||||
|
- **字体**: Pacifico (标题) + Inter (正文)
|
||||||
|
- **图标**: Font Awesome 6.4.0
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
web_client/
|
||||||
|
├── app.py # Flask应用主文件
|
||||||
|
├── requirements.txt # Python依赖
|
||||||
|
├── README.md # 本文档
|
||||||
|
├── static/
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── style.css # 自定义样式(动画、效果)
|
||||||
|
│ ├── js/
|
||||||
|
│ │ └── main.js # 前端交互逻辑
|
||||||
|
│ └── assets/ # 静态资源(图片等)
|
||||||
|
└── templates/
|
||||||
|
└── index.html # 主页面模板
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd test-client/web_client
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
默认端口:`5000`
|
||||||
|
|
||||||
|
### 3. 访问应用
|
||||||
|
|
||||||
|
打开浏览器访问:`http://localhost:5000`
|
||||||
|
|
||||||
|
## 🎨 界面还原度
|
||||||
|
|
||||||
|
### 布局结构
|
||||||
|
- ✅ 左侧固定宽度(320px)用户信息面板
|
||||||
|
- ✅ 右侧自适应对话区域
|
||||||
|
- ✅ 顶部导航栏(64px高度)
|
||||||
|
- ✅ 底部输入区域
|
||||||
|
|
||||||
|
### 颜色方案
|
||||||
|
```css
|
||||||
|
/* 背景渐变 */
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
|
||||||
|
|
||||||
|
/* 主色调 */
|
||||||
|
--primary: #6366f1;
|
||||||
|
--secondary: #8b5cf6;
|
||||||
|
|
||||||
|
/* 用户消息气泡 */
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
|
||||||
|
/* AI消息气泡 */
|
||||||
|
background: linear-gradient(135deg, #06b6d4 0%, #10b981 100%);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 动画效果
|
||||||
|
1. **粒子背景** - 50个动态浮动粒子
|
||||||
|
2. **消息滑入** - 0.3s ease-out 动画
|
||||||
|
3. **打字指示器** - 3个点的波浪动画
|
||||||
|
4. **按钮发光** - hover时的阴影和位移效果
|
||||||
|
5. **脉冲动画** - 2s循环的扩散效果
|
||||||
|
6. **语音波形** - 5个条形的波动动画
|
||||||
|
|
||||||
|
## 🔌 API集成
|
||||||
|
|
||||||
|
### 已预留的API接口
|
||||||
|
|
||||||
|
#### 1. 获取应用列表
|
||||||
|
```javascript
|
||||||
|
GET /api/applications
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 发送聊天消息
|
||||||
|
```javascript
|
||||||
|
POST /api/chat/send
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"appId": 15,
|
||||||
|
"message": "你好",
|
||||||
|
"chatId": "optional_chat_id",
|
||||||
|
"stream": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 健康检查
|
||||||
|
```javascript
|
||||||
|
GET /api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 集成真实API
|
||||||
|
|
||||||
|
编辑 `app.py`,确保父目录的 `api_client.py` 和 `config.py` 可以正确导入:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from api_client import AIAssistantClient
|
||||||
|
from config import APPLICATIONS, AI_ASSISTANT_CONFIG
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 使用说明
|
||||||
|
|
||||||
|
### 基础对话
|
||||||
|
1. 在底部输入框输入消息
|
||||||
|
2. 点击发送按钮或按Enter键
|
||||||
|
3. 等待AI回复(显示打字指示器)
|
||||||
|
|
||||||
|
### 快捷回复
|
||||||
|
点击顶部的快捷回复按钮,自动填充常用问题
|
||||||
|
|
||||||
|
### 主题切换
|
||||||
|
点击顶部导航栏的主题按钮切换不同配色方案
|
||||||
|
|
||||||
|
### 待办事项
|
||||||
|
在左侧面板添加和管理待办事项
|
||||||
|
|
||||||
|
## 🔧 配置说明
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
创建 `.env` 文件(可选):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 服务端口
|
||||||
|
PORT=5000
|
||||||
|
|
||||||
|
# 调试模式
|
||||||
|
DEBUG=True
|
||||||
|
|
||||||
|
# API基础URL(如果需要代理)
|
||||||
|
API_BASE_URL=http://localhost:8082
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改默认应用
|
||||||
|
|
||||||
|
编辑 `static/js/main.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
let currentAppId = 15; // 修改为你的应用ID
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📱 响应式设计
|
||||||
|
|
||||||
|
- ✅ 桌面端(1920x1080及以上)- 完整布局
|
||||||
|
- ✅ 平板端(768px-1920px)- 自适应布局
|
||||||
|
- ✅ 移动端(<768px)- 优化的单栏布局
|
||||||
|
|
||||||
|
## 🐛 故障排查
|
||||||
|
|
||||||
|
### 样式未加载
|
||||||
|
- 检查 `static/css/style.css` 文件是否存在
|
||||||
|
- 确认Flask静态文件路径配置正确
|
||||||
|
|
||||||
|
### JavaScript未执行
|
||||||
|
- 检查浏览器控制台是否有错误
|
||||||
|
- 确认 `static/js/main.js` 文件是否存在
|
||||||
|
|
||||||
|
### API调用失败
|
||||||
|
- 检查后端服务是否启动
|
||||||
|
- 查看浏览器Network标签的请求详情
|
||||||
|
- 确认CORS配置正确
|
||||||
|
|
||||||
|
## 📝 开发计划
|
||||||
|
|
||||||
|
- [ ] 支持流式响应(SSE)
|
||||||
|
- [ ] 支持文件上传
|
||||||
|
- [ ] 支持语音输入
|
||||||
|
- [ ] 支持多会话管理
|
||||||
|
- [ ] 支持消息历史记录
|
||||||
|
- [ ] 支持用户认证
|
||||||
|
|
||||||
|
## 📄 许可
|
||||||
|
|
||||||
|
内部测试工具,仅供开发和测试使用。
|
||||||
|
|
||||||
|
## 🤝 贡献
|
||||||
|
|
||||||
|
如需修改或扩展功能,请遵循以下原则:
|
||||||
|
1. 保持与原型设计的一致性
|
||||||
|
2. 添加注释说明修改内容
|
||||||
|
3. 测试所有浏览器兼容性
|
||||||
|
4. 更新本README文档
|
||||||
|
|
||||||
Binary file not shown.
@@ -0,0 +1,156 @@
|
|||||||
|
"""
|
||||||
|
AI助手Web客户端 - Flask应用
|
||||||
|
100%还原docs/ai-assistant.html原型设计
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from flask import Flask, render_template, request, jsonify, Response
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
|
# 添加父目录到路径,以便导入api_client
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from api_client import AIAssistantClient
|
||||||
|
from config import APPLICATIONS, AI_ASSISTANT_CONFIG
|
||||||
|
except ImportError:
|
||||||
|
# 如果导入失败,使用模拟数据
|
||||||
|
AIAssistantClient = None
|
||||||
|
APPLICATIONS = {}
|
||||||
|
AI_ASSISTANT_CONFIG = {}
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 创建Flask应用
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['SECRET_KEY'] = 'ai-assistant-web-client-secret-key'
|
||||||
|
CORS(app) # 启用CORS
|
||||||
|
|
||||||
|
# 初始化API客户端
|
||||||
|
api_client = None
|
||||||
|
if AIAssistantClient:
|
||||||
|
try:
|
||||||
|
api_client = AIAssistantClient()
|
||||||
|
logger.info("API客户端初始化成功")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"API客户端初始化失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
"""主页面"""
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/applications', methods=['GET'])
|
||||||
|
def get_applications():
|
||||||
|
"""获取应用列表"""
|
||||||
|
try:
|
||||||
|
if api_client:
|
||||||
|
apps = api_client.get_app_list()
|
||||||
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'message': 'success',
|
||||||
|
'data': apps
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# 返回模拟数据
|
||||||
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'message': 'success',
|
||||||
|
'data': list(APPLICATIONS.values()) if APPLICATIONS else []
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取应用列表失败: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'code': 500,
|
||||||
|
'message': str(e),
|
||||||
|
'data': []
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/chat/send', methods=['POST'])
|
||||||
|
def send_message():
|
||||||
|
"""发送聊天消息"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
app_id = data.get('appId')
|
||||||
|
message = data.get('message')
|
||||||
|
chat_id = data.get('chatId')
|
||||||
|
stream = data.get('stream', False)
|
||||||
|
|
||||||
|
if not message:
|
||||||
|
return jsonify({
|
||||||
|
'code': 400,
|
||||||
|
'message': '消息内容不能为空',
|
||||||
|
'data': None
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if api_client and app_id:
|
||||||
|
# 调用真实API
|
||||||
|
response = api_client.send_message(
|
||||||
|
app_id=app_id,
|
||||||
|
message=message,
|
||||||
|
chat_id=chat_id,
|
||||||
|
stream=stream
|
||||||
|
)
|
||||||
|
|
||||||
|
if stream:
|
||||||
|
# 流式响应
|
||||||
|
def generate():
|
||||||
|
for chunk in response:
|
||||||
|
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
return Response(generate(), mimetype='text/event-stream')
|
||||||
|
else:
|
||||||
|
# 普通响应
|
||||||
|
return jsonify(response)
|
||||||
|
else:
|
||||||
|
# 返回模拟响应
|
||||||
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'message': 'success',
|
||||||
|
'data': {
|
||||||
|
'answer': f'这是对"{message}"的模拟回复。实际使用时会调用真实的AI助手API。',
|
||||||
|
'chatId': chat_id or 'mock_chat_id_001',
|
||||||
|
'messageId': 'mock_message_id_001'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"发送消息失败: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'code': 500,
|
||||||
|
'message': str(e),
|
||||||
|
'data': None
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/health', methods=['GET'])
|
||||||
|
def health_check():
|
||||||
|
"""健康检查"""
|
||||||
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'message': 'OK',
|
||||||
|
'data': {
|
||||||
|
'status': 'healthy',
|
||||||
|
'api_client_available': api_client is not None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
port = int(os.environ.get('PORT', 5000))
|
||||||
|
debug = os.environ.get('DEBUG', 'True').lower() == 'true'
|
||||||
|
|
||||||
|
logger.info(f"启动AI助手Web客户端,端口: {port}, 调试模式: {debug}")
|
||||||
|
app.run(host='0.0.0.0', port=port, debug=debug)
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# AI助手Web客户端依赖包
|
||||||
|
|
||||||
|
# Flask Web框架
|
||||||
|
Flask>=2.3.0
|
||||||
|
|
||||||
|
# CORS支持
|
||||||
|
Flask-CORS>=4.0.0
|
||||||
|
|
||||||
|
# HTTP请求库(用于调用后端API)
|
||||||
|
requests>=2.31.0
|
||||||
|
|
||||||
|
# 环境变量管理(可选)
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# AI助手Web客户端启动脚本
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo " AI助手Web客户端启动脚本"
|
||||||
|
echo "========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 检查Python环境
|
||||||
|
if ! command -v python3 &> /dev/null; then
|
||||||
|
echo "❌ 错误: 未找到Python3,请先安装Python 3.7+"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Python版本: $(python3 --version)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 检查是否在正确的目录
|
||||||
|
if [ ! -f "app.py" ]; then
|
||||||
|
echo "❌ 错误: 请在web_client目录下运行此脚本"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查依赖是否安装
|
||||||
|
echo "📦 检查依赖..."
|
||||||
|
if ! python3 -c "import flask" 2>/dev/null; then
|
||||||
|
echo "⚠️ Flask未安装,正在安装依赖..."
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ 依赖安装失败"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ 依赖安装成功"
|
||||||
|
else
|
||||||
|
echo "✅ 依赖已安装"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================="
|
||||||
|
echo " 启动Web服务器..."
|
||||||
|
echo "========================================="
|
||||||
|
echo ""
|
||||||
|
echo "🌐 访问地址: http://localhost:5000"
|
||||||
|
echo "📝 按 Ctrl+C 停止服务"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 启动Flask应用
|
||||||
|
python3 app.py
|
||||||
|
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/* AI助手Web客户端样式 - 100%还原原型设计 */
|
||||||
|
|
||||||
|
@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css');
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Pacifico&family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
/* 全局样式 */
|
||||||
|
body {
|
||||||
|
min-height: 1024px;
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 玻璃效果 */
|
||||||
|
.glass-effect {
|
||||||
|
background: rgba(30, 41, 59, 0.3);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(100, 100, 100, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 霓虹边框 */
|
||||||
|
.neon-border {
|
||||||
|
box-shadow: 0 0 10px rgba(99, 102, 241, 0.5), 0 0 20px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户消息气泡 */
|
||||||
|
.user-bubble {
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
box-shadow: 0 0 15px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI消息气泡 */
|
||||||
|
.ai-bubble {
|
||||||
|
background: linear-gradient(135deg, #06b6d4 0%, #10b981 100%);
|
||||||
|
box-shadow: 0 0 15px rgba(6, 182, 212, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 脉冲动画 */
|
||||||
|
.pulse {
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.7); }
|
||||||
|
70% { box-shadow: 0 0 0 10px rgba(99, 102, 241, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 发光按钮 */
|
||||||
|
.glow-button {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-button:hover {
|
||||||
|
box-shadow: 0 0 15px rgba(99, 102, 241, 0.8);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 粒子背景 */
|
||||||
|
.particles {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
animation: float 6s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0) translateX(0); opacity: 0.3; }
|
||||||
|
50% { transform: translateY(-20px) translateX(10px); opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 打字指示器 */
|
||||||
|
.typing-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #10b981;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0 2px;
|
||||||
|
animation: typing 1.4s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes typing {
|
||||||
|
0%, 60%, 100% { transform: translateY(0); }
|
||||||
|
30% { transform: translateY(-5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滑入动画 */
|
||||||
|
.slide-in {
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 淡入动画 */
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.5s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通知徽章 */
|
||||||
|
.notification-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
right: -5px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 语音波形 */
|
||||||
|
.voice-wave {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wave-bar {
|
||||||
|
width: 2px;
|
||||||
|
height: 10px;
|
||||||
|
background: #6366f1;
|
||||||
|
margin: 0 1px;
|
||||||
|
animation: wave 1.2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wave-bar:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.wave-bar:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
.wave-bar:nth-child(4) { animation-delay: 0.6s; }
|
||||||
|
.wave-bar:nth-child(5) { animation-delay: 0.8s; }
|
||||||
|
|
||||||
|
@keyframes wave {
|
||||||
|
0%, 100% { height: 10px; }
|
||||||
|
50% { height: 20px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(30, 41, 59, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(99, 102, 241, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题颜色变量 */
|
||||||
|
:root {
|
||||||
|
--primary: #6366f1;
|
||||||
|
--secondary: #8b5cf6;
|
||||||
|
--bg-dark: #0f172a;
|
||||||
|
--bg-slate: #1e293b;
|
||||||
|
--text-white: #ffffff;
|
||||||
|
--text-slate: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.user-bubble, .ai-bubble {
|
||||||
|
max-width: 80% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏元素 */
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载动画 */
|
||||||
|
.loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* AI助手Web客户端 - 前端交互逻辑
|
||||||
|
* 100%还原原型设计的交互效果
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 全局变量
|
||||||
|
let currentChatId = null;
|
||||||
|
let currentAppId = 15; // 默认使用人岗匹配应用
|
||||||
|
let isTyping = false;
|
||||||
|
|
||||||
|
// API配置
|
||||||
|
const API_BASE_URL = ''; // 使用相对路径
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建动态粒子背景
|
||||||
|
*/
|
||||||
|
function createParticles() {
|
||||||
|
const particlesContainer = document.getElementById('particles');
|
||||||
|
const particleCount = 50;
|
||||||
|
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
const particle = document.createElement('div');
|
||||||
|
particle.className = 'particle';
|
||||||
|
|
||||||
|
// 随机位置和大小
|
||||||
|
const size = Math.random() * 4 + 1;
|
||||||
|
const posX = Math.random() * 100;
|
||||||
|
const posY = Math.random() * 100;
|
||||||
|
|
||||||
|
particle.style.width = `${size}px`;
|
||||||
|
particle.style.height = `${size}px`;
|
||||||
|
particle.style.left = `${posX}%`;
|
||||||
|
particle.style.top = `${posY}%`;
|
||||||
|
|
||||||
|
// 随机动画延迟
|
||||||
|
particle.style.animationDelay = `${Math.random() * 5}s`;
|
||||||
|
|
||||||
|
particlesContainer.appendChild(particle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加消息到聊天容器
|
||||||
|
*/
|
||||||
|
function addMessage(message, isUser = true) {
|
||||||
|
const chatContainer = document.getElementById('chat-container');
|
||||||
|
|
||||||
|
// 移除打字指示器
|
||||||
|
removeTypingIndicator();
|
||||||
|
|
||||||
|
const messageDiv = document.createElement('div');
|
||||||
|
messageDiv.className = `flex ${isUser ? 'justify-end' : 'justify-start'} slide-in`;
|
||||||
|
|
||||||
|
const bubbleClass = isUser ? 'user-bubble' : 'ai-bubble';
|
||||||
|
const roundedClass = isUser ? 'rounded-br-md' : 'rounded-bl-md';
|
||||||
|
|
||||||
|
messageDiv.innerHTML = `
|
||||||
|
<div class="max-w-xs lg:max-w-md ${bubbleClass} text-white p-4 rounded-2xl ${roundedClass}">
|
||||||
|
<p>${escapeHtml(message)}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
chatContainer.appendChild(messageDiv);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示打字指示器
|
||||||
|
*/
|
||||||
|
function showTypingIndicator() {
|
||||||
|
if (isTyping) return;
|
||||||
|
|
||||||
|
isTyping = true;
|
||||||
|
const chatContainer = document.getElementById('chat-container');
|
||||||
|
|
||||||
|
const typingDiv = document.createElement('div');
|
||||||
|
typingDiv.id = 'typing-indicator';
|
||||||
|
typingDiv.className = 'flex justify-start';
|
||||||
|
typingDiv.innerHTML = `
|
||||||
|
<div class="max-w-xs lg:max-w-md ai-bubble text-white p-4 rounded-2xl rounded-bl-md">
|
||||||
|
<div class="typing-indicator">
|
||||||
|
<div class="typing-dot"></div>
|
||||||
|
<div class="typing-dot"></div>
|
||||||
|
<div class="typing-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
chatContainer.appendChild(typingDiv);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除打字指示器
|
||||||
|
*/
|
||||||
|
function removeTypingIndicator() {
|
||||||
|
const typingIndicator = document.getElementById('typing-indicator');
|
||||||
|
if (typingIndicator) {
|
||||||
|
typingIndicator.remove();
|
||||||
|
isTyping = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动到底部
|
||||||
|
*/
|
||||||
|
function scrollToBottom() {
|
||||||
|
const chatContainer = document.getElementById('chat-container');
|
||||||
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML转义
|
||||||
|
*/
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息
|
||||||
|
*/
|
||||||
|
async function sendMessage() {
|
||||||
|
const input = document.getElementById('message-input');
|
||||||
|
const message = input.value.trim();
|
||||||
|
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
// 添加用户消息
|
||||||
|
addMessage(message, true);
|
||||||
|
input.value = '';
|
||||||
|
|
||||||
|
// 显示打字指示器
|
||||||
|
showTypingIndicator();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用API
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/chat/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
appId: currentAppId,
|
||||||
|
message: message,
|
||||||
|
chatId: currentChatId,
|
||||||
|
stream: false
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 移除打字指示器
|
||||||
|
removeTypingIndicator();
|
||||||
|
|
||||||
|
if (data.code === 200 && data.data) {
|
||||||
|
// 添加AI回复
|
||||||
|
addMessage(data.data.answer, false);
|
||||||
|
|
||||||
|
// 更新chatId
|
||||||
|
if (data.data.chatId) {
|
||||||
|
currentChatId = data.data.chatId;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addMessage(`错误: ${data.message || '未知错误'}`, false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送消息失败:', error);
|
||||||
|
removeTypingIndicator();
|
||||||
|
addMessage('抱歉,发送消息失败,请稍后重试。', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷回复按钮点击
|
||||||
|
*/
|
||||||
|
function handleQuickReply(text) {
|
||||||
|
const input = document.getElementById('message-input');
|
||||||
|
input.value = text;
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题切换
|
||||||
|
*/
|
||||||
|
function changeTheme(theme) {
|
||||||
|
const body = document.body;
|
||||||
|
|
||||||
|
// 移除所有主题类
|
||||||
|
body.classList.remove('theme-dark', 'theme-blue', 'theme-purple', 'theme-green');
|
||||||
|
|
||||||
|
// 添加新主题类
|
||||||
|
body.classList.add(`theme-${theme}`);
|
||||||
|
|
||||||
|
// 更新背景渐变
|
||||||
|
const gradients = {
|
||||||
|
dark: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%)',
|
||||||
|
blue: 'linear-gradient(135deg, #0c4a6e 0%, #075985 50%, #0c4a6e 100%)',
|
||||||
|
purple: 'linear-gradient(135deg, #581c87 0%, #6b21a8 50%, #581c87 100%)',
|
||||||
|
green: 'linear-gradient(135deg, #064e3b 0%, #065f46 50%, #064e3b 100%)'
|
||||||
|
};
|
||||||
|
|
||||||
|
body.style.background = gradients[theme] || gradients.dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字体切换
|
||||||
|
*/
|
||||||
|
function changeFont(font) {
|
||||||
|
const chatContainer = document.getElementById('chat-container');
|
||||||
|
chatContainer.style.fontFamily = font;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始新会话
|
||||||
|
*/
|
||||||
|
function startNewChat() {
|
||||||
|
currentChatId = null;
|
||||||
|
const chatContainer = document.getElementById('chat-container');
|
||||||
|
|
||||||
|
// 清空聊天记录(保留欢迎消息)
|
||||||
|
const messages = chatContainer.querySelectorAll('.slide-in');
|
||||||
|
messages.forEach(msg => msg.remove());
|
||||||
|
|
||||||
|
// 添加欢迎消息
|
||||||
|
addMessage('您好!我是AI助手,有什么可以帮助您的吗?', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待办事项管理
|
||||||
|
*/
|
||||||
|
function addTodoItem() {
|
||||||
|
const input = document.getElementById('todo-input');
|
||||||
|
const text = input.value.trim();
|
||||||
|
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const todoList = document.getElementById('todo-list');
|
||||||
|
const todoItem = document.createElement('div');
|
||||||
|
todoItem.className = 'flex items-center space-x-2 p-2 bg-slate-800/30 rounded-lg';
|
||||||
|
todoItem.innerHTML = `
|
||||||
|
<input type="checkbox" class="rounded text-primary">
|
||||||
|
<span class="text-slate-300 text-sm">${escapeHtml(text)}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
todoList.appendChild(todoItem);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面加载完成后初始化
|
||||||
|
*/
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
console.log('AI助手Web客户端已加载');
|
||||||
|
|
||||||
|
// 创建粒子背景
|
||||||
|
createParticles();
|
||||||
|
|
||||||
|
// 绑定发送按钮事件
|
||||||
|
const sendButton = document.getElementById('send-button');
|
||||||
|
if (sendButton) {
|
||||||
|
sendButton.addEventListener('click', sendMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定输入框回车事件
|
||||||
|
const messageInput = document.getElementById('message-input');
|
||||||
|
if (messageInput) {
|
||||||
|
messageInput.addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定快捷回复按钮
|
||||||
|
const quickReplyButtons = document.querySelectorAll('.quick-reply-btn');
|
||||||
|
quickReplyButtons.forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const text = this.textContent.trim();
|
||||||
|
// 移除图标文本
|
||||||
|
const cleanText = text.replace(/[💡📅✅]/g, '').trim();
|
||||||
|
handleQuickReply(cleanText);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 绑定主题切换按钮
|
||||||
|
const themeButtons = document.querySelectorAll('.theme-btn');
|
||||||
|
themeButtons.forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const theme = this.dataset.theme;
|
||||||
|
changeTheme(theme);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 绑定字体选择
|
||||||
|
const fontSelect = document.getElementById('font-select');
|
||||||
|
if (fontSelect) {
|
||||||
|
fontSelect.addEventListener('change', function() {
|
||||||
|
changeFont(this.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定待办事项添加
|
||||||
|
const todoAddButton = document.getElementById('todo-add-btn');
|
||||||
|
const todoInput = document.getElementById('todo-input');
|
||||||
|
if (todoAddButton && todoInput) {
|
||||||
|
todoAddButton.addEventListener('click', addTodoItem);
|
||||||
|
todoInput.addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
addTodoItem();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加欢迎消息
|
||||||
|
setTimeout(() => {
|
||||||
|
addMessage('您好!我是AI助手,有什么可以帮助您的吗?', false);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AI 助手对话界面</title>
|
||||||
|
|
||||||
|
<!-- 字体 -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Pacifico&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: '#6366f1',
|
||||||
|
secondary: '#8b5cf6'
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
'none': '0px',
|
||||||
|
'sm': '2px',
|
||||||
|
DEFAULT: '4px',
|
||||||
|
'md': '8px',
|
||||||
|
'lg': '12px',
|
||||||
|
'xl': '16px',
|
||||||
|
'2xl': '20px',
|
||||||
|
'3xl': '24px',
|
||||||
|
'full': '9999px',
|
||||||
|
'button': '4px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- 自定义样式 -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body class="relative">
|
||||||
|
<!-- 动态背景粒子效果 -->
|
||||||
|
<div class="particles" id="particles"></div>
|
||||||
|
|
||||||
|
<div class="flex h-screen">
|
||||||
|
<!-- 左侧用户信息面板 -->
|
||||||
|
<div class="w-80 bg-slate-900/80 glass-effect border-r border-slate-700 flex flex-col">
|
||||||
|
<div class="p-6 border-b border-slate-700">
|
||||||
|
<h2 class="text-xl font-bold text-white mb-4">用户画像</h2>
|
||||||
|
<div class="flex items-center space-x-3 mb-4">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-gradient-to-r from-primary to-secondary flex items-center justify-center text-white text-xl font-bold">
|
||||||
|
U
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-medium">张伟明</p>
|
||||||
|
<p class="text-slate-400 text-sm">AI 助手用户</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 个性化推荐 -->
|
||||||
|
<div class="mt-4 p-3 bg-slate-800/50 rounded-lg">
|
||||||
|
<h3 class="text-sm font-medium text-white mb-2">个性化推荐</h3>
|
||||||
|
<p class="text-slate-300 text-xs">根据您的使用习惯,推荐以下功能</p>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1">
|
||||||
|
<span class="px-2 py-1 bg-primary/20 text-primary text-xs rounded-full">智能问答</span>
|
||||||
|
<span class="px-2 py-1 bg-secondary/20 text-secondary text-xs rounded-full">语音识别</span>
|
||||||
|
<span class="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-full">任务管理</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 待办事项 -->
|
||||||
|
<div class="p-6 border-b border-slate-700">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-4">待办事项</h3>
|
||||||
|
<div class="space-y-3" id="todo-list">
|
||||||
|
<div class="flex items-center space-x-2 p-2 bg-slate-800/30 rounded-lg">
|
||||||
|
<input type="checkbox" class="rounded text-primary">
|
||||||
|
<span class="text-slate-300 text-sm">完成项目报告</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2 p-2 bg-slate-800/30 rounded-lg">
|
||||||
|
<input type="checkbox" class="rounded text-primary">
|
||||||
|
<span class="text-slate-300 text-sm">准备会议材料</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2 p-2 bg-slate-800/30 rounded-lg">
|
||||||
|
<input type="checkbox" class="rounded text-primary">
|
||||||
|
<span class="text-slate-300 text-sm">回复客户邮件</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex">
|
||||||
|
<input type="text" id="todo-input" placeholder="添加新任务..." class="flex-1 bg-slate-800 text-white text-sm px-3 py-2 rounded-l-lg border border-slate-600 focus:outline-none focus:border-primary">
|
||||||
|
<button id="todo-add-btn" class="bg-primary text-white px-3 py-2 rounded-r-lg hover:bg-primary/80 transition">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 提醒设置 -->
|
||||||
|
<div class="p-6 flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-4">提醒设置</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="p-3 bg-slate-800/30 rounded-lg">
|
||||||
|
<p class="text-slate-300 text-sm">每日 9:00 AM</p>
|
||||||
|
<p class="text-white text-sm">晨会准备</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-slate-800/30 rounded-lg">
|
||||||
|
<p class="text-slate-300 text-sm">每周三 2:00 PM</p>
|
||||||
|
<p class="text-white text-sm">团队同步会议</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-slate-800/30 rounded-lg">
|
||||||
|
<p class="text-slate-300 text-sm">每月 15 日</p>
|
||||||
|
<p class="text-white text-sm">月度总结报告</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="w-full mt-4 bg-slate-700 text-white py-2 rounded-lg hover:bg-slate-600 transition flex items-center justify-center space-x-2">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
<span>设置新提醒</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主对话区域 -->
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
<!-- 顶部导航栏 -->
|
||||||
|
<div class="h-16 bg-slate-900/80 glass-effect border-b border-slate-700 flex items-center justify-between px-6 relative">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<h1 class="text-xl font-bold text-white font-['Pacifico']">AI 助手</h1>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button class="theme-btn px-3 py-1 bg-slate-700 text-white rounded-lg text-sm hover:bg-slate-600 transition" data-theme="dark">深空黑</button>
|
||||||
|
<button class="theme-btn px-3 py-1 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-500 transition" data-theme="blue">极光蓝</button>
|
||||||
|
<button class="theme-btn px-3 py-1 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-500 transition" data-theme="purple">霓虹紫</button>
|
||||||
|
<button class="theme-btn px-3 py-1 bg-green-600 text-white rounded-lg text-sm hover:bg-green-500 transition" data-theme="green">科技绿</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 字体选择 -->
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<select id="font-select" class="bg-slate-800 text-white px-3 py-1 rounded-lg border border-slate-600 focus:outline-none focus:border-primary">
|
||||||
|
<option value="'Inter', sans-serif">默认字体</option>
|
||||||
|
<option value="'Source Han Sans CN', sans-serif">思源黑体</option>
|
||||||
|
<option value="'PingFang SC', sans-serif">苹方</option>
|
||||||
|
<option value="'Roboto', sans-serif">Roboto</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 消息通知 -->
|
||||||
|
<div class="relative">
|
||||||
|
<button class="text-white p-2 hover:bg-slate-700 rounded-lg transition relative">
|
||||||
|
<i class="fas fa-bell text-lg"></i>
|
||||||
|
<div class="notification-badge">3</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-8 h-8 bg-gradient-to-r from-primary to-secondary rounded-full flex items-center justify-center text-white">
|
||||||
|
<i class="fas fa-user text-sm"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 智能回复提示 -->
|
||||||
|
<div class="p-4 bg-slate-800/50 border-b border-slate-700">
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<button class="quick-reply-btn px-4 py-2 bg-slate-700 text-slate-300 rounded-lg text-sm hover:bg-slate-600 transition whitespace-nowrap">
|
||||||
|
<i class="fas fa-lightbulb mr-2"></i>今日天气如何?
|
||||||
|
</button>
|
||||||
|
<button class="quick-reply-btn px-4 py-2 bg-slate-700 text-slate-300 rounded-lg text-sm hover:bg-slate-600 transition whitespace-nowrap">
|
||||||
|
<i class="fas fa-calendar mr-2"></i>明天的会议安排
|
||||||
|
</button>
|
||||||
|
<button class="quick-reply-btn px-4 py-2 bg-slate-700 text-slate-300 rounded-lg text-sm hover:bg-slate-600 transition whitespace-nowrap">
|
||||||
|
<i class="fas fa-tasks mr-2"></i>帮我制定工作计划
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 对话内容区域 -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-6 space-y-4" id="chat-container">
|
||||||
|
<!-- 用户消息示例 -->
|
||||||
|
<div class="flex justify-end slide-in">
|
||||||
|
<div class="max-w-xs lg:max-w-md user-bubble text-white p-4 rounded-2xl rounded-br-md">
|
||||||
|
<p>你好,我想了解一下今天的工作安排。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI 回复示例 -->
|
||||||
|
<div class="flex justify-start slide-in">
|
||||||
|
<div class="max-w-xs lg:max-w-md ai-bubble text-white p-4 rounded-2xl rounded-bl-md">
|
||||||
|
<p>您好!根据您的日程安排,今天有以下任务:</p>
|
||||||
|
<ul class="mt-2 space-y-1 text-sm">
|
||||||
|
<li>• 9:00 AM - 晨会准备</li>
|
||||||
|
<li>• 10:30 AM - 客户电话会议</li>
|
||||||
|
<li>• 2:00 PM - 项目进度汇报</li>
|
||||||
|
<li>• 4:00 PM - 团队协作讨论</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 输入区域 -->
|
||||||
|
<div class="p-6 bg-slate-900/80 glass-effect border-t border-slate-700">
|
||||||
|
<div class="flex items-end space-x-4">
|
||||||
|
<!-- 语音输入按钮 -->
|
||||||
|
<button class="w-12 h-12 bg-slate-700 rounded-full flex items-center justify-center text-white hover:bg-slate-600 transition glow-button">
|
||||||
|
<div class="voice-wave">
|
||||||
|
<div class="wave-bar"></div>
|
||||||
|
<div class="wave-bar"></div>
|
||||||
|
<div class="wave-bar"></div>
|
||||||
|
<div class="wave-bar"></div>
|
||||||
|
<div class="wave-bar"></div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 文本输入框 -->
|
||||||
|
<div class="flex-1 relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="message-input"
|
||||||
|
placeholder="输入消息..."
|
||||||
|
class="w-full bg-slate-800 text-white px-4 py-3 rounded-xl border border-slate-600 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all duration-300"
|
||||||
|
style="border: 1px solid rgba(99, 102, 241, 0.5); box-shadow: 0 0 10px rgba(99, 102, 241, 0.2);"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 发送按钮 -->
|
||||||
|
<button id="send-button" class="w-12 h-12 bg-gradient-to-r from-primary to-secondary rounded-full flex items-center justify-center text-white hover:from-primary/80 hover:to-secondary/80 transition glow-button pulse">
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 功能按钮 -->
|
||||||
|
<div class="flex justify-between items-center mt-3">
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button class="p-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition">
|
||||||
|
<i class="fas fa-image"></i>
|
||||||
|
</button>
|
||||||
|
<button class="p-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition">
|
||||||
|
<i class="fas fa-paperclip"></i>
|
||||||
|
</button>
|
||||||
|
<button class="p-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition">
|
||||||
|
<i class="fas fa-microphone"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-slate-400 text-sm">连接状态</span>
|
||||||
|
<div class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript -->
|
||||||
|
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
AI助手Web客户端测试
|
||||||
|
测试Flask应用的各个端点
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import json
|
||||||
|
from app import app
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebClient(unittest.TestCase):
|
||||||
|
"""Web客户端测试类"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""测试前准备"""
|
||||||
|
self.app = app
|
||||||
|
self.app.config['TESTING'] = True
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
|
||||||
|
def test_index_page(self):
|
||||||
|
"""测试主页面"""
|
||||||
|
response = self.client.get('/')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'AI', response.data)
|
||||||
|
|
||||||
|
def test_health_check(self):
|
||||||
|
"""测试健康检查端点"""
|
||||||
|
response = self.client.get('/api/health')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
self.assertEqual(data['code'], 200)
|
||||||
|
self.assertEqual(data['message'], 'OK')
|
||||||
|
self.assertIn('status', data['data'])
|
||||||
|
self.assertEqual(data['data']['status'], 'healthy')
|
||||||
|
|
||||||
|
def test_get_applications(self):
|
||||||
|
"""测试获取应用列表"""
|
||||||
|
response = self.client.get('/api/applications')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
self.assertEqual(data['code'], 200)
|
||||||
|
self.assertIn('data', data)
|
||||||
|
self.assertIsInstance(data['data'], list)
|
||||||
|
|
||||||
|
def test_send_message_without_content(self):
|
||||||
|
"""测试发送空消息"""
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/chat/send',
|
||||||
|
data=json.dumps({
|
||||||
|
'appId': 15,
|
||||||
|
'message': '',
|
||||||
|
'chatId': None,
|
||||||
|
'stream': False
|
||||||
|
}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
self.assertEqual(data['code'], 400)
|
||||||
|
|
||||||
|
def test_send_message_with_content(self):
|
||||||
|
"""测试发送正常消息"""
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/chat/send',
|
||||||
|
data=json.dumps({
|
||||||
|
'appId': 15,
|
||||||
|
'message': '你好',
|
||||||
|
'chatId': None,
|
||||||
|
'stream': False
|
||||||
|
}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
self.assertEqual(data['code'], 200)
|
||||||
|
self.assertIn('data', data)
|
||||||
|
self.assertIn('answer', data['data'])
|
||||||
|
|
||||||
|
def test_cors_headers(self):
|
||||||
|
"""测试CORS头"""
|
||||||
|
response = self.client.get('/api/health')
|
||||||
|
self.assertIn('Access-Control-Allow-Origin', response.headers)
|
||||||
|
|
||||||
|
def test_static_files(self):
|
||||||
|
"""测试静态文件访问"""
|
||||||
|
# 测试CSS文件
|
||||||
|
response = self.client.get('/static/css/style.css')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# 测试JS文件
|
||||||
|
response = self.client.get('/static/js/main.js')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAPIResponses(unittest.TestCase):
|
||||||
|
"""API响应格式测试"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""测试前准备"""
|
||||||
|
self.app = app
|
||||||
|
self.app.config['TESTING'] = True
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
|
||||||
|
def test_response_format(self):
|
||||||
|
"""测试响应格式统一性"""
|
||||||
|
response = self.client.get('/api/health')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
|
||||||
|
# 检查必需字段
|
||||||
|
self.assertIn('code', data)
|
||||||
|
self.assertIn('message', data)
|
||||||
|
self.assertIn('data', data)
|
||||||
|
|
||||||
|
# 检查字段类型
|
||||||
|
self.assertIsInstance(data['code'], int)
|
||||||
|
self.assertIsInstance(data['message'], str)
|
||||||
|
|
||||||
|
def test_error_response_format(self):
|
||||||
|
"""测试错误响应格式"""
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/chat/send',
|
||||||
|
data=json.dumps({'message': ''}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
data = json.loads(response.data)
|
||||||
|
|
||||||
|
# 错误响应也应该有统一格式
|
||||||
|
self.assertIn('code', data)
|
||||||
|
self.assertIn('message', data)
|
||||||
|
self.assertEqual(data['code'], 400)
|
||||||
|
|
||||||
|
|
||||||
|
def run_tests():
|
||||||
|
"""运行所有测试"""
|
||||||
|
# 创建测试套件
|
||||||
|
loader = unittest.TestLoader()
|
||||||
|
suite = unittest.TestSuite()
|
||||||
|
|
||||||
|
# 添加测试
|
||||||
|
suite.addTests(loader.loadTestsFromTestCase(TestWebClient))
|
||||||
|
suite.addTests(loader.loadTestsFromTestCase(TestAPIResponses))
|
||||||
|
|
||||||
|
# 运行测试
|
||||||
|
runner = unittest.TextTestRunner(verbosity=2)
|
||||||
|
result = runner.run(suite)
|
||||||
|
|
||||||
|
# 返回测试结果
|
||||||
|
return result.wasSuccessful()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
success = run_tests()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
Reference in New Issue
Block a user