diff --git a/tools/__pycache__/config.cpython-312.pyc b/tools/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..5e6b484 Binary files /dev/null and b/tools/__pycache__/config.cpython-312.pyc differ diff --git a/tools/app.py b/tools/app.py new file mode 100644 index 0000000..4eab8bd --- /dev/null +++ b/tools/app.py @@ -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) + diff --git a/tools/config.py b/tools/config.py new file mode 100644 index 0000000..5452c22 --- /dev/null +++ b/tools/config.py @@ -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 # 默认请求间隔(毫秒) + diff --git a/tools/models/__init__.py b/tools/models/__init__.py new file mode 100644 index 0000000..89e3d9e --- /dev/null +++ b/tools/models/__init__.py @@ -0,0 +1,9 @@ +""" +数据模型模块 + +@author huazm +""" +from .database import db, RequestHistory, Collection, Environment + +__all__ = ['db', 'RequestHistory', 'Collection', 'Environment'] + diff --git a/tools/models/__pycache__/__init__.cpython-312.pyc b/tools/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..d6a45d1 Binary files /dev/null and b/tools/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/tools/models/__pycache__/database.cpython-312.pyc b/tools/models/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000..10136a1 Binary files /dev/null and b/tools/models/__pycache__/database.cpython-312.pyc differ diff --git a/tools/models/database.py b/tools/models/database.py new file mode 100644 index 0000000..9463015 --- /dev/null +++ b/tools/models/database.py @@ -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 + } + diff --git a/tools/requirements.txt b/tools/requirements.txt new file mode 100644 index 0000000..7af58d0 --- /dev/null +++ b/tools/requirements.txt @@ -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 + diff --git a/tools/routes/__init__.py b/tools/routes/__init__.py new file mode 100644 index 0000000..fe4ee42 --- /dev/null +++ b/tools/routes/__init__.py @@ -0,0 +1,9 @@ +""" +路由模块 + +@author huazm +""" +from .api import api_bp + +__all__ = ['api_bp'] + diff --git a/tools/routes/__pycache__/__init__.cpython-312.pyc b/tools/routes/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..470e5b8 Binary files /dev/null and b/tools/routes/__pycache__/__init__.cpython-312.pyc differ diff --git a/tools/routes/__pycache__/api.cpython-312.pyc b/tools/routes/__pycache__/api.cpython-312.pyc new file mode 100644 index 0000000..2a79369 Binary files /dev/null and b/tools/routes/__pycache__/api.cpython-312.pyc differ diff --git a/tools/routes/api.py b/tools/routes/api.py new file mode 100644 index 0000000..131c150 --- /dev/null +++ b/tools/routes/api.py @@ -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/', 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/', 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//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 + diff --git a/tools/services/__init__.py b/tools/services/__init__.py new file mode 100644 index 0000000..458258f --- /dev/null +++ b/tools/services/__init__.py @@ -0,0 +1,9 @@ +""" +服务模块 + +@author huazm +""" +from .http_client import HttpClient + +__all__ = ['HttpClient'] + diff --git a/tools/services/__pycache__/__init__.cpython-312.pyc b/tools/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..a867758 Binary files /dev/null and b/tools/services/__pycache__/__init__.cpython-312.pyc differ diff --git a/tools/services/__pycache__/http_client.cpython-312.pyc b/tools/services/__pycache__/http_client.cpython-312.pyc new file mode 100644 index 0000000..adfc8ce Binary files /dev/null and b/tools/services/__pycache__/http_client.cpython-312.pyc differ diff --git a/tools/services/http_client.py b/tools/services/http_client.py new file mode 100644 index 0000000..1de1918 --- /dev/null +++ b/tools/services/http_client.py @@ -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 + diff --git a/tools/static/css/main.css b/tools/static/css/main.css new file mode 100644 index 0000000..b6af25b --- /dev/null +++ b/tools/static/css/main.css @@ -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); +} diff --git a/tools/static/js/app.js b/tools/static/js/app.js new file mode 100644 index 0000000..88402fb --- /dev/null +++ b/tools/static/js/app.js @@ -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 = ` + + + `; + container.appendChild(group); + }); +} + +// ==================== Key-Value 编辑器 ==================== +function addKVRow(editorId) { + const editor = document.getElementById(editorId); + const row = document.createElement('div'); + row.className = 'kv-row'; + row.innerHTML = ` + + + + `; + 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 = '
加载中...
'; + + 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 => ` +
+ ${item.method} + ${item.url} + ${item.statusCode || '--'} + ${new Date(item.createdAt).toLocaleString()} +
+ `).join(''); + } else { + list.innerHTML = '
暂无历史记录
'; + } + } catch (error) { + list.innerHTML = '
加载失败
'; + } +} + +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(); + } + }); +}); + diff --git a/tools/templates/index.html b/tools/templates/index.html new file mode 100644 index 0000000..6d7ba32 --- /dev/null +++ b/tools/templates/index.html @@ -0,0 +1,208 @@ + + + + + + API Tester - 未来科技接口测试工具 + + + + + + + + + + +
+ +
+ + +
+ + +
+
+ + +
+ +
+ +
+ + + +
+ + +
+ + + + +
+ + +
+ +
+
+
+ + + +
+
+ +
+ + +
+
+
+ + + +
+
+ + + +
+
+ Content-Type: + + + + + +
+
+ Accept: + + + +
+
+ 鉴权: + + + + +
+
+ 其他常用: + + + + + + +
+
+
+ + +
+
+ + + + + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+
+ + +
+
+
+ -- + 等待请求 +
+
+ 耗时: -- + 大小: -- +
+
+
+ + +
+
+
// 响应将显示在这里
+ +
+
+
+ + + +
+ + + + + diff --git a/web_client/.env.example b/web_client/.env.example new file mode 100644 index 0000000..4313969 --- /dev/null +++ b/web_client/.env.example @@ -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 + diff --git a/web_client/DEPLOYMENT.md b/web_client/DEPLOYMENT.md new file mode 100644 index 0000000..fea09ee --- /dev/null +++ b/web_client/DEPLOYMENT.md @@ -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配置 + +--- + +**部署成功后,记得测试所有功能!** ✅ + diff --git a/web_client/QUICKSTART.md b/web_client/QUICKSTART.md new file mode 100644 index 0000000..ad1e104 --- /dev/null +++ b/web_client/QUICKSTART.md @@ -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客户端!** 🎉 + diff --git a/web_client/README.md b/web_client/README.md new file mode 100644 index 0000000..4fbbde0 --- /dev/null +++ b/web_client/README.md @@ -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文档 + diff --git a/web_client/__pycache__/app.cpython-312.pyc b/web_client/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000..01e7cc4 Binary files /dev/null and b/web_client/__pycache__/app.cpython-312.pyc differ diff --git a/web_client/app.py b/web_client/app.py new file mode 100644 index 0000000..0ca681a --- /dev/null +++ b/web_client/app.py @@ -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) + diff --git a/web_client/requirements.txt b/web_client/requirements.txt new file mode 100644 index 0000000..20c0f50 --- /dev/null +++ b/web_client/requirements.txt @@ -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 + diff --git a/web_client/start.sh b/web_client/start.sh new file mode 100755 index 0000000..ea6bcc3 --- /dev/null +++ b/web_client/start.sh @@ -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 + diff --git a/web_client/static/css/style.css b/web_client/static/css/style.css new file mode 100644 index 0000000..10c0df1 --- /dev/null +++ b/web_client/static/css/style.css @@ -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; +} + diff --git a/web_client/static/js/main.js b/web_client/static/js/main.js new file mode 100644 index 0000000..cbef5c3 --- /dev/null +++ b/web_client/static/js/main.js @@ -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 = ` +
+

${escapeHtml(message)}

+
+ `; + + 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 = ` +
+
+
+
+
+
+
+ `; + + 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 = ` + + ${escapeHtml(text)} + `; + + 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); +}); + + diff --git a/web_client/templates/index.html b/web_client/templates/index.html new file mode 100644 index 0000000..083b3d1 --- /dev/null +++ b/web_client/templates/index.html @@ -0,0 +1,254 @@ + + + + + + AI 助手对话界面 + + + + + + + + + + + + + + + +
+ +
+ +
+
+

用户画像

+
+
+ U +
+
+

张伟明

+

AI 助手用户

+
+
+ + +
+

个性化推荐

+

根据您的使用习惯,推荐以下功能

+
+ 智能问答 + 语音识别 + 任务管理 +
+
+
+ + +
+

待办事项

+
+
+ + 完成项目报告 +
+
+ + 准备会议材料 +
+
+ + 回复客户邮件 +
+
+
+ + +
+
+ + +
+

提醒设置

+
+
+

每日 9:00 AM

+

晨会准备

+
+
+

每周三 2:00 PM

+

团队同步会议

+
+
+

每月 15 日

+

月度总结报告

+
+
+ +
+
+ + +
+ +
+
+

AI 助手

+
+ + + + +
+
+ + +
+ + + +
+ +
+ +
+ +
+
+
+ + +
+
+ + + +
+
+ + +
+ +
+
+

你好,我想了解一下今天的工作安排。

+
+
+ + +
+
+

您好!根据您的日程安排,今天有以下任务:

+
    +
  • • 9:00 AM - 晨会准备
  • +
  • • 10:30 AM - 客户电话会议
  • +
  • • 2:00 PM - 项目进度汇报
  • +
  • • 4:00 PM - 团队协作讨论
  • +
+
+
+
+ + +
+
+ + + + +
+ +
+ + + +
+ + +
+
+ + + +
+
+ 连接状态 +
+
+
+
+
+
+ + + + + diff --git a/web_client/test_app.py b/web_client/test_app.py new file mode 100644 index 0000000..592b477 --- /dev/null +++ b/web_client/test_app.py @@ -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) +