diff --git a/backend/apps/asset/views.py b/backend/apps/asset/views.py index 51b6d393..771921b8 100644 --- a/backend/apps/asset/views.py +++ b/backend/apps/asset/views.py @@ -164,12 +164,13 @@ class SubdomainViewSet(viewsets.ModelViewSet): 响应: { - "message": "批量创建完成", - "createdCount": 10, - "skippedCount": 2, - "invalidCount": 1, - "mismatchedCount": 1, - "totalReceived": 14 + "data": { + "createdCount": 10, + "skippedCount": 2, + "invalidCount": 1, + "mismatchedCount": 1, + "totalReceived": 14 + } } """ from apps.targets.models import Target @@ -220,12 +221,13 @@ class SubdomainViewSet(viewsets.ModelViewSet): ) return Response({ - 'message': '批量创建完成', - 'createdCount': result.created_count, - 'skippedCount': result.skipped_count, - 'invalidCount': result.invalid_count, - 'mismatchedCount': result.mismatched_count, - 'totalReceived': result.total_received, + 'data': { + 'createdCount': result.created_count, + 'skippedCount': result.skipped_count, + 'invalidCount': result.invalid_count, + 'mismatchedCount': result.mismatched_count, + 'totalReceived': result.total_received, + } }, status=status.HTTP_200_OK) @action(detail=False, methods=['get'], url_path='export') @@ -299,9 +301,9 @@ class WebSiteViewSet(viewsets.ModelViewSet): 响应: { - "message": "批量创建完成", - "createdCount": 10, - "mismatchedCount": 2 + "data": { + "createdCount": 10 + } } """ from apps.targets.models import Target @@ -346,8 +348,9 @@ class WebSiteViewSet(viewsets.ModelViewSet): ) return Response({ - 'message': '批量创建完成', - 'createdCount': created_count, + 'data': { + 'createdCount': created_count, + } }, status=status.HTTP_200_OK) @action(detail=False, methods=['get'], url_path='export') @@ -426,9 +429,9 @@ class DirectoryViewSet(viewsets.ModelViewSet): 响应: { - "message": "批量创建完成", - "createdCount": 10, - "mismatchedCount": 2 + "data": { + "createdCount": 10 + } } """ from apps.targets.models import Target @@ -473,8 +476,9 @@ class DirectoryViewSet(viewsets.ModelViewSet): ) return Response({ - 'message': '批量创建完成', - 'createdCount': created_count, + 'data': { + 'createdCount': created_count, + } }, status=status.HTTP_200_OK) @action(detail=False, methods=['get'], url_path='export') @@ -553,9 +557,9 @@ class EndpointViewSet(viewsets.ModelViewSet): 响应: { - "message": "批量创建完成", - "createdCount": 10, - "mismatchedCount": 2 + "data": { + "createdCount": 10 + } } """ from apps.targets.models import Target @@ -600,8 +604,9 @@ class EndpointViewSet(viewsets.ModelViewSet): ) return Response({ - 'message': '批量创建完成', - 'createdCount': created_count, + 'data': { + 'createdCount': created_count, + } }, status=status.HTTP_200_OK) @action(detail=False, methods=['get'], url_path='export') diff --git a/backend/apps/common/error_codes.py b/backend/apps/common/error_codes.py new file mode 100644 index 00000000..bf8eb827 --- /dev/null +++ b/backend/apps/common/error_codes.py @@ -0,0 +1,31 @@ +""" +标准化错误码定义 + +采用简化方案(参考 Stripe、GitHub 等大厂做法): +- 只定义 5-10 个通用错误码 +- 未知错误使用通用错误码 +- 错误码格式:大写字母和下划线组成 +""" + + +class ErrorCodes: + """标准化错误码 + + 只定义通用错误码,其他错误使用通用消息。 + 这是 Stripe、GitHub 等大厂的标准做法。 + + 错误码格式规范: + - 使用大写字母和下划线 + - 简洁明了,易于理解 + - 前端通过错误码映射到 i18n 键 + """ + + # 通用错误码(8 个) + VALIDATION_ERROR = 'VALIDATION_ERROR' # 输入验证失败 + NOT_FOUND = 'NOT_FOUND' # 资源未找到 + PERMISSION_DENIED = 'PERMISSION_DENIED' # 权限不足 + SERVER_ERROR = 'SERVER_ERROR' # 服务器内部错误 + BAD_REQUEST = 'BAD_REQUEST' # 请求格式错误 + CONFLICT = 'CONFLICT' # 资源冲突(如重复创建) + UNAUTHORIZED = 'UNAUTHORIZED' # 未认证 + RATE_LIMITED = 'RATE_LIMITED' # 请求过于频繁 diff --git a/backend/apps/common/response_helpers.py b/backend/apps/common/response_helpers.py new file mode 100644 index 00000000..e9973286 --- /dev/null +++ b/backend/apps/common/response_helpers.py @@ -0,0 +1,93 @@ +""" +标准化 API 响应辅助函数 + +遵循行业标准(RFC 9457 Problem Details)和大厂实践(Google、Stripe、GitHub): +- 成功响应只包含数据,不包含 message 字段 +- 错误响应使用机器可读的错误码,前端映射到 i18n 消息 +""" +from typing import Any, Dict, List, Optional, Union + +from rest_framework import status +from rest_framework.response import Response + + +def success_response( + data: Optional[Union[Dict[str, Any], List[Any]]] = None, + meta: Optional[Dict[str, Any]] = None, + status_code: int = status.HTTP_200_OK +) -> Response: + """ + 标准化成功响应 + + Args: + data: 响应数据(dict 或 list) + meta: 元数据(如 count、total、page) + status_code: HTTP 状态码,默认 200 + + Returns: + Response: DRF Response 对象 + + Examples: + # 单个资源 + >>> success_response(data={'id': 1, 'name': 'Test'}) + {'data': {'id': 1, 'name': 'Test'}} + + # 列表资源带分页 + >>> success_response(data=[...], meta={'total': 100, 'page': 1}) + {'data': [...], 'meta': {'total': 100, 'page': 1}} + + # 创建资源 + >>> success_response(data={'id': 1}, status_code=201) + """ + response_body: Dict[str, Any] = {} + + if data is not None: + response_body['data'] = data + + if meta is not None: + response_body['meta'] = meta + + return Response(response_body, status=status_code) + + +def error_response( + code: str, + message: Optional[str] = None, + details: Optional[List[Dict[str, Any]]] = None, + status_code: int = status.HTTP_400_BAD_REQUEST +) -> Response: + """ + 标准化错误响应 + + Args: + code: 错误码(如 'VALIDATION_ERROR', 'NOT_FOUND') + 格式:大写字母和下划线组成 + message: 开发者调试信息(非用户显示) + details: 详细错误信息(如字段级验证错误) + status_code: HTTP 状态码,默认 400 + + Returns: + Response: DRF Response 对象 + + Examples: + # 简单错误 + >>> error_response(code='NOT_FOUND', status_code=404) + {'error': {'code': 'NOT_FOUND'}} + + # 带调试信息 + >>> error_response( + ... code='VALIDATION_ERROR', + ... message='Invalid input data', + ... details=[{'field': 'name', 'message': 'Required'}] + ... ) + {'error': {'code': 'VALIDATION_ERROR', 'message': '...', 'details': [...]}} + """ + error_body: Dict[str, Any] = {'code': code} + + if message: + error_body['message'] = message + + if details: + error_body['details'] = details + + return Response({'error': error_body}, status=status_code) diff --git a/backend/apps/common/views/auth_views.py b/backend/apps/common/views/auth_views.py index 4504d653..5378b437 100644 --- a/backend/apps/common/views/auth_views.py +++ b/backend/apps/common/views/auth_views.py @@ -11,6 +11,9 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import AllowAny, IsAuthenticated +from apps.common.response_helpers import success_response, error_response +from apps.common.error_codes import ErrorCodes + logger = logging.getLogger(__name__) @@ -28,9 +31,10 @@ class LoginView(APIView): password = request.data.get('password') if not username or not password: - return Response( - {'error': '请提供用户名和密码'}, - status=status.HTTP_400_BAD_REQUEST + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='Username and password are required', + status_code=status.HTTP_400_BAD_REQUEST ) user = authenticate(request, username=username, password=password) @@ -38,20 +42,22 @@ class LoginView(APIView): if user is not None: login(request, user) logger.info(f"用户 {username} 登录成功") - return Response({ - 'message': '登录成功', - 'user': { - 'id': user.id, - 'username': user.username, - 'isStaff': user.is_staff, - 'isSuperuser': user.is_superuser, + return success_response( + data={ + 'user': { + 'id': user.id, + 'username': user.username, + 'isStaff': user.is_staff, + 'isSuperuser': user.is_superuser, + } } - }) + ) else: logger.warning(f"用户 {username} 登录失败:用户名或密码错误") - return Response( - {'error': '用户名或密码错误'}, - status=status.HTTP_401_UNAUTHORIZED + return error_response( + code=ErrorCodes.UNAUTHORIZED, + message='Invalid username or password', + status_code=status.HTTP_401_UNAUTHORIZED ) @@ -79,7 +85,7 @@ class LogoutView(APIView): logout(request) else: logout(request) - return Response({'message': '已登出'}) + return success_response() @method_decorator(csrf_exempt, name='dispatch') @@ -100,22 +106,26 @@ class MeView(APIView): if user_id: try: user = User.objects.get(pk=user_id) - return Response({ - 'authenticated': True, - 'user': { - 'id': user.id, - 'username': user.username, - 'isStaff': user.is_staff, - 'isSuperuser': user.is_superuser, + return success_response( + data={ + 'authenticated': True, + 'user': { + 'id': user.id, + 'username': user.username, + 'isStaff': user.is_staff, + 'isSuperuser': user.is_superuser, + } } - }) + ) except User.DoesNotExist: pass - return Response({ - 'authenticated': False, - 'user': None - }) + return success_response( + data={ + 'authenticated': False, + 'user': None + } + ) @method_decorator(csrf_exempt, name='dispatch') @@ -134,17 +144,19 @@ class ChangePasswordView(APIView): user_id = request.session.get('_auth_user_id') if not user_id: - return Response( - {'error': '请先登录'}, - status=status.HTTP_401_UNAUTHORIZED + return error_response( + code=ErrorCodes.UNAUTHORIZED, + message='Please login first', + status_code=status.HTTP_401_UNAUTHORIZED ) try: user = User.objects.get(pk=user_id) except User.DoesNotExist: - return Response( - {'error': '用户不存在'}, - status=status.HTTP_401_UNAUTHORIZED + return error_response( + code=ErrorCodes.UNAUTHORIZED, + message='User does not exist', + status_code=status.HTTP_401_UNAUTHORIZED ) # CamelCaseParser 将 oldPassword -> old_password @@ -152,15 +164,17 @@ class ChangePasswordView(APIView): new_password = request.data.get('new_password') if not old_password or not new_password: - return Response( - {'error': '请提供旧密码和新密码'}, - status=status.HTTP_400_BAD_REQUEST + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='Old password and new password are required', + status_code=status.HTTP_400_BAD_REQUEST ) if not user.check_password(old_password): - return Response( - {'error': '旧密码错误'}, - status=status.HTTP_400_BAD_REQUEST + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='Old password is incorrect', + status_code=status.HTTP_400_BAD_REQUEST ) user.set_password(new_password) @@ -170,4 +184,4 @@ class ChangePasswordView(APIView): update_session_auth_hash(request, user) logger.info(f"用户 {user.username} 已修改密码") - return Response({'message': '密码修改成功'}) + return success_response() diff --git a/backend/apps/common/views/system_log_views.py b/backend/apps/common/views/system_log_views.py index d476b518..9f0bbcf9 100644 --- a/backend/apps/common/views/system_log_views.py +++ b/backend/apps/common/views/system_log_views.py @@ -13,6 +13,8 @@ from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView +from apps.common.response_helpers import success_response, error_response +from apps.common.error_codes import ErrorCodes from apps.common.services.system_log_service import SystemLogService @@ -61,9 +63,17 @@ class SystemLogsView(APIView): # 调用服务获取日志内容 content = self.service.get_logs_content(lines=lines) - return Response({"content": content}) + return success_response(data={"content": content}) except ValueError: - return Response({"error": "lines 参数必须是整数"}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='lines must be an integer', + status_code=status.HTTP_400_BAD_REQUEST + ) except Exception: logger.exception("获取系统日志失败") - return Response({"error": "获取系统日志失败"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + code=ErrorCodes.SERVER_ERROR, + message='Failed to get system logs', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) diff --git a/backend/apps/engine/views/nuclei_template_repo_views.py b/backend/apps/engine/views/nuclei_template_repo_views.py index 803fa84f..61067e8c 100644 --- a/backend/apps/engine/views/nuclei_template_repo_views.py +++ b/backend/apps/engine/views/nuclei_template_repo_views.py @@ -31,6 +31,8 @@ from rest_framework.decorators import action from rest_framework.request import Request from rest_framework.response import Response +from apps.common.response_helpers import success_response, error_response +from apps.common.error_codes import ErrorCodes from apps.engine.models import NucleiTemplateRepo from apps.engine.serializers import NucleiTemplateRepoSerializer from apps.engine.services import NucleiTemplateRepoService @@ -107,18 +109,30 @@ class NucleiTemplateRepoViewSet(viewsets.ModelViewSet): try: repo_id = int(pk) if pk is not None else None except (TypeError, ValueError): - return Response({"message": "无效的仓库 ID"}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='Invalid repository ID', + status_code=status.HTTP_400_BAD_REQUEST + ) # 调用 Service 层 try: result = self.service.refresh_repo(repo_id) except ValidationError as exc: - return Response({"message": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message=str(exc), + status_code=status.HTTP_400_BAD_REQUEST + ) except Exception as exc: # noqa: BLE001 logger.error("刷新 Nuclei 模板仓库失败: %s", exc, exc_info=True) - return Response({"message": f"刷新仓库失败: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + code=ErrorCodes.SERVER_ERROR, + message=f'Refresh failed: {exc}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) - return Response({"message": "刷新成功", "result": result}, status=status.HTTP_200_OK) + return success_response(data={'result': result}) # ==================== 自定义 Action: 模板只读浏览 ==================== @@ -142,18 +156,30 @@ class NucleiTemplateRepoViewSet(viewsets.ModelViewSet): try: repo_id = int(pk) if pk is not None else None except (TypeError, ValueError): - return Response({"message": "无效的仓库 ID"}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='Invalid repository ID', + status_code=status.HTTP_400_BAD_REQUEST + ) # 调用 Service 层,仅从当前本地目录读取目录树 try: roots = self.service.get_template_tree(repo_id) except ValidationError as exc: - return Response({"message": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message=str(exc), + status_code=status.HTTP_400_BAD_REQUEST + ) except Exception as exc: # noqa: BLE001 logger.error("获取 Nuclei 模板目录树失败: %s", exc, exc_info=True) - return Response({"message": "获取模板目录树失败"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + code=ErrorCodes.SERVER_ERROR, + message='Failed to get template tree', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) - return Response({"roots": roots}) + return success_response(data={'roots': roots}) @action(detail=True, methods=["get"], url_path="templates/content") def templates_content(self, request: Request, pk: str | None = None) -> Response: @@ -174,23 +200,43 @@ class NucleiTemplateRepoViewSet(viewsets.ModelViewSet): try: repo_id = int(pk) if pk is not None else None except (TypeError, ValueError): - return Response({"message": "无效的仓库 ID"}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='Invalid repository ID', + status_code=status.HTTP_400_BAD_REQUEST + ) # 解析 path 参数 rel_path = (request.query_params.get("path", "") or "").strip() if not rel_path: - return Response({"message": "缺少 path 参数"}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='Missing path parameter', + status_code=status.HTTP_400_BAD_REQUEST + ) # 调用 Service 层 try: result = self.service.get_template_content(repo_id, rel_path) except ValidationError as exc: - return Response({"message": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message=str(exc), + status_code=status.HTTP_400_BAD_REQUEST + ) except Exception as exc: # noqa: BLE001 logger.error("获取 Nuclei 模板内容失败: %s", exc, exc_info=True) - return Response({"message": "获取模板内容失败"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + code=ErrorCodes.SERVER_ERROR, + message='Failed to get template content', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) # 文件不存在 if result is None: - return Response({"message": "模板不存在或无法读取"}, status=status.HTTP_404_NOT_FOUND) - return Response(result) + return error_response( + code=ErrorCodes.NOT_FOUND, + message='Template not found or unreadable', + status_code=status.HTTP_404_NOT_FOUND + ) + return success_response(data=result) diff --git a/backend/apps/engine/views/wordlist_views.py b/backend/apps/engine/views/wordlist_views.py index 4ac47759..213239b9 100644 --- a/backend/apps/engine/views/wordlist_views.py +++ b/backend/apps/engine/views/wordlist_views.py @@ -9,6 +9,8 @@ from rest_framework.decorators import action from rest_framework.response import Response from apps.common.pagination import BasePagination +from apps.common.response_helpers import success_response, error_response +from apps.common.error_codes import ErrorCodes from apps.engine.serializers.wordlist_serializer import WordlistSerializer from apps.engine.services.wordlist_service import WordlistService @@ -46,7 +48,11 @@ class WordlistViewSet(viewsets.ViewSet): uploaded_file = request.FILES.get("file") if not uploaded_file: - return Response({"error": "缺少字典文件"}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='Missing wordlist file', + status_code=status.HTTP_400_BAD_REQUEST + ) try: wordlist = self.service.create_wordlist( @@ -55,21 +61,32 @@ class WordlistViewSet(viewsets.ViewSet): uploaded_file=uploaded_file, ) except ValidationError as exc: - return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message=str(exc), + status_code=status.HTTP_400_BAD_REQUEST + ) serializer = WordlistSerializer(wordlist) - return Response(serializer.data, status=status.HTTP_201_CREATED) + return success_response(data=serializer.data, status_code=status.HTTP_201_CREATED) def destroy(self, request, pk=None): """删除字典记录""" try: wordlist_id = int(pk) except (TypeError, ValueError): - return Response({"error": "无效的 ID"}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='Invalid ID', + status_code=status.HTTP_400_BAD_REQUEST + ) success = self.service.delete_wordlist(wordlist_id) if not success: - return Response({"error": "字典不存在"}, status=status.HTTP_404_NOT_FOUND) + return error_response( + code=ErrorCodes.NOT_FOUND, + status_code=status.HTTP_404_NOT_FOUND + ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -82,15 +99,27 @@ class WordlistViewSet(viewsets.ViewSet): """ name = (request.query_params.get("wordlist", "") or "").strip() if not name: - return Response({"error": "缺少参数 wordlist"}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='Missing parameter: wordlist', + status_code=status.HTTP_400_BAD_REQUEST + ) wordlist = self.service.get_wordlist_by_name(name) if not wordlist: - return Response({"error": "字典不存在"}, status=status.HTTP_404_NOT_FOUND) + return error_response( + code=ErrorCodes.NOT_FOUND, + message='Wordlist not found', + status_code=status.HTTP_404_NOT_FOUND + ) file_path = wordlist.file_path if not file_path or not os.path.exists(file_path): - return Response({"error": "字典文件不存在"}, status=status.HTTP_404_NOT_FOUND) + return error_response( + code=ErrorCodes.NOT_FOUND, + message='Wordlist file not found', + status_code=status.HTTP_404_NOT_FOUND + ) filename = os.path.basename(file_path) response = FileResponse(open(file_path, "rb"), as_attachment=True, filename=filename) @@ -106,22 +135,38 @@ class WordlistViewSet(viewsets.ViewSet): try: wordlist_id = int(pk) except (TypeError, ValueError): - return Response({"error": "无效的 ID"}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='Invalid ID', + status_code=status.HTTP_400_BAD_REQUEST + ) if request.method == "GET": content = self.service.get_wordlist_content(wordlist_id) if content is None: - return Response({"error": "字典不存在或文件无法读取"}, status=status.HTTP_404_NOT_FOUND) - return Response({"content": content}) + return error_response( + code=ErrorCodes.NOT_FOUND, + message='Wordlist not found or file unreadable', + status_code=status.HTTP_404_NOT_FOUND + ) + return success_response(data={"content": content}) elif request.method == "PUT": content = request.data.get("content") if content is None: - return Response({"error": "缺少 content 参数"}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='Missing content parameter', + status_code=status.HTTP_400_BAD_REQUEST + ) wordlist = self.service.update_wordlist_content(wordlist_id, content) if not wordlist: - return Response({"error": "字典不存在或更新失败"}, status=status.HTTP_404_NOT_FOUND) + return error_response( + code=ErrorCodes.NOT_FOUND, + message='Wordlist not found or update failed', + status_code=status.HTTP_404_NOT_FOUND + ) serializer = WordlistSerializer(wordlist) - return Response(serializer.data) + return success_response(data=serializer.data) diff --git a/backend/apps/engine/views/worker_views.py b/backend/apps/engine/views/worker_views.py index fedfcfc1..88900af3 100644 --- a/backend/apps/engine/views/worker_views.py +++ b/backend/apps/engine/views/worker_views.py @@ -9,6 +9,8 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response +from apps.common.response_helpers import success_response, error_response +from apps.common.error_codes import ErrorCodes from apps.engine.serializers import WorkerNodeSerializer from apps.engine.services import WorkerService from apps.common.signals import worker_delete_failed @@ -111,9 +113,8 @@ class WorkerNodeViewSet(viewsets.ModelViewSet): threading.Thread(target=_async_remote_uninstall, daemon=True).start() # 3. 立即返回成功 - return Response( - {"message": f"节点 {worker_name} 已删除"}, - status=status.HTTP_200_OK + return success_response( + data={'name': worker_name} ) @action(detail=True, methods=['post']) @@ -190,11 +191,13 @@ class WorkerNodeViewSet(viewsets.ModelViewSet): worker.status = 'online' worker.save(update_fields=['status']) - return Response({ - 'status': 'ok', - 'need_update': need_update, - 'server_version': server_version - }) + return success_response( + data={ + 'status': 'ok', + 'needUpdate': need_update, + 'serverVersion': server_version + } + ) def _trigger_remote_agent_update(self, worker, target_version: str): """ @@ -304,9 +307,10 @@ class WorkerNodeViewSet(viewsets.ModelViewSet): is_local = request.data.get('is_local', True) if not name: - return Response( - {'error': '缺少 name 参数'}, - status=status.HTTP_400_BAD_REQUEST + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='Missing name parameter', + status_code=status.HTTP_400_BAD_REQUEST ) worker, created = self.worker_service.register_worker( @@ -314,11 +318,13 @@ class WorkerNodeViewSet(viewsets.ModelViewSet): is_local=is_local ) - return Response({ - 'worker_id': worker.id, - 'name': worker.name, - 'created': created - }) + return success_response( + data={ + 'workerId': worker.id, + 'name': worker.name, + 'created': created + } + ) @action(detail=False, methods=['get']) def config(self, request): @@ -380,24 +386,26 @@ class WorkerNodeViewSet(viewsets.ModelViewSet): logger.info("返回 Worker 配置 - db_host: %s, redis_url: %s", worker_db_host, worker_redis_url) - return Response({ - 'db': { - 'host': worker_db_host, - 'port': str(settings.DATABASES['default']['PORT']), - 'name': settings.DATABASES['default']['NAME'], - 'user': settings.DATABASES['default']['USER'], - 'password': settings.DATABASES['default']['PASSWORD'], - }, - 'redisUrl': worker_redis_url, - 'paths': { - 'results': getattr(settings, 'CONTAINER_RESULTS_MOUNT', '/opt/xingrin/results'), - 'logs': getattr(settings, 'CONTAINER_LOGS_MOUNT', '/opt/xingrin/logs'), - }, - 'logging': { - 'level': os.getenv('LOG_LEVEL', 'INFO'), - 'enableCommandLogging': os.getenv('ENABLE_COMMAND_LOGGING', 'true').lower() == 'true', - }, - 'debug': settings.DEBUG, - # Git 加速配置(用于 Git clone 加速,如 Nuclei 模板仓库) - 'gitMirror': settings.GIT_MIRROR, - }) + return success_response( + data={ + 'db': { + 'host': worker_db_host, + 'port': str(settings.DATABASES['default']['PORT']), + 'name': settings.DATABASES['default']['NAME'], + 'user': settings.DATABASES['default']['USER'], + 'password': settings.DATABASES['default']['PASSWORD'], + }, + 'redisUrl': worker_redis_url, + 'paths': { + 'results': getattr(settings, 'CONTAINER_RESULTS_MOUNT', '/opt/xingrin/results'), + 'logs': getattr(settings, 'CONTAINER_LOGS_MOUNT', '/opt/xingrin/logs'), + }, + 'logging': { + 'level': os.getenv('LOG_LEVEL', 'INFO'), + 'enableCommandLogging': os.getenv('ENABLE_COMMAND_LOGGING', 'true').lower() == 'true', + }, + 'debug': settings.DEBUG, + # Git 加速配置(用于 Git clone 加速,如 Nuclei 模板仓库) + 'gitMirror': settings.GIT_MIRROR, + } + ) diff --git a/backend/apps/scan/notifications/views.py b/backend/apps/scan/notifications/views.py index 73a7109d..af0775c8 100644 --- a/backend/apps/scan/notifications/views.py +++ b/backend/apps/scan/notifications/views.py @@ -14,6 +14,8 @@ from rest_framework.response import Response from rest_framework.views import APIView from apps.common.pagination import BasePagination +from apps.common.response_helpers import success_response, error_response +from apps.common.error_codes import ErrorCodes from .models import Notification from .serializers import NotificationSerializer from .types import NotificationLevel @@ -60,34 +62,7 @@ def notifications_test(request): }, status=500) -def build_api_response( - data: Any = None, - *, - message: str = '操作成功', - code: str = '200', - state: str = 'success', - status_code: int = status.HTTP_200_OK -) -> Response: - """构建统一的 API 响应格式 - - Args: - data: 响应数据体(可选) - message: 响应消息 - code: 响应代码 - state: 响应状态(success/error) - status_code: HTTP 状态码 - - Returns: - DRF Response 对象 - """ - payload = { - 'code': code, - 'state': state, - 'message': message, - } - if data is not None: - payload['data'] = data - return Response(payload, status=status_code) +# build_api_response 已废弃,请使用 success_response/error_response def _parse_bool(value: str | None) -> bool | None: @@ -172,7 +147,7 @@ class NotificationUnreadCountView(APIView): """获取未读通知数量""" service = NotificationService() count = service.get_unread_count() - return build_api_response({'count': count}, message='获取未读数量成功') + return success_response(data={'count': count}) class NotificationMarkAllAsReadView(APIView): @@ -192,7 +167,7 @@ class NotificationMarkAllAsReadView(APIView): """标记全部通知为已读""" service = NotificationService() updated = service.mark_all_as_read() - return build_api_response({'updated': updated}, message='全部标记已读成功') + return success_response(data={'updated': updated}) class NotificationSettingsView(APIView): @@ -209,13 +184,13 @@ class NotificationSettingsView(APIView): """获取通知设置""" service = NotificationSettingsService() settings = service.get_settings() - return Response(settings) + return success_response(data=settings) def put(self, request: Request) -> Response: """更新通知设置""" service = NotificationSettingsService() settings = service.update_settings(request.data) - return Response({'message': '已保存通知设置', **settings}) + return success_response(data=settings) # ============================================ @@ -247,22 +222,24 @@ def notification_callback(request): required_fields = ['id', 'category', 'title', 'message', 'level', 'created_at'] for field in required_fields: if field not in data: - return Response( - {'error': f'缺少字段: {field}'}, - status=status.HTTP_400_BAD_REQUEST + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message=f'Missing field: {field}', + status_code=status.HTTP_400_BAD_REQUEST ) # 推送到 WebSocket _push_notification_to_websocket(data) logger.debug(f"回调通知推送成功 - ID: {data['id']}, Title: {data['title']}") - return Response({'status': 'ok'}) + return success_response(data={'status': 'ok'}) except Exception as e: logger.error(f"回调通知处理失败: {e}", exc_info=True) - return Response( - {'error': str(e)}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR + return error_response( + code=ErrorCodes.SERVER_ERROR, + message=str(e), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR ) diff --git a/backend/apps/scan/views/scan_views.py b/backend/apps/scan/views/scan_views.py index 4cf60cc1..a9b7fac8 100644 --- a/backend/apps/scan/views/scan_views.py +++ b/backend/apps/scan/views/scan_views.py @@ -7,6 +7,9 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db.utils import DatabaseError, IntegrityError, OperationalError import logging +from apps.common.response_helpers import success_response, error_response +from apps.common.error_codes import ErrorCodes + logger = logging.getLogger(__name__) from ..models import Scan, ScheduledScan @@ -75,20 +78,31 @@ class ScanViewSet(viewsets.ModelViewSet): scan_service = ScanService() result = scan_service.delete_scans_two_phase([scan.id]) - return Response({ - 'message': f'已删除扫描任务: Scan #{scan.id}', - 'scanId': scan.id, - 'deletedCount': result['soft_deleted_count'], - 'deletedScans': result['scan_names'] - }, status=status.HTTP_200_OK) + return success_response( + data={ + 'scanId': scan.id, + 'deletedCount': result['soft_deleted_count'], + 'deletedScans': result['scan_names'] + } + ) except Scan.DoesNotExist: - raise NotFound('扫描任务不存在') + return error_response( + code=ErrorCodes.NOT_FOUND, + status_code=status.HTTP_404_NOT_FOUND + ) except ValueError as e: - raise NotFound(str(e)) + return error_response( + code=ErrorCodes.NOT_FOUND, + message=str(e), + status_code=status.HTTP_404_NOT_FOUND + ) except Exception as e: logger.exception("删除扫描任务时发生错误") - raise APIException('服务器错误,请稍后重试') + return error_response( + code=ErrorCodes.SERVER_ERROR, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) @action(detail=False, methods=['post']) def quick(self, request): @@ -132,10 +146,12 @@ class ScanViewSet(viewsets.ModelViewSet): targets = result['targets'] if not targets: - return Response({ - 'error': '没有有效的目标可供扫描', - 'errors': result.get('errors', []) - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='No valid targets for scanning', + details=result.get('errors', []), + status_code=status.HTTP_400_BAD_REQUEST + ) # 2. 获取扫描引擎 engine_service = EngineService() @@ -153,21 +169,28 @@ class ScanViewSet(viewsets.ModelViewSet): # 序列化返回结果 scan_serializer = ScanSerializer(created_scans, many=True) - return Response({ - 'message': f'快速扫描已启动:{len(created_scans)} 个任务', - 'target_stats': result['target_stats'], - 'asset_stats': result['asset_stats'], - 'errors': result.get('errors', []), - 'scans': scan_serializer.data - }, status=status.HTTP_201_CREATED) + return success_response( + data={ + 'count': len(created_scans), + 'targetStats': result['target_stats'], + 'assetStats': result['asset_stats'], + 'errors': result.get('errors', []), + 'scans': scan_serializer.data + }, + status_code=status.HTTP_201_CREATED + ) except ValidationError as e: - return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message=str(e), + status_code=status.HTTP_400_BAD_REQUEST + ) except Exception as e: logger.exception("快速扫描启动失败") - return Response( - {'error': '服务器内部错误,请稍后重试'}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR + return error_response( + code=ErrorCodes.SERVER_ERROR, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR ) @action(detail=False, methods=['post']) @@ -208,35 +231,36 @@ class ScanViewSet(viewsets.ModelViewSet): # 序列化返回结果 scan_serializer = ScanSerializer(created_scans, many=True) - return Response( - { - 'message': f'已成功发起 {len(created_scans)} 个扫描任务', + return success_response( + data={ 'count': len(created_scans), 'scans': scan_serializer.data }, - status=status.HTTP_201_CREATED + status_code=status.HTTP_201_CREATED ) except ObjectDoesNotExist as e: # 资源不存在错误(由 service 层抛出) - error_msg = str(e) - return Response( - {'error': error_msg}, - status=status.HTTP_404_NOT_FOUND + return error_response( + code=ErrorCodes.NOT_FOUND, + message=str(e), + status_code=status.HTTP_404_NOT_FOUND ) except ValidationError as e: # 参数验证错误(由 service 层抛出) - return Response( - {'error': str(e)}, - status=status.HTTP_400_BAD_REQUEST + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message=str(e), + status_code=status.HTTP_400_BAD_REQUEST ) except (DatabaseError, IntegrityError, OperationalError): # 数据库错误 - return Response( - {'error': '数据库错误,请稍后重试'}, - status=status.HTTP_503_SERVICE_UNAVAILABLE + return error_response( + code=ErrorCodes.SERVER_ERROR, + message='Database error', + status_code=status.HTTP_503_SERVICE_UNAVAILABLE ) # 所有快照相关的 action 和 export 已迁移到 asset/views.py 中的快照 ViewSet @@ -278,21 +302,24 @@ class ScanViewSet(viewsets.ModelViewSet): # 参数验证 if not ids: - return Response( - {'error': '缺少必填参数: ids'}, - status=status.HTTP_400_BAD_REQUEST + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='Missing required parameter: ids', + status_code=status.HTTP_400_BAD_REQUEST ) if not isinstance(ids, list): - return Response( - {'error': 'ids 必须是数组'}, - status=status.HTTP_400_BAD_REQUEST + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='ids must be an array', + status_code=status.HTTP_400_BAD_REQUEST ) if not all(isinstance(i, int) for i in ids): - return Response( - {'error': 'ids 数组中的所有元素必须是整数'}, - status=status.HTTP_400_BAD_REQUEST + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message='All elements in ids array must be integers', + status_code=status.HTTP_400_BAD_REQUEST ) try: @@ -300,19 +327,27 @@ class ScanViewSet(viewsets.ModelViewSet): scan_service = ScanService() result = scan_service.delete_scans_two_phase(ids) - return Response({ - 'message': f"已删除 {result['soft_deleted_count']} 个扫描任务", - 'deletedCount': result['soft_deleted_count'], - 'deletedScans': result['scan_names'] - }, status=status.HTTP_200_OK) + return success_response( + data={ + 'deletedCount': result['soft_deleted_count'], + 'deletedScans': result['scan_names'] + } + ) except ValueError as e: # 未找到记录 - raise NotFound(str(e)) + return error_response( + code=ErrorCodes.NOT_FOUND, + message=str(e), + status_code=status.HTTP_404_NOT_FOUND + ) except Exception as e: logger.exception("批量删除扫描任务时发生错误") - raise APIException('服务器错误,请稍后重试') + return error_response( + code=ErrorCodes.SERVER_ERROR, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) @action(detail=False, methods=['get']) def statistics(self, request): @@ -337,22 +372,25 @@ class ScanViewSet(viewsets.ModelViewSet): scan_service = ScanService() stats = scan_service.get_statistics() - return Response({ - 'total': stats['total'], - 'running': stats['running'], - 'completed': stats['completed'], - 'failed': stats['failed'], - 'totalVulns': stats['total_vulns'], - 'totalSubdomains': stats['total_subdomains'], - 'totalEndpoints': stats['total_endpoints'], - 'totalWebsites': stats['total_websites'], - 'totalAssets': stats['total_assets'], - }) + return success_response( + data={ + 'total': stats['total'], + 'running': stats['running'], + 'completed': stats['completed'], + 'failed': stats['failed'], + 'totalVulns': stats['total_vulns'], + 'totalSubdomains': stats['total_subdomains'], + 'totalEndpoints': stats['total_endpoints'], + 'totalWebsites': stats['total_websites'], + 'totalAssets': stats['total_assets'], + } + ) except (DatabaseError, OperationalError): - return Response( - {'error': '数据库错误,请稍后重试'}, - status=status.HTTP_503_SERVICE_UNAVAILABLE + return error_response( + code=ErrorCodes.SERVER_ERROR, + message='Database error', + status_code=status.HTTP_503_SERVICE_UNAVAILABLE ) @action(detail=True, methods=['post']) @@ -383,35 +421,31 @@ class ScanViewSet(viewsets.ModelViewSet): # 检查是否是状态不允许的问题 scan = scan_service.get_scan(scan_id=pk, prefetch_relations=False) if scan and scan.status not in [ScanStatus.RUNNING, ScanStatus.INITIATED]: - return Response( - { - 'error': f'无法停止扫描:当前状态为 {ScanStatus(scan.status).label}', - 'detail': '只能停止运行中或初始化状态的扫描' - }, - status=status.HTTP_400_BAD_REQUEST + return error_response( + code=ErrorCodes.BAD_REQUEST, + message=f'Cannot stop scan: current status is {ScanStatus(scan.status).label}', + status_code=status.HTTP_400_BAD_REQUEST ) # 其他失败原因 - return Response( - {'error': '停止扫描失败'}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR + return error_response( + code=ErrorCodes.SERVER_ERROR, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR ) - return Response( - { - 'message': f'扫描已停止,已撤销 {revoked_count} 个任务', - 'revokedTaskCount': revoked_count - }, - status=status.HTTP_200_OK + return success_response( + data={'revokedTaskCount': revoked_count} ) except ObjectDoesNotExist: - return Response( - {'error': f'扫描 ID {pk} 不存在'}, - status=status.HTTP_404_NOT_FOUND + return error_response( + code=ErrorCodes.NOT_FOUND, + message=f'Scan ID {pk} not found', + status_code=status.HTTP_404_NOT_FOUND ) except (DatabaseError, IntegrityError, OperationalError): - return Response( - {'error': '数据库错误,请稍后重试'}, - status=status.HTTP_503_SERVICE_UNAVAILABLE + return error_response( + code=ErrorCodes.SERVER_ERROR, + message='Database error', + status_code=status.HTTP_503_SERVICE_UNAVAILABLE ) diff --git a/backend/apps/scan/views/scheduled_scan_views.py b/backend/apps/scan/views/scheduled_scan_views.py index 900ab8eb..664229ad 100644 --- a/backend/apps/scan/views/scheduled_scan_views.py +++ b/backend/apps/scan/views/scheduled_scan_views.py @@ -18,6 +18,8 @@ from ..serializers import ( from ..services.scheduled_scan_service import ScheduledScanService from ..repositories import ScheduledScanDTO from apps.common.pagination import BasePagination +from apps.common.response_helpers import success_response, error_response +from apps.common.error_codes import ErrorCodes logger = logging.getLogger(__name__) @@ -75,15 +77,16 @@ class ScheduledScanViewSet(viewsets.ModelViewSet): scheduled_scan = self.service.create(dto) response_serializer = ScheduledScanSerializer(scheduled_scan) - return Response( - { - 'message': f'创建定时扫描任务成功: {scheduled_scan.name}', - 'scheduled_scan': response_serializer.data - }, - status=status.HTTP_201_CREATED + return success_response( + data=response_serializer.data, + status_code=status.HTTP_201_CREATED ) except ValidationError as e: - return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message=str(e), + status_code=status.HTTP_400_BAD_REQUEST + ) def update(self, request, *args, **kwargs): """更新定时扫描任务""" @@ -105,24 +108,27 @@ class ScheduledScanViewSet(viewsets.ModelViewSet): scheduled_scan = self.service.update(instance.id, dto) response_serializer = ScheduledScanSerializer(scheduled_scan) - return Response({ - 'message': f'更新定时扫描任务成功: {scheduled_scan.name}', - 'scheduled_scan': response_serializer.data - }) + return success_response(data=response_serializer.data) except ValidationError as e: - return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + code=ErrorCodes.VALIDATION_ERROR, + message=str(e), + status_code=status.HTTP_400_BAD_REQUEST + ) def destroy(self, request, *args, **kwargs): """删除定时扫描任务""" instance = self.get_object() + scan_id = instance.id name = instance.name - if self.service.delete(instance.id): - return Response({ - 'message': f'删除定时扫描任务成功: {name}', - 'id': instance.id - }) - return Response({'error': '删除失败'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + if self.service.delete(scan_id): + return success_response(data={'id': scan_id, 'name': name}) + return error_response( + code=ErrorCodes.SERVER_ERROR, + message='Failed to delete scheduled scan', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) @action(detail=True, methods=['post']) def toggle(self, request, pk=None): @@ -136,14 +142,11 @@ class ScheduledScanViewSet(viewsets.ModelViewSet): scheduled_scan = self.get_object() response_serializer = ScheduledScanSerializer(scheduled_scan) - status_text = '启用' if is_enabled else '禁用' - return Response({ - 'message': f'已{status_text}定时扫描任务', - 'scheduled_scan': response_serializer.data - }) + return success_response(data=response_serializer.data) - return Response( - {'error': f'定时扫描任务 ID {pk} 不存在或操作失败'}, - status=status.HTTP_404_NOT_FOUND + return error_response( + code=ErrorCodes.NOT_FOUND, + message=f'Scheduled scan with ID {pk} not found or operation failed', + status_code=status.HTTP_404_NOT_FOUND ) diff --git a/backend/apps/targets/views.py b/backend/apps/targets/views.py index 8ac174d7..1f47b502 100644 --- a/backend/apps/targets/views.py +++ b/backend/apps/targets/views.py @@ -95,8 +95,9 @@ class OrganizationViewSet(viewsets.ModelViewSet): organization.targets.remove(*existing_target_ids) return Response({ - 'unlinked_count': existing_count, - 'message': f'成功解除 {existing_count} 个目标的关联' + 'data': { + 'unlinkedCount': existing_count + } }) def destroy(self, request, *args, **kwargs): @@ -125,11 +126,12 @@ class OrganizationViewSet(viewsets.ModelViewSet): result = self.org_service.delete_organizations_two_phase([organization.id]) return Response({ - 'message': f'已删除组织: {organization.name}', - 'organizationId': organization.id, - 'organizationName': organization.name, - 'deletedCount': result['soft_deleted_count'], - 'deletedOrganizations': result['organization_names'] + 'data': { + 'organizationId': organization.id, + 'organizationName': organization.name, + 'deletedCount': result['soft_deleted_count'], + 'deletedOrganizations': result['organization_names'] + } }, status=200) except Organization.DoesNotExist: @@ -182,9 +184,10 @@ class OrganizationViewSet(viewsets.ModelViewSet): result = self.org_service.delete_organizations_two_phase(ids) return Response({ - 'message': f"已删除 {result['soft_deleted_count']} 个组织", - 'deletedCount': result['soft_deleted_count'], - 'deletedOrganizations': result['organization_names'] + 'data': { + 'deletedCount': result['soft_deleted_count'], + 'deletedOrganizations': result['organization_names'] + } }, status=200) except ValueError as e: @@ -272,10 +275,11 @@ class TargetViewSet(viewsets.ModelViewSet): result = self.target_service.delete_targets_two_phase([target.id]) return Response({ - 'message': f'已删除目标: {target.name}', - 'targetId': target.id, - 'targetName': target.name, - 'deletedCount': result['soft_deleted_count'] + 'data': { + 'targetId': target.id, + 'targetName': target.name, + 'deletedCount': result['soft_deleted_count'] + } }, status=200) except Target.DoesNotExist: @@ -331,9 +335,10 @@ class TargetViewSet(viewsets.ModelViewSet): result = self.target_service.delete_targets_two_phase(ids) return Response({ - 'message': f"已删除 {result['soft_deleted_count']} 个目标", - 'deletedCount': result['soft_deleted_count'], - 'deletedTargets': result['target_names'] + 'data': { + 'deletedCount': result['soft_deleted_count'], + 'deletedTargets': result['target_names'] + } }, status=200) except ValueError as e: diff --git a/frontend/hooks/use-auth.ts b/frontend/hooks/use-auth.ts index ff237e1a..db66c1c1 100644 --- a/frontend/hooks/use-auth.ts +++ b/frontend/hooks/use-auth.ts @@ -3,9 +3,9 @@ */ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' -import { toast } from 'sonner' import { login, logout, getMe, changePassword } from '@/services/auth.service' -import { getErrorMessage } from '@/lib/api-client' +import { useToastMessages } from '@/lib/toast-helpers' +import { getErrorCode } from '@/lib/response-parser' import type { LoginRequest, ChangePasswordRequest } from '@/types/auth.types' /** @@ -30,16 +30,22 @@ export function useAuth() { export function useLogin() { const queryClient = useQueryClient() const router = useRouter() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: LoginRequest) => login(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) - toast.success('Login successful') + toastMessages.success('toast.auth.login.success') router.push('/dashboard/') }, - onError: (error: unknown) => { - toast.error(getErrorMessage(error)) + onError: (error: any) => { + const errorCode = getErrorCode(error.response?.data) + if (errorCode) { + toastMessages.errorFromCode(errorCode) + } else { + toastMessages.error('auth.loginFailed') + } }, }) } @@ -50,16 +56,22 @@ export function useLogin() { export function useLogout() { const queryClient = useQueryClient() const router = useRouter() + const toastMessages = useToastMessages() return useMutation({ mutationFn: logout, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) - toast.success('Logged out') + toastMessages.success('toast.auth.logout.success') router.push('/login/') }, - onError: (error: unknown) => { - toast.error(getErrorMessage(error)) + onError: (error: any) => { + const errorCode = getErrorCode(error.response?.data) + if (errorCode) { + toastMessages.errorFromCode(errorCode) + } else { + toastMessages.error('errors.unknown') + } }, }) } @@ -68,13 +80,20 @@ export function useLogout() { * Change password */ export function useChangePassword() { + const toastMessages = useToastMessages() + return useMutation({ mutationFn: (data: ChangePasswordRequest) => changePassword(data), onSuccess: () => { - toast.success('Password changed successfully') + toastMessages.success('toast.auth.changePassword.success') }, - onError: (error: unknown) => { - toast.error(getErrorMessage(error)) + onError: (error: any) => { + const errorCode = getErrorCode(error.response?.data) + if (errorCode) { + toastMessages.errorFromCode(errorCode) + } else { + toastMessages.error('toast.auth.changePassword.error') + } }, }) } diff --git a/frontend/hooks/use-commands.ts b/frontend/hooks/use-commands.ts index 1817c948..4b62f1d9 100644 --- a/frontend/hooks/use-commands.ts +++ b/frontend/hooks/use-commands.ts @@ -7,7 +7,8 @@ import type { GetCommandsResponse, Command, } from "@/types/command.types" -import { toast } from "sonner" +import { useToastMessages } from '@/lib/toast-helpers' +import { getErrorCode } from '@/lib/response-parser' // Mock data const MOCK_COMMANDS: Command[] = [ @@ -273,16 +274,17 @@ export function useCommand(id: number) { */ export function useCreateCommand() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: CreateCommandRequest) => CommandService.createCommand(data), onSuccess: () => { - toast.success("Command created successfully") + toastMessages.success('toast.command.create.success') queryClient.invalidateQueries({ queryKey: ["commands"] }) }, onError: (error: any) => { console.error("Failed to create command:", error) - toast.error("Failed to create command") + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.command.create.error') }, }) } @@ -292,18 +294,19 @@ export function useCreateCommand() { */ export function useUpdateCommand() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: ({ id, data }: { id: number; data: UpdateCommandRequest }) => CommandService.updateCommand(id, data), onSuccess: () => { - toast.success("Command updated successfully") + toastMessages.success('toast.command.update.success') queryClient.invalidateQueries({ queryKey: ["commands"] }) queryClient.invalidateQueries({ queryKey: ["command"] }) }, onError: (error: any) => { console.error("Failed to update command:", error) - toast.error("Failed to update command") + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.command.update.error') }, }) } @@ -313,16 +316,17 @@ export function useUpdateCommand() { */ export function useDeleteCommand() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (id: number) => CommandService.deleteCommand(id), onSuccess: () => { - toast.success("Command deleted successfully") + toastMessages.success('toast.command.delete.success') queryClient.invalidateQueries({ queryKey: ["commands"] }) }, onError: (error: any) => { console.error("Failed to delete command:", error) - toast.error("Failed to delete command") + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.command.delete.error') }, }) } @@ -332,6 +336,7 @@ export function useDeleteCommand() { */ export function useBatchDeleteCommands() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: async (ids: number[]) => { @@ -351,12 +356,12 @@ export function useBatchDeleteCommands() { } }, onSuccess: (response) => { - toast.success(`Successfully deleted ${response.data?.deletedCount} commands`) + toastMessages.success('toast.command.delete.bulkSuccess', { count: response.data?.deletedCount || 0 }) queryClient.invalidateQueries({ queryKey: ["commands"] }) }, onError: (error: any) => { console.error("Failed to batch delete commands:", error) - toast.error("Failed to batch delete commands") + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.command.delete.error') }, }) } diff --git a/frontend/hooks/use-directories.ts b/frontend/hooks/use-directories.ts index 74b57882..7bd7c757 100644 --- a/frontend/hooks/use-directories.ts +++ b/frontend/hooks/use-directories.ts @@ -1,5 +1,6 @@ import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query' -import { toast } from 'sonner' +import { useToastMessages } from '@/lib/toast-helpers' +import { getErrorCode } from '@/lib/response-parser' import { DirectoryService } from '@/services/directory.service' import type { Directory, DirectoryListResponse } from '@/types/directory.types' @@ -108,28 +109,25 @@ export function useScanDirectories( // 删除单个目录(使用单独的 DELETE API) export function useDeleteDirectory() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: directoryService.deleteDirectory, onMutate: (id) => { - toast.loading('正在删除目录...', { id: `delete-directory-${id}` }) + toastMessages.loading('common.status.deleting', {}, `delete-directory-${id}`) }, onSuccess: (response, id) => { - toast.dismiss(`delete-directory-${id}`) + toastMessages.dismiss(`delete-directory-${id}`) + toastMessages.success('toast.asset.directory.delete.success') - // 显示删除成功信息 - const { directoryUrl } = response - toast.success(`目录 "${directoryUrl}" 已成功删除`) - - // 刷新相关查询 queryClient.invalidateQueries({ queryKey: ['target-directories'] }) queryClient.invalidateQueries({ queryKey: ['scan-directories'] }) queryClient.invalidateQueries({ queryKey: ['targets'] }) queryClient.invalidateQueries({ queryKey: ['scans'] }) }, - onError: (error: Error, id) => { - toast.dismiss(`delete-directory-${id}`) - toast.error(error.message || '删除目录失败') + onError: (error: any, id) => { + toastMessages.dismiss(`delete-directory-${id}`) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.asset.directory.delete.error') }, }) } @@ -137,25 +135,25 @@ export function useDeleteDirectory() { // 批量删除目录(使用统一的批量删除接口) export function useBulkDeleteDirectories() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: directoryService.bulkDeleteDirectories, onMutate: () => { - toast.loading('正在批量删除目录...', { id: 'bulk-delete-directories' }) + toastMessages.loading('common.status.batchDeleting', {}, 'bulk-delete-directories') }, onSuccess: (response) => { - toast.dismiss('bulk-delete-directories') - toast.success(`成功删除 ${response.deletedCount} 个目录`) + toastMessages.dismiss('bulk-delete-directories') + toastMessages.success('toast.asset.directory.delete.bulkSuccess', { count: response.deletedCount }) - // 刷新相关查询 queryClient.invalidateQueries({ queryKey: ['target-directories'] }) queryClient.invalidateQueries({ queryKey: ['scan-directories'] }) queryClient.invalidateQueries({ queryKey: ['targets'] }) queryClient.invalidateQueries({ queryKey: ['scans'] }) }, - onError: (error: Error) => { - toast.dismiss('bulk-delete-directories') - toast.error(error.message || '批量删除目录失败') + onError: (error: any) => { + toastMessages.dismiss('bulk-delete-directories') + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.asset.directory.delete.error') }, }) } @@ -164,32 +162,31 @@ export function useBulkDeleteDirectories() { // 批量创建目录(绑定到目标) export function useBulkCreateDirectories() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: { targetId: number; urls: string[] }) => DirectoryService.bulkCreateDirectories(data.targetId, data.urls), onMutate: async () => { - toast.loading('正在批量创建目录...', { id: 'bulk-create-directories' }) + toastMessages.loading('common.status.batchCreating', {}, 'bulk-create-directories') }, onSuccess: (response, { targetId }) => { - toast.dismiss('bulk-create-directories') + toastMessages.dismiss('bulk-create-directories') const { createdCount } = response if (createdCount > 0) { - toast.success(`成功创建 ${createdCount} 个目录`) + toastMessages.success('toast.asset.directory.create.success', { count: createdCount }) } else { - toast.warning('没有新目录被创建(可能已存在)') + toastMessages.warning('toast.asset.directory.create.partialSuccess', { success: 0, skipped: 0 }) } - // 刷新目录列表 queryClient.invalidateQueries({ queryKey: ['target-directories', targetId] }) queryClient.invalidateQueries({ queryKey: ['scan-directories'] }) }, onError: (error: any) => { - toast.dismiss('bulk-create-directories') - console.error('批量创建目录失败:', error) - const errorMessage = error?.response?.data?.error || '批量创建失败,请查看控制台日志' - toast.error(errorMessage) + toastMessages.dismiss('bulk-create-directories') + console.error('Failed to bulk create directories:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.asset.directory.create.error') }, }) } diff --git a/frontend/hooks/use-endpoints.ts b/frontend/hooks/use-endpoints.ts index 1793e657..3e8f6fb4 100644 --- a/frontend/hooks/use-endpoints.ts +++ b/frontend/hooks/use-endpoints.ts @@ -1,7 +1,8 @@ "use client" import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query" -import { toast } from "sonner" +import { useToastMessages } from '@/lib/toast-helpers' +import { getErrorCode } from '@/lib/response-parser' import { EndpointService } from "@/services/endpoint.service" import type { Endpoint, @@ -111,45 +112,35 @@ export function useScanEndpoints(scanId: number, params?: Omit }) => EndpointService.createEndpoints(data), onMutate: async () => { - toast.loading('正在创建端点...', { id: 'create-endpoint' }) + toastMessages.loading('common.status.creating', {}, 'create-endpoint') }, onSuccess: (response) => { - // 关闭加载提示 - toast.dismiss('create-endpoint') + toastMessages.dismiss('create-endpoint') const { createdCount, existedCount } = response - // 打印后端响应 - console.log('创建端点成功') - console.log('后端响应:', response) - - // 前端自己构造成功提示消息 if (existedCount > 0) { - toast.warning( - `成功创建 ${createdCount} 个端点(${existedCount} 个已存在)` - ) + toastMessages.warning('toast.asset.endpoint.create.partialSuccess', { + success: createdCount, + skipped: existedCount + }) } else { - toast.success(`成功创建 ${createdCount} 个端点`) + toastMessages.success('toast.asset.endpoint.create.success', { count: createdCount }) } - // 刷新所有端点相关查询(通配符匹配) queryClient.invalidateQueries({ queryKey: ['endpoints'] }) }, onError: (error: any) => { - // 关闭加载提示 - toast.dismiss('create-endpoint') - - console.error('创建端点失败:', error) - console.error('后端响应:', error?.response?.data || error) - - // 前端自己构造错误提示 - toast.error('创建端点失败,请查看控制台日志') + toastMessages.dismiss('create-endpoint') + console.error('Failed to create endpoint:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.asset.endpoint.create.error') }, }) } @@ -157,31 +148,22 @@ export function useCreateEndpoint() { // 删除单个 Endpoint export function useDeleteEndpoint() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (id: number) => EndpointService.deleteEndpoint(id), onMutate: (id) => { - toast.loading('正在删除端点...', { id: `delete-endpoint-${id}` }) + toastMessages.loading('common.status.deleting', {}, `delete-endpoint-${id}`) }, onSuccess: (response, id) => { - toast.dismiss(`delete-endpoint-${id}`) - - // 打印后端响应 - console.log('删除端点成功') - - toast.success('删除成功') - - // 刷新所有端点相关查询(通配符匹配) + toastMessages.dismiss(`delete-endpoint-${id}`) + toastMessages.success('toast.asset.endpoint.delete.success') queryClient.invalidateQueries({ queryKey: ['endpoints'] }) }, onError: (error: any, id) => { - toast.dismiss(`delete-endpoint-${id}`) - - console.error('删除端点失败:', error) - console.error('后端响应:', error?.response?.data || error) - - // 前端自己构造错误提示 - toast.error('删除端点失败,请查看控制台日志') + toastMessages.dismiss(`delete-endpoint-${id}`) + console.error('Failed to delete endpoint:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.asset.endpoint.delete.error') }, }) } @@ -189,33 +171,23 @@ export function useDeleteEndpoint() { // 批量删除 Endpoint export function useBatchDeleteEndpoints() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: BatchDeleteEndpointsRequest) => EndpointService.batchDeleteEndpoints(data), onMutate: () => { - toast.loading('正在批量删除端点...', { id: 'batch-delete-endpoints' }) + toastMessages.loading('common.status.batchDeleting', {}, 'batch-delete-endpoints') }, onSuccess: (response) => { - toast.dismiss('batch-delete-endpoints') - - // 打印后端响应 - console.log('批量删除端点成功') - console.log('后端响应:', response) - + toastMessages.dismiss('batch-delete-endpoints') const { deletedCount } = response - toast.success(`成功删除 ${deletedCount} 个端点`) - - // 刷新所有端点相关查询(通配符匹配) + toastMessages.success('toast.asset.endpoint.delete.bulkSuccess', { count: deletedCount }) queryClient.invalidateQueries({ queryKey: ['endpoints'] }) }, onError: (error: any) => { - toast.dismiss('batch-delete-endpoints') - - console.error('批量删除端点失败:', error) - console.error('后端响应:', error?.response?.data || error) - - // 前端自己构造错误提示 - toast.error('批量删除失败,请查看控制台日志') + toastMessages.dismiss('batch-delete-endpoints') + console.error('Failed to batch delete endpoints:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.asset.endpoint.delete.error') }, }) } @@ -223,32 +195,31 @@ export function useBatchDeleteEndpoints() { // 批量创建端点(绑定到目标) export function useBulkCreateEndpoints() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: { targetId: number; urls: string[] }) => EndpointService.bulkCreateEndpoints(data.targetId, data.urls), onMutate: async () => { - toast.loading('正在批量创建端点...', { id: 'bulk-create-endpoints' }) + toastMessages.loading('common.status.batchCreating', {}, 'bulk-create-endpoints') }, onSuccess: (response, { targetId }) => { - toast.dismiss('bulk-create-endpoints') + toastMessages.dismiss('bulk-create-endpoints') const { createdCount } = response if (createdCount > 0) { - toast.success(`成功创建 ${createdCount} 个端点`) + toastMessages.success('toast.asset.endpoint.create.success', { count: createdCount }) } else { - toast.warning('没有新端点被创建(可能已存在)') + toastMessages.warning('toast.asset.endpoint.create.partialSuccess', { success: 0, skipped: 0 }) } - // 刷新端点列表 queryClient.invalidateQueries({ queryKey: endpointKeys.byTarget(targetId, {}) }) queryClient.invalidateQueries({ queryKey: ['endpoints'] }) }, onError: (error: any) => { - toast.dismiss('bulk-create-endpoints') - console.error('批量创建端点失败:', error) - const errorMessage = error?.response?.data?.error || '批量创建失败,请查看控制台日志' - toast.error(errorMessage) + toastMessages.dismiss('bulk-create-endpoints') + console.error('Failed to bulk create endpoints:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.asset.endpoint.create.error') }, }) } diff --git a/frontend/hooks/use-engines.ts b/frontend/hooks/use-engines.ts index 46a8c015..2221e117 100644 --- a/frontend/hooks/use-engines.ts +++ b/frontend/hooks/use-engines.ts @@ -1,5 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' +import { useToastMessages } from '@/lib/toast-helpers' +import { getErrorCode } from '@/lib/response-parser' import { getEngines, getEngine, @@ -35,17 +36,16 @@ export function useEngine(id: number) { */ export function useCreateEngine() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: createEngine, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['engines'] }) - toast.success('Engine created successfully') + toastMessages.success('toast.engine.create.success') }, onError: (error: any) => { - toast.error('Failed to create engine', { - description: error?.response?.data?.error || error.message, - }) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.engine.create.error') }, }) } @@ -55,6 +55,7 @@ export function useCreateEngine() { */ export function useUpdateEngine() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: ({ id, data }: { id: number; data: Parameters[1] }) => @@ -62,12 +63,10 @@ export function useUpdateEngine() { onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: ['engines'] }) queryClient.invalidateQueries({ queryKey: ['engines', variables.id] }) - toast.success('Engine updated successfully') + toastMessages.success('toast.engine.update.success') }, onError: (error: any) => { - toast.error('Failed to update engine', { - description: error?.response?.data?.error || error.message, - }) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.engine.update.error') }, }) } @@ -77,17 +76,16 @@ export function useUpdateEngine() { */ export function useDeleteEngine() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: deleteEngine, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['engines'] }) - toast.success('Engine deleted successfully') + toastMessages.success('toast.engine.delete.success') }, onError: (error: any) => { - toast.error('Failed to delete engine', { - description: error?.response?.data?.error || error.message, - }) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.engine.delete.error') }, }) } diff --git a/frontend/hooks/use-notification-settings.ts b/frontend/hooks/use-notification-settings.ts index 6195a843..9728ffb4 100644 --- a/frontend/hooks/use-notification-settings.ts +++ b/frontend/hooks/use-notification-settings.ts @@ -1,7 +1,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { NotificationSettingsService } from '@/services/notification-settings.service' import type { UpdateNotificationSettingsRequest } from '@/types/notification-settings.types' -import { toast } from 'sonner' +import { useToastMessages } from '@/lib/toast-helpers' +import { getErrorCode } from '@/lib/response-parser' export function useNotificationSettings() { return useQuery({ @@ -12,15 +13,17 @@ export function useNotificationSettings() { export function useUpdateNotificationSettings() { const qc = useQueryClient() + const toastMessages = useToastMessages() + return useMutation({ mutationFn: (data: UpdateNotificationSettingsRequest) => NotificationSettingsService.updateSettings(data), - onSuccess: (res) => { + onSuccess: () => { qc.invalidateQueries({ queryKey: ['notification-settings'] }) - toast.success(res?.message || 'Notification settings saved') + toastMessages.success('toast.notification.settings.success') }, - onError: () => { - toast.error('Save failed, please try again') + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.notification.settings.error') }, }) } diff --git a/frontend/hooks/use-notification-sse.ts b/frontend/hooks/use-notification-sse.ts index 5748cdc5..bbc39603 100644 --- a/frontend/hooks/use-notification-sse.ts +++ b/frontend/hooks/use-notification-sse.ts @@ -4,9 +4,9 @@ import { useCallback, useEffect, useState, useRef } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' import type { BackendNotification, Notification, BackendNotificationLevel, NotificationSeverity } from '@/types/notification.types' import { getBackendBaseUrl } from '@/lib/env' +import { useToastMessages } from '@/lib/toast-helpers' const severityMap: Record = { critical: 'critical', @@ -70,6 +70,7 @@ export function useNotificationSSE() { const reconnectAttempts = useRef(0) const maxReconnectAttempts = 10 const baseReconnectDelay = 1000 // 1秒 + const toastMessages = useToastMessages() const markNotificationsAsRead = useCallback((ids?: number[]) => { setNotifications(prev => prev.map(notification => { @@ -176,7 +177,7 @@ export function useNotificationSSE() { if (data.type === 'error') { console.error('[ERROR] WebSocket 错误:', data.message) - toast.error(`通知连接错误: ${data.message}`) + toastMessages.error('toast.notification.connection.error', { message: data.message }) return } @@ -258,7 +259,7 @@ export function useNotificationSSE() { setIsConnected(false) isConnectingRef.current = false } - }, [queryClient, startHeartbeat, stopHeartbeat, getReconnectDelay]) + }, [queryClient, startHeartbeat, stopHeartbeat, getReconnectDelay, toastMessages]) // 断开连接 const disconnect = useCallback(() => { diff --git a/frontend/hooks/use-nuclei-git-settings.ts b/frontend/hooks/use-nuclei-git-settings.ts index d38ba083..86e51160 100644 --- a/frontend/hooks/use-nuclei-git-settings.ts +++ b/frontend/hooks/use-nuclei-git-settings.ts @@ -1,7 +1,8 @@ "use client" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { toast } from "sonner" +import { useToastMessages } from '@/lib/toast-helpers' +import { getErrorCode } from '@/lib/response-parser' import { NucleiGitService } from "@/services/nuclei-git.service" import type { UpdateNucleiGitSettingsRequest } from "@/types/nuclei-git.types" @@ -14,15 +15,16 @@ export function useNucleiGitSettings() { export function useUpdateNucleiGitSettings() { const qc = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: UpdateNucleiGitSettingsRequest) => NucleiGitService.updateSettings(data), - onSuccess: (res) => { + onSuccess: () => { qc.invalidateQueries({ queryKey: ["nuclei", "git", "settings"] }) - toast.success(res?.message || "Git repository configuration saved") + toastMessages.success('toast.nucleiGit.settings.success') }, - onError: () => { - toast.error("Failed to save Git repository configuration, please try again") + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.nucleiGit.settings.error') }, }) } diff --git a/frontend/hooks/use-nuclei-repos.ts b/frontend/hooks/use-nuclei-repos.ts index 97663472..128587f7 100644 --- a/frontend/hooks/use-nuclei-repos.ts +++ b/frontend/hooks/use-nuclei-repos.ts @@ -3,9 +3,9 @@ */ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { toast } from "sonner" +import { useToastMessages } from '@/lib/toast-helpers' +import { getErrorCode } from '@/lib/response-parser' import { nucleiRepoApi } from "../services/nuclei-repo.api" -import { getErrorMessage } from "@/lib/api-client" import type { NucleiTemplateTreeNode, NucleiTemplateContent } from "@/types/nuclei.types" // ==================== 仓库 CRUD ==================== @@ -41,15 +41,16 @@ export function useNucleiRepo(repoId: number | null) { /** 创建仓库 */ export function useCreateNucleiRepo() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: nucleiRepoApi.createRepo, - onSuccess: (data) => { - toast.success(`仓库「${data.name}」创建成功`) + onSuccess: () => { + toastMessages.success('toast.nucleiRepo.create.success') queryClient.invalidateQueries({ queryKey: ["nuclei-repos"] }) }, - onError: (error) => { - toast.error(getErrorMessage(error)) + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.nucleiRepo.create.error') }, }) } @@ -57,6 +58,7 @@ export function useCreateNucleiRepo() { /** 更新仓库 */ export function useUpdateNucleiRepo() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: { @@ -64,12 +66,12 @@ export function useUpdateNucleiRepo() { repoUrl?: string }) => nucleiRepoApi.updateRepo(data.id, { repoUrl: data.repoUrl }), onSuccess: (_data, variables) => { - toast.success("仓库配置已更新") + toastMessages.success('toast.nucleiRepo.update.success') queryClient.invalidateQueries({ queryKey: ["nuclei-repos"] }) queryClient.invalidateQueries({ queryKey: ["nuclei-repos", variables.id] }) }, - onError: (error) => { - toast.error(getErrorMessage(error)) + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.nucleiRepo.update.error') }, }) } @@ -77,15 +79,16 @@ export function useUpdateNucleiRepo() { /** 删除仓库 */ export function useDeleteNucleiRepo() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: nucleiRepoApi.deleteRepo, onSuccess: () => { - toast.success("仓库已删除") + toastMessages.success('toast.nucleiRepo.delete.success') queryClient.invalidateQueries({ queryKey: ["nuclei-repos"] }) }, - onError: (error) => { - toast.error(getErrorMessage(error)) + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.nucleiRepo.delete.error') }, }) } @@ -95,18 +98,18 @@ export function useDeleteNucleiRepo() { /** 刷新仓库(Git clone/pull) */ export function useRefreshNucleiRepo() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: nucleiRepoApi.refreshRepo, onSuccess: (_data, repoId) => { - toast.success("仓库同步成功") - // 刷新仓库列表(last_synced_at 会更新) + toastMessages.success('toast.nucleiRepo.sync.success') queryClient.invalidateQueries({ queryKey: ["nuclei-repos"] }) queryClient.invalidateQueries({ queryKey: ["nuclei-repos", repoId] }) queryClient.invalidateQueries({ queryKey: ["nuclei-repo-tree", repoId] }) }, - onError: (error) => { - toast.error(getErrorMessage(error)) + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.nucleiRepo.sync.error') }, }) } diff --git a/frontend/hooks/use-nuclei-templates.ts b/frontend/hooks/use-nuclei-templates.ts index 66d2644d..c0a4014a 100644 --- a/frontend/hooks/use-nuclei-templates.ts +++ b/frontend/hooks/use-nuclei-templates.ts @@ -1,7 +1,8 @@ "use client" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { toast } from "sonner" +import { useToastMessages } from '@/lib/toast-helpers' +import { getErrorCode } from '@/lib/response-parser' import { getNucleiTemplateTree, getNucleiTemplateContent, refreshNucleiTemplates, saveNucleiTemplate, uploadNucleiTemplate } from "@/services/nuclei.service" import type { NucleiTemplateTreeNode, NucleiTemplateContent, UploadNucleiTemplatePayload, SaveNucleiTemplatePayload } from "@/types/nuclei.types" @@ -22,60 +23,63 @@ export function useNucleiTemplateContent(path: string | null) { export function useRefreshNucleiTemplates() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: () => refreshNucleiTemplates(), onMutate: () => { - toast.loading("正在更新 Nuclei 官方模板...", { id: "refresh-nuclei-templates" }) + toastMessages.loading('toast.nucleiTemplate.refresh.loading', {}, 'refresh-nuclei-templates') }, onSuccess: () => { - toast.dismiss("refresh-nuclei-templates") - toast.success("模板更新完成") + toastMessages.dismiss('refresh-nuclei-templates') + toastMessages.success('toast.nucleiTemplate.refresh.success') queryClient.invalidateQueries({ queryKey: ["nuclei", "templates", "tree"] }) }, - onError: () => { - toast.dismiss("refresh-nuclei-templates") - toast.error("模板更新失败") + onError: (error: any) => { + toastMessages.dismiss('refresh-nuclei-templates') + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.nucleiTemplate.refresh.error') }, }) } export function useUploadNucleiTemplate() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (payload) => uploadNucleiTemplate(payload), onMutate: () => { - toast.loading("正在上传模板...", { id: "upload-nuclei-template" }) + toastMessages.loading('common.status.uploading', {}, 'upload-nuclei-template') }, onSuccess: () => { - toast.dismiss("upload-nuclei-template") - toast.success("模板上传成功") + toastMessages.dismiss('upload-nuclei-template') + toastMessages.success('toast.nucleiTemplate.upload.success') queryClient.invalidateQueries({ queryKey: ["nuclei", "templates", "tree"] }) }, - onError: (error) => { - toast.dismiss("upload-nuclei-template") - toast.error(error.message || "模板上传失败") + onError: (error: any) => { + toastMessages.dismiss('upload-nuclei-template') + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.nucleiTemplate.upload.error') }, }) } export function useSaveNucleiTemplate() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (payload) => saveNucleiTemplate(payload), onMutate: () => { - toast.loading("正在保存模板...", { id: "save-nuclei-template" }) + toastMessages.loading('common.actions.saving', {}, 'save-nuclei-template') }, onSuccess: (_data, variables) => { - toast.dismiss("save-nuclei-template") - toast.success("模板保存成功") + toastMessages.dismiss('save-nuclei-template') + toastMessages.success('toast.nucleiTemplate.save.success') queryClient.invalidateQueries({ queryKey: ["nuclei", "templates", "content", variables.path] }) }, - onError: (error) => { - toast.dismiss("save-nuclei-template") - toast.error(error.message || "模板保存失败") + onError: (error: any) => { + toastMessages.dismiss('save-nuclei-template') + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.nucleiTemplate.save.error') }, }) } diff --git a/frontend/hooks/use-organizations.ts b/frontend/hooks/use-organizations.ts index a2c37c08..b364888f 100644 --- a/frontend/hooks/use-organizations.ts +++ b/frontend/hooks/use-organizations.ts @@ -1,5 +1,6 @@ import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query' -import { toast } from 'sonner' +import { useToastMessages } from '@/lib/toast-helpers' +import { getErrorCode } from '@/lib/response-parser' import { OrganizationService } from '@/services/organization.service' import type { Organization, CreateOrganizationRequest, UpdateOrganizationRequest } from '@/types/organization.types' @@ -107,33 +108,23 @@ export function useOrganizationTargets( */ export function useCreateOrganization() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: CreateOrganizationRequest) => OrganizationService.createOrganization(data), onMutate: () => { - // Show creation start notification - toast.loading('Creating organization...', { id: 'create-organization' }) + toastMessages.loading('common.status.creating', {}, 'create-organization') }, onSuccess: () => { - // Close loading notification - toast.dismiss('create-organization') - - // Refresh all organization-related queries (wildcard matching) + toastMessages.dismiss('create-organization') queryClient.invalidateQueries({ queryKey: ['organizations'] }) - - // Show success notification - toast.success('Created successfully') + toastMessages.success('toast.organization.create.success') }, onError: (error: any) => { - // Close loading notification - toast.dismiss('create-organization') - + toastMessages.dismiss('create-organization') console.error('Failed to create organization:', error) - console.error('Backend response:', error?.response?.data || error) - - // Frontend constructs error message - toast.error('Failed to create organization, please check console logs') + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.organization.create.error') }, }) } @@ -143,33 +134,23 @@ export function useCreateOrganization() { */ export function useUpdateOrganization() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: ({ id, data }: { id: number; data: UpdateOrganizationRequest }) => OrganizationService.updateOrganization({ id, ...data }), - onMutate: ({ id, data }) => { - // 显示更新开始的提示 - toast.loading('正在更新组织...', { id: `update-${id}` }) + onMutate: ({ id }) => { + toastMessages.loading('common.status.updating', {}, `update-${id}`) }, onSuccess: ({ id }) => { - // 关闭加载提示 - toast.dismiss(`update-${id}`) - - // 刷新所有组织相关查询(通配符匹配) + toastMessages.dismiss(`update-${id}`) queryClient.invalidateQueries({ queryKey: ['organizations'] }) - - // 显示成功提示 - toast.success('更新成功') + toastMessages.success('toast.organization.update.success') }, onError: (error: any, { id }) => { - // 关闭加载提示 - toast.dismiss(`update-${id}`) - - console.error('更新组织失败:', error) - console.error('后端响应:', error?.response?.data || error) - - // 前端自己构造错误提示 - toast.error('更新组织失败,请查看控制台日志') + toastMessages.dismiss(`update-${id}`) + console.error('Failed to update organization:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.organization.update.error') }, }) } @@ -179,20 +160,16 @@ export function useUpdateOrganization() { */ export function useDeleteOrganization() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (id: number) => OrganizationService.deleteOrganization(id), onMutate: async (deletedId) => { - // 显示删除开始的提示 - toast.loading('正在删除组织...', { id: `delete-${deletedId}` }) + toastMessages.loading('common.status.deleting', {}, `delete-${deletedId}`) - // 取消正在进行的查询 await queryClient.cancelQueries({ queryKey: ['organizations'] }) - - // 获取当前数据作为备份 const previousData = queryClient.getQueriesData({ queryKey: ['organizations'] }) - // 乐观更新:从所有列表查询中移除该组织 queryClient.setQueriesData( { queryKey: ['organizations'] }, (old: any) => { @@ -206,38 +183,27 @@ export function useDeleteOrganization() { } ) - // 返回备份数据用于回滚 return { previousData, deletedId } }, - onSuccess: (response, deletedId, context) => { - // 关闭加载提示 - toast.dismiss(`delete-${deletedId}`) - - // 显示删除成功信息 + onSuccess: (response, deletedId) => { + toastMessages.dismiss(`delete-${deletedId}`) const { organizationName } = response - toast.success(`组织 "${organizationName}" 已成功删除`) + toastMessages.success('toast.organization.delete.success', { name: organizationName }) }, onError: (error: any, deletedId, context) => { - // 关闭加载提示 - toast.dismiss(`delete-${deletedId}`) + toastMessages.dismiss(`delete-${deletedId}`) - // 回滚乐观更新 if (context?.previousData) { context.previousData.forEach(([queryKey, data]) => { queryClient.setQueryData(queryKey, data) }) } - console.error('删除组织失败:', error) - console.error('后端响应:', error?.response?.data || error) - - // 前端自己构造错误提示 - toast.error('删除组织失败,请查看控制台日志') + console.error('Failed to delete organization:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.organization.delete.error') }, onSettled: () => { - // 无论成功失败都刷新数据 queryClient.invalidateQueries({ queryKey: ['organizations'] }) - // 刷新目标查询,因为删除组织会解除目标的关联关系,需要更新目标的 organizations 字段 queryClient.invalidateQueries({ queryKey: ['targets'] }) }, }) @@ -248,21 +214,17 @@ export function useDeleteOrganization() { */ export function useBatchDeleteOrganizations() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (ids: number[]) => OrganizationService.batchDeleteOrganizations(ids), onMutate: async (deletedIds) => { - // 显示批量删除开始的提示 - toast.loading('正在批量删除组织...', { id: 'batch-delete' }) + toastMessages.loading('common.status.batchDeleting', {}, 'batch-delete') - // 取消正在进行的查询 await queryClient.cancelQueries({ queryKey: ['organizations'] }) - - // 获取当前数据作为备份 const previousData = queryClient.getQueriesData({ queryKey: ['organizations'] }) - // 乐观更新:从所有列表查询中移除这些组织 queryClient.setQueriesData( { queryKey: ['organizations'] }, (old: any) => { @@ -278,42 +240,27 @@ export function useBatchDeleteOrganizations() { } ) - // 返回备份数据用于回滚 return { previousData, deletedIds } }, - onSuccess: (response, deletedIds) => { - // 关闭加载提示 - toast.dismiss('batch-delete') - - // 打印后端响应 - console.log('批量删除组织成功') - console.log('后端响应:', response) - - // 显示删除成功信息 + onSuccess: (response) => { + toastMessages.dismiss('batch-delete') const { deletedOrganizationCount } = response - toast.success(`成功删除 ${deletedOrganizationCount} 个组织`) + toastMessages.success('toast.organization.delete.bulkSuccess', { count: deletedOrganizationCount }) }, onError: (error: any, deletedIds, context) => { - // 关闭加载提示 - toast.dismiss('batch-delete') + toastMessages.dismiss('batch-delete') - // 回滚乐观更新 if (context?.previousData) { context.previousData.forEach(([queryKey, data]) => { queryClient.setQueryData(queryKey, data) }) } - console.error('批量删除组织失败:', error) - console.error('后端响应:', error?.response?.data || error) - - // 前端自己构造错误提示 - toast.error('批量删除失败,请查看控制台日志') + console.error('Failed to batch delete organizations:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.organization.delete.error') }, onSettled: () => { - // 无论成功失败都刷新数据 queryClient.invalidateQueries({ queryKey: ['organizations'] }) - // 刷新目标查询,因为删除组织会解除目标的关联关系,需要更新目标的 organizations 字段 queryClient.invalidateQueries({ queryKey: ['targets'] }) }, }) @@ -326,29 +273,25 @@ export function useBatchDeleteOrganizations() { */ export function useUnlinkTargetsFromOrganization() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: { organizationId: number; targetIds: number[] }) => OrganizationService.unlinkTargetsFromOrganization(data), - onMutate: ({ organizationId, targetIds }) => { - toast.loading('正在解除关联...', { id: `unlink-${organizationId}` }) + onMutate: ({ organizationId }) => { + toastMessages.loading('common.status.unlinking', {}, `unlink-${organizationId}`) }, - onSuccess: (response, { organizationId }) => { - toast.dismiss(`unlink-${organizationId}`) - toast.success(response.message || '已成功解除关联') + onSuccess: (response, { organizationId, targetIds }) => { + toastMessages.dismiss(`unlink-${organizationId}`) + toastMessages.success('toast.target.unlink.bulkSuccess', { count: targetIds.length }) - // 刷新所有目标和组织相关查询 queryClient.invalidateQueries({ queryKey: ['targets'] }) queryClient.invalidateQueries({ queryKey: ['organizations'] }) }, onError: (error: any, { organizationId }) => { - toast.dismiss(`unlink-${organizationId}`) - - console.error('解除关联失败:', error) - console.error('后端响应:', error?.response?.data || error) - - const errorMessage = error?.response?.data?.error || '解除关联失败' - toast.error(errorMessage) + toastMessages.dismiss(`unlink-${organizationId}`) + console.error('Failed to unlink targets:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.target.unlink.error') }, }) } diff --git a/frontend/hooks/use-scans.ts b/frontend/hooks/use-scans.ts index 1dcbb9a2..ede5a525 100644 --- a/frontend/hooks/use-scans.ts +++ b/frontend/hooks/use-scans.ts @@ -1,6 +1,21 @@ -import { useQuery, keepPreviousData } from '@tanstack/react-query' -import { getScans, getScan, getScanStatistics } from '@/services/scan.service' -import type { GetScansParams } from '@/types/scan.types' +import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query' +import { + getScans, + getScan, + getScanStatistics, + quickScan, + initiateScan, + deleteScan, + bulkDeleteScans, + stopScan +} from '@/services/scan.service' +import { useToastMessages } from '@/lib/toast-helpers' +import { parseResponse, getErrorCode } from '@/lib/response-parser' +import type { + GetScansParams, + QuickScanRequest, + InitiateScanRequest +} from '@/types/scan.types' export function useScans(params: GetScansParams = { page: 1, pageSize: 10 }) { return useQuery({ @@ -31,3 +46,156 @@ export function useScanStatistics() { queryFn: getScanStatistics, }) } + +/** + * 快速扫描 mutation hook + */ +export function useQuickScan() { + const queryClient = useQueryClient() + const toastMessages = useToastMessages() + + return useMutation({ + mutationFn: (data: QuickScanRequest) => quickScan(data), + onSuccess: (response) => { + const data = parseResponse(response) + if (data) { + // 使用 i18n 消息显示成功提示 + const count = data.scans?.length || data.targetStats?.created || data.count || 0 + toastMessages.success('toast.scan.quick.success', { count }) + // 刷新扫描列表 + queryClient.invalidateQueries({ queryKey: ['scans'] }) + queryClient.invalidateQueries({ queryKey: ['scan-statistics'] }) + } + }, + onError: (error: any) => { + const errorCode = getErrorCode(error.response?.data) + if (errorCode) { + toastMessages.errorFromCode(errorCode) + } else { + toastMessages.error('toast.scan.quick.error') + } + }, + }) +} + +/** + * 发起扫描 mutation hook + */ +export function useInitiateScan() { + const queryClient = useQueryClient() + const toastMessages = useToastMessages() + + return useMutation({ + mutationFn: (data: InitiateScanRequest) => initiateScan(data), + onSuccess: (response) => { + const data = parseResponse(response) + if (data) { + // 使用 i18n 消息显示成功提示 + toastMessages.success('toast.scan.initiate.success') + // 刷新扫描列表 + queryClient.invalidateQueries({ queryKey: ['scans'] }) + queryClient.invalidateQueries({ queryKey: ['scan-statistics'] }) + } + }, + onError: (error: any) => { + const errorCode = getErrorCode(error.response?.data) + if (errorCode) { + toastMessages.errorFromCode(errorCode) + } else { + toastMessages.error('toast.scan.initiate.error') + } + }, + }) +} + +/** + * 删除扫描 mutation hook + */ +export function useDeleteScan() { + const queryClient = useQueryClient() + const toastMessages = useToastMessages() + + return useMutation({ + mutationFn: (id: number) => deleteScan(id), + onSuccess: (response, id) => { + const data = parseResponse(response) + // 使用 i18n 消息显示成功提示 + toastMessages.success('toast.scan.delete.success', { + name: `Scan #${id}` + }) + // 刷新扫描列表 + queryClient.invalidateQueries({ queryKey: ['scans'] }) + queryClient.invalidateQueries({ queryKey: ['scan-statistics'] }) + }, + onError: (error: any) => { + const errorCode = getErrorCode(error.response?.data) + if (errorCode) { + toastMessages.errorFromCode(errorCode) + } else { + toastMessages.error('toast.deleteFailed') + } + }, + }) +} + +/** + * 批量删除扫描 mutation hook + */ +export function useBulkDeleteScans() { + const queryClient = useQueryClient() + const toastMessages = useToastMessages() + + return useMutation({ + mutationFn: (ids: number[]) => bulkDeleteScans(ids), + onSuccess: (response, ids) => { + const data = parseResponse(response) + if (data) { + // 使用 i18n 消息显示成功提示 + const count = data.deletedCount || ids.length || 0 + toastMessages.success('toast.scan.delete.bulkSuccess', { count }) + // 刷新扫描列表 + queryClient.invalidateQueries({ queryKey: ['scans'] }) + queryClient.invalidateQueries({ queryKey: ['scan-statistics'] }) + } + }, + onError: (error: any) => { + const errorCode = getErrorCode(error.response?.data) + if (errorCode) { + toastMessages.errorFromCode(errorCode) + } else { + toastMessages.error('toast.bulkDeleteFailed') + } + }, + }) +} + +/** + * 停止扫描 mutation hook + */ +export function useStopScan() { + const queryClient = useQueryClient() + const toastMessages = useToastMessages() + + return useMutation({ + mutationFn: (id: number) => stopScan(id), + onSuccess: (response) => { + const data = parseResponse(response) + if (data) { + // 使用 i18n 消息显示成功提示 + const count = data.revokedTaskCount || 1 + toastMessages.success('toast.scan.stop.success', { count }) + // 刷新扫描列表 + queryClient.invalidateQueries({ queryKey: ['scans'] }) + queryClient.invalidateQueries({ queryKey: ['scan-statistics'] }) + } + }, + onError: (error: any) => { + const errorCode = getErrorCode(error.response?.data) + if (errorCode) { + toastMessages.errorFromCode(errorCode) + } else { + toastMessages.error('toast.stopFailed') + } + }, + }) +} diff --git a/frontend/hooks/use-scheduled-scans.ts b/frontend/hooks/use-scheduled-scans.ts index 61c2fbc4..adbf4a3c 100644 --- a/frontend/hooks/use-scheduled-scans.ts +++ b/frontend/hooks/use-scheduled-scans.ts @@ -1,5 +1,4 @@ import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query' -import { toast } from 'sonner' import { getScheduledScans, getScheduledScan, @@ -8,6 +7,8 @@ import { deleteScheduledScan, toggleScheduledScan, } from '@/services/scheduled-scan.service' +import { useToastMessages } from '@/lib/toast-helpers' +import { parseResponse, getErrorCode } from '@/lib/response-parser' import type { CreateScheduledScanRequest, UpdateScheduledScanRequest } from '@/types/scheduled-scan.types' /** @@ -37,15 +38,23 @@ export function useScheduledScan(id: number) { */ export function useCreateScheduledScan() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: CreateScheduledScanRequest) => createScheduledScan(data), - onSuccess: (result) => { - toast.success(result.message) + onSuccess: (response) => { + const data = parseResponse(response) + // 使用 i18n 消息显示成功提示 + toastMessages.success('toast.scheduledScan.create.success') queryClient.invalidateQueries({ queryKey: ['scheduled-scans'] }) }, - onError: (error: Error) => { - toast.error(`创建失败: ${error.message}`) + onError: (error: any) => { + const errorCode = getErrorCode(error.response?.data) + if (errorCode) { + toastMessages.errorFromCode(errorCode) + } else { + toastMessages.error('toast.scheduledScan.create.error') + } }, }) } @@ -55,17 +64,25 @@ export function useCreateScheduledScan() { */ export function useUpdateScheduledScan() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: ({ id, data }: { id: number; data: UpdateScheduledScanRequest }) => updateScheduledScan(id, data), - onSuccess: (result) => { - toast.success(result.message) + onSuccess: (response) => { + const data = parseResponse(response) + // 使用 i18n 消息显示成功提示 + toastMessages.success('toast.scheduledScan.update.success') queryClient.invalidateQueries({ queryKey: ['scheduled-scans'] }) queryClient.invalidateQueries({ queryKey: ['scheduled-scan'] }) }, - onError: (error: Error) => { - toast.error(`更新失败: ${error.message}`) + onError: (error: any) => { + const errorCode = getErrorCode(error.response?.data) + if (errorCode) { + toastMessages.errorFromCode(errorCode) + } else { + toastMessages.error('toast.scheduledScan.update.error') + } }, }) } @@ -75,15 +92,23 @@ export function useUpdateScheduledScan() { */ export function useDeleteScheduledScan() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (id: number) => deleteScheduledScan(id), - onSuccess: (result) => { - toast.success(result.message) + onSuccess: (response) => { + const data = parseResponse(response) + // 使用 i18n 消息显示成功提示 + toastMessages.success('toast.scheduledScan.delete.success') queryClient.invalidateQueries({ queryKey: ['scheduled-scans'] }) }, - onError: (error: Error) => { - toast.error(`删除失败: ${error.message}`) + onError: (error: any) => { + const errorCode = getErrorCode(error.response?.data) + if (errorCode) { + toastMessages.errorFromCode(errorCode) + } else { + toastMessages.error('toast.scheduledScan.delete.error') + } }, }) } @@ -94,6 +119,7 @@ export function useDeleteScheduledScan() { */ export function useToggleScheduledScan() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: ({ id, isEnabled }: { id: number; isEnabled: boolean }) => @@ -122,18 +148,29 @@ export function useToggleScheduledScan() { // 返回上下文用于回滚 return { previousQueries } }, - onSuccess: (result) => { - toast.success(result.message) + onSuccess: (response, { isEnabled }) => { + const data = parseResponse(response) + // 使用 i18n 消息显示成功提示 + if (isEnabled) { + toastMessages.success('toast.scheduledScan.toggle.enabled') + } else { + toastMessages.success('toast.scheduledScan.toggle.disabled') + } // 不调用 invalidateQueries,保持当前排序 }, - onError: (error: Error, _variables, context) => { + onError: (error: any, _variables, context) => { // 回滚到之前的状态 if (context?.previousQueries) { context.previousQueries.forEach(([queryKey, data]) => { queryClient.setQueryData(queryKey, data) }) } - toast.error(`操作失败: ${error.message}`) + const errorCode = getErrorCode(error.response?.data) + if (errorCode) { + toastMessages.errorFromCode(errorCode) + } else { + toastMessages.error('toast.scheduledScan.toggle.error') + } }, }) } diff --git a/frontend/hooks/use-subdomains.ts b/frontend/hooks/use-subdomains.ts index abf6ef5b..3ed3cb2a 100644 --- a/frontend/hooks/use-subdomains.ts +++ b/frontend/hooks/use-subdomains.ts @@ -1,7 +1,8 @@ "use client" import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query" -import { toast } from "sonner" +import { useToastMessages } from '@/lib/toast-helpers' +import { getErrorCode } from '@/lib/response-parser' import { SubdomainService } from "@/services/subdomain.service" import { OrganizationService } from "@/services/organization.service" import type { Subdomain, GetSubdomainsResponse, GetAllSubdomainsParams } from "@/types/subdomain.types" @@ -57,33 +58,32 @@ export function useOrganizationSubdomains( // 创建子域名(绑定到资产) export function useCreateSubdomain() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: { domains: Array<{ name: string }>; assetId: number }) => SubdomainService.createSubdomains(data), onMutate: async () => { - toast.loading('正在创建子域名...', { id: 'create-subdomain' }) + toastMessages.loading('common.status.creating', {}, 'create-subdomain') }, onSuccess: (response) => { - toast.dismiss('create-subdomain') + toastMessages.dismiss('create-subdomain') const { createdCount, existedCount, skippedCount = 0 } = response - if (skippedCount > 0 && existedCount > 0) { - toast.warning(`成功创建 ${createdCount} 个子域名(${existedCount} 个已存在,${skippedCount} 个已跳过)`) - } else if (skippedCount > 0) { - toast.warning(`成功创建 ${createdCount} 个子域名(${skippedCount} 个已跳过)`) - } else if (existedCount > 0) { - toast.warning(`成功创建 ${createdCount} 个子域名(${existedCount} 个已存在)`) + if (skippedCount > 0 || existedCount > 0) { + toastMessages.warning('toast.asset.subdomain.create.partialSuccess', { + success: createdCount, + skipped: (existedCount || 0) + (skippedCount || 0) + }) } else { - toast.success(`成功创建 ${createdCount} 个子域名`) + toastMessages.success('toast.asset.subdomain.create.success', { count: createdCount }) } queryClient.invalidateQueries({ queryKey: ['subdomains'] }) queryClient.invalidateQueries({ queryKey: ['assets'] }) }, onError: (error: any) => { - toast.dismiss('create-subdomain') - console.error('创建子域名失败:', error) - console.error('后端响应:', error?.response?.data || error) - toast.error('创建子域名失败,请查看控制台日志') + toastMessages.dismiss('create-subdomain') + console.error('Failed to create subdomain:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.asset.subdomain.create.error') }, }) } @@ -91,6 +91,7 @@ export function useCreateSubdomain() { // 从组织中移除子域名 export function useDeleteSubdomainFromOrganization() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: { organizationId: number; targetId: number }) => @@ -99,19 +100,18 @@ export function useDeleteSubdomainFromOrganization() { targetIds: [data.targetId], }), onMutate: ({ organizationId, targetId }) => { - toast.loading('正在移除子域名...', { id: `delete-${organizationId}-${targetId}` }) + toastMessages.loading('common.status.removing', {}, `delete-${organizationId}-${targetId}`) }, - onSuccess: (_response, { organizationId }) => { - toast.dismiss(`delete-${organizationId}`) - toast.success('子域名已成功移除') + onSuccess: (_response, { organizationId, targetId }) => { + toastMessages.dismiss(`delete-${organizationId}-${targetId}`) + toastMessages.success('toast.asset.subdomain.delete.success') queryClient.invalidateQueries({ queryKey: ['subdomains'] }) queryClient.invalidateQueries({ queryKey: ['organizations'] }) }, onError: (error: any, { organizationId, targetId }) => { - toast.dismiss(`delete-${organizationId}-${targetId}`) - console.error('移除子域名失败:', error) - console.error('后端响应:', error?.response?.data || error) - toast.error('移除子域名失败,请查看控制台日志') + toastMessages.dismiss(`delete-${organizationId}-${targetId}`) + console.error('Failed to remove subdomain:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.asset.subdomain.delete.error') }, }) } @@ -119,30 +119,25 @@ export function useDeleteSubdomainFromOrganization() { // 批量从组织中移除子域名 export function useBatchDeleteSubdomainsFromOrganization() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: { organizationId: number; domainIds: number[] }) => SubdomainService.batchDeleteSubdomainsFromOrganization(data), onMutate: ({ organizationId }) => { - toast.loading('正在批量移除子域名...', { id: `batch-delete-${organizationId}` }) + toastMessages.loading('common.status.batchRemoving', {}, `batch-delete-${organizationId}`) }, onSuccess: (response, { organizationId }) => { - toast.dismiss(`batch-delete-${organizationId}`) + toastMessages.dismiss(`batch-delete-${organizationId}`) const successCount = response.successCount || 0 - const failedCount = response.failedCount || 0 - if (failedCount > 0) { - toast.warning(`批量移除完成(成功:${successCount},失败:${failedCount})`) - } else { - toast.success(`成功移除 ${successCount} 个子域名`) - } + toastMessages.success('toast.asset.subdomain.delete.bulkSuccess', { count: successCount }) queryClient.invalidateQueries({ queryKey: ['subdomains'] }) queryClient.invalidateQueries({ queryKey: ['organizations'] }) }, onError: (error: any, { organizationId }) => { - toast.dismiss(`batch-delete-${organizationId}`) - console.error('批量移除子域名失败:', error) - console.error('后端响应:', error?.response?.data || error) - toast.error('批量移除失败,请查看控制台日志') + toastMessages.dismiss(`batch-delete-${organizationId}`) + console.error('Failed to batch remove subdomains:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.asset.subdomain.delete.error') }, }) } @@ -150,18 +145,16 @@ export function useBatchDeleteSubdomainsFromOrganization() { // 删除单个子域名(使用单独的 DELETE API) export function useDeleteSubdomain() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (id: number) => SubdomainService.deleteSubdomain(id), onMutate: (id) => { - toast.loading('正在删除子域名...', { id: `delete-subdomain-${id}` }) + toastMessages.loading('common.status.deleting', {}, `delete-subdomain-${id}`) }, onSuccess: (response, id) => { - toast.dismiss(`delete-subdomain-${id}`) - - // 显示删除成功信息 - const { subdomainName } = response - toast.success(`子域名 "${subdomainName}" 已成功删除`) + toastMessages.dismiss(`delete-subdomain-${id}`) + toastMessages.success('toast.asset.subdomain.delete.success') queryClient.invalidateQueries({ queryKey: ['subdomains'] }) queryClient.invalidateQueries({ queryKey: ['targets'] }) @@ -169,10 +162,9 @@ export function useDeleteSubdomain() { queryClient.invalidateQueries({ queryKey: ['organizations'] }) }, onError: (error: any, id) => { - toast.dismiss(`delete-subdomain-${id}`) - console.error('删除子域名失败:', error) - console.error('后端响应:', error?.response?.data || error) - toast.error('删除子域名失败,请查看控制台日志') + toastMessages.dismiss(`delete-subdomain-${id}`) + console.error('Failed to delete subdomain:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.asset.subdomain.delete.error') }, }) } @@ -180,29 +172,16 @@ export function useDeleteSubdomain() { // 批量删除子域名(使用统一的批量删除接口) export function useBatchDeleteSubdomains() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (ids: number[]) => SubdomainService.batchDeleteSubdomains(ids), onMutate: () => { - toast.loading('正在批量删除子域名...', { id: 'batch-delete-subdomains' }) + toastMessages.loading('common.status.batchDeleting', {}, 'batch-delete-subdomains') }, onSuccess: (response) => { - toast.dismiss('batch-delete-subdomains') - - // 显示级联删除信息 - const cascadeInfo = Object.entries(response.cascadeDeleted || {}) - .filter(([key, count]) => key !== 'asset.Subdomain' && count > 0) - .map(([key, count]) => { - const modelName = key.split('.')[1] - return `${modelName}: ${count}` - }) - .join(', ') - - if (cascadeInfo) { - toast.success(`成功删除 ${response.deletedCount} 个子域名(级联删除: ${cascadeInfo})`) - } else { - toast.success(`成功删除 ${response.deletedCount} 个子域名`) - } + toastMessages.dismiss('batch-delete-subdomains') + toastMessages.success('toast.asset.subdomain.delete.bulkSuccess', { count: response.deletedCount }) queryClient.invalidateQueries({ queryKey: ['subdomains'] }) queryClient.invalidateQueries({ queryKey: ['targets'] }) @@ -210,10 +189,9 @@ export function useBatchDeleteSubdomains() { queryClient.invalidateQueries({ queryKey: ['organizations'] }) }, onError: (error: any) => { - toast.dismiss('batch-delete-subdomains') - console.error('批量删除子域名失败:', error) - console.error('后端响应:', error?.response?.data || error) - toast.error('批量删除失败,请查看控制台日志') + toastMessages.dismiss('batch-delete-subdomains') + console.error('Failed to batch delete subdomains:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.asset.subdomain.delete.error') }, }) } @@ -221,24 +199,24 @@ export function useBatchDeleteSubdomains() { // 更新子域名 export function useUpdateSubdomain() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: ({ id, data }: { id: number; data: { name?: string; description?: string } }) => SubdomainService.updateSubdomain({ id, ...data }), onMutate: ({ id }) => { - toast.loading('正在更新子域名...', { id: `update-subdomain-${id}` }) + toastMessages.loading('common.status.updating', {}, `update-subdomain-${id}`) }, onSuccess: (_response, { id }) => { - toast.dismiss(`update-subdomain-${id}`) - toast.success('更新成功') + toastMessages.dismiss(`update-subdomain-${id}`) + toastMessages.success('common.status.updateSuccess') queryClient.invalidateQueries({ queryKey: ['subdomains'] }) queryClient.invalidateQueries({ queryKey: ['organizations'] }) }, onError: (error: any, { id }) => { - toast.dismiss(`update-subdomain-${id}`) - console.error('更新子域名失败:', error) - console.error('后端响应:', error?.response?.data || error) - toast.error('更新子域名失败,请查看控制台日志') + toastMessages.dismiss(`update-subdomain-${id}`) + console.error('Failed to update subdomain:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'common.status.updateFailed') }, }) } @@ -295,49 +273,40 @@ export function useScanSubdomains( // 批量创建子域名(绑定到目标) export function useBulkCreateSubdomains() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: { targetId: number; subdomains: string[] }) => SubdomainService.bulkCreateSubdomains(data.targetId, data.subdomains), onMutate: async () => { - toast.loading('正在批量创建子域名...', { id: 'bulk-create-subdomains' }) + toastMessages.loading('common.status.batchCreating', {}, 'bulk-create-subdomains') }, onSuccess: (response, { targetId }) => { - toast.dismiss('bulk-create-subdomains') - const { createdCount, skippedCount, invalidCount, mismatchedCount } = response + toastMessages.dismiss('bulk-create-subdomains') + const { createdCount, skippedCount = 0, invalidCount = 0, mismatchedCount = 0 } = response + const totalSkipped = skippedCount + invalidCount + mismatchedCount - let message = `成功创建 ${createdCount} 个子域名` - const details: string[] = [] - - if (skippedCount > 0) { - details.push(`${skippedCount} 个重复`) - } - if (invalidCount > 0) { - details.push(`${invalidCount} 个格式无效`) - } - if (mismatchedCount > 0) { - details.push(`${mismatchedCount} 个不匹配`) - } - - if (details.length > 0) { - message += `(${details.join(',')})` - } - - if (createdCount > 0) { - toast.success(message) + if (totalSkipped > 0) { + toastMessages.warning('toast.asset.subdomain.create.partialSuccess', { + success: createdCount, + skipped: totalSkipped + }) + } else if (createdCount > 0) { + toastMessages.success('toast.asset.subdomain.create.success', { count: createdCount }) } else { - toast.warning(message) + toastMessages.warning('toast.asset.subdomain.create.partialSuccess', { + success: 0, + skipped: 0 + }) } - // 刷新子域名列表 queryClient.invalidateQueries({ queryKey: ['targets', targetId, 'subdomains'] }) queryClient.invalidateQueries({ queryKey: ['subdomains'] }) }, onError: (error: any) => { - toast.dismiss('bulk-create-subdomains') - console.error('批量创建子域名失败:', error) - const errorMessage = error?.response?.data?.error || '批量创建失败,请查看控制台日志' - toast.error(errorMessage) + toastMessages.dismiss('bulk-create-subdomains') + console.error('Failed to bulk create subdomains:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.asset.subdomain.create.error') }, }) } diff --git a/frontend/hooks/use-system-logs.ts b/frontend/hooks/use-system-logs.ts index 0480623a..a1d2f594 100644 --- a/frontend/hooks/use-system-logs.ts +++ b/frontend/hooks/use-system-logs.ts @@ -1,11 +1,12 @@ import { useEffect, useRef } from "react" import { useQuery } from "@tanstack/react-query" -import { toast } from "sonner" import { systemLogService } from "@/services/system-log.service" +import { useToastMessages } from "@/lib/toast-helpers" export function useSystemLogs(options?: { lines?: number; enabled?: boolean }) { const hadErrorRef = useRef(false) + const toastMessages = useToastMessages() const query = useQuery({ queryKey: ["system", "logs", { lines: options?.lines ?? null }], @@ -19,14 +20,14 @@ export function useSystemLogs(options?: { lines?: number; enabled?: boolean }) { useEffect(() => { if (query.isError && !hadErrorRef.current) { hadErrorRef.current = true - toast.error("系统日志获取失败,请检查后端接口") + toastMessages.error('toast.systemLog.fetch.error') } if (query.isSuccess && hadErrorRef.current) { hadErrorRef.current = false - toast.success("系统日志连接已恢复") + toastMessages.success('toast.systemLog.fetch.recovered') } - }, [query.isError, query.isSuccess]) + }, [query.isError, query.isSuccess, toastMessages]) return query } diff --git a/frontend/hooks/use-targets.ts b/frontend/hooks/use-targets.ts index 38bdf37f..18d61456 100644 --- a/frontend/hooks/use-targets.ts +++ b/frontend/hooks/use-targets.ts @@ -2,7 +2,8 @@ * Targets Hooks - 目标管理相关 hooks */ import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query' -import { toast } from 'sonner' +import { useToastMessages } from '@/lib/toast-helpers' +import { getErrorCode } from '@/lib/response-parser' import { getTargets, getTargetById, @@ -110,15 +111,16 @@ export function useTarget(id: number, options?: { enabled?: boolean }) { */ export function useCreateTarget() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: CreateTargetRequest) => createTarget(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['targets'] }) - toast.success('目标创建成功') + toastMessages.success('toast.target.create.success') }, - onError: (error: Error) => { - toast.error(`创建失败: ${error.message}`) + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.target.create.error') }, }) } @@ -128,6 +130,7 @@ export function useCreateTarget() { */ export function useUpdateTarget() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: ({ id, data }: { id: number; data: UpdateTargetRequest }) => @@ -135,10 +138,10 @@ export function useUpdateTarget() { onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: ['targets'] }) queryClient.invalidateQueries({ queryKey: ['targets', variables.id] }) - toast.success('目标更新成功') + toastMessages.success('toast.target.update.success') }, - onError: (error: Error) => { - toast.error(`更新失败: ${error.message}`) + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.target.update.error') }, }) } @@ -148,25 +151,26 @@ export function useUpdateTarget() { */ export function useDeleteTarget() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (id: number) => deleteTarget(id), onMutate: (id) => { - toast.loading('正在删除目标...', { id: `delete-target-${id}` }) + toastMessages.loading('common.status.deleting', {}, `delete-target-${id}`) }, onSuccess: (response, id) => { - toast.dismiss(`delete-target-${id}`) + toastMessages.dismiss(`delete-target-${id}`) // 显示删除成功信息 const { targetName } = response - toast.success(`目标 "${targetName}" 已成功删除`) + toastMessages.success('toast.target.delete.success', { name: targetName }) queryClient.invalidateQueries({ queryKey: ['targets'] }) queryClient.invalidateQueries({ queryKey: ['organizations'] }) }, - onError: (error: Error, id) => { - toast.dismiss(`delete-target-${id}`) - toast.error(`删除失败: ${error.message}`) + onError: (error: any, id) => { + toastMessages.dismiss(`delete-target-${id}`) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.target.delete.error') }, }) } @@ -176,15 +180,16 @@ export function useDeleteTarget() { */ export function useBatchDeleteTargets() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: BatchDeleteTargetsRequest) => batchDeleteTargets(data), onSuccess: (response) => { queryClient.invalidateQueries({ queryKey: ['targets'] }) - toast.success(`成功删除 ${response.deletedCount} 个目标`) + toastMessages.success('toast.target.delete.bulkSuccess', { count: response.deletedCount }) }, - onError: (error: Error) => { - toast.error(`批量删除失败: ${error.message}`) + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.target.delete.error') }, }) } @@ -194,16 +199,17 @@ export function useBatchDeleteTargets() { */ export function useBatchCreateTargets() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: BatchCreateTargetsRequest) => batchCreateTargets(data), onSuccess: (response) => { queryClient.invalidateQueries({ queryKey: ['targets'] }) queryClient.invalidateQueries({ queryKey: ['organizations'] }) - toast.success(response.message) + toastMessages.success('toast.target.create.bulkSuccess', { count: response.createdCount || 0 }) }, - onError: (error: Error) => { - toast.error(`批量创建失败: ${error.message}`) + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.target.create.error') }, }) } @@ -224,6 +230,7 @@ export function useTargetOrganizations(targetId: number, page = 1, pageSize = 10 */ export function useLinkTargetOrganizations() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: ({ targetId, organizationIds }: { targetId: number; organizationIds: number[] }) => @@ -231,10 +238,10 @@ export function useLinkTargetOrganizations() { onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: ['targets', variables.targetId, 'organizations'] }) queryClient.invalidateQueries({ queryKey: ['targets', variables.targetId] }) - toast.success('组织关联成功') + toastMessages.success('toast.target.link.success') }, - onError: (error: Error) => { - toast.error(`关联失败: ${error.message}`) + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.target.link.error') }, }) } @@ -244,6 +251,7 @@ export function useLinkTargetOrganizations() { */ export function useUnlinkTargetOrganizations() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: ({ targetId, organizationIds }: { targetId: number; organizationIds: number[] }) => @@ -251,10 +259,10 @@ export function useUnlinkTargetOrganizations() { onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: ['targets', variables.targetId, 'organizations'] }) queryClient.invalidateQueries({ queryKey: ['targets', variables.targetId] }) - toast.success('取消关联成功') + toastMessages.success('toast.target.unlink.success') }, - onError: (error: Error) => { - toast.error(`取消关联失败: ${error.message}`) + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.target.unlink.error') }, }) } diff --git a/frontend/hooks/use-tools.ts b/frontend/hooks/use-tools.ts index 5f1fb652..f2444510 100644 --- a/frontend/hooks/use-tools.ts +++ b/frontend/hooks/use-tools.ts @@ -1,7 +1,8 @@ "use client" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { toast } from "sonner" +import { useToastMessages } from '@/lib/toast-helpers' +import { getErrorCode } from '@/lib/response-parser' import { ToolService } from "@/services/tool.service" import type { Tool, GetToolsParams, CreateToolRequest, UpdateToolRequest } from "@/types/tool.types" @@ -35,35 +36,25 @@ export function useTools(params: GetToolsParams = {}) { // 创建工具 export function useCreateTool() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: CreateToolRequest) => ToolService.createTool(data), onMutate: async () => { - toast.loading('正在创建工具...', { id: 'create-tool' }) + toastMessages.loading('common.status.creating', {}, 'create-tool') }, - onSuccess: (response) => { - toast.dismiss('create-tool') - - // 打印后端响应 - console.log('创建工具成功') - console.log('后端响应:', response) - - toast.success('创建成功') - - // 刷新工具列表和分类列表 + onSuccess: () => { + toastMessages.dismiss('create-tool') + toastMessages.success('toast.tool.create.success') queryClient.invalidateQueries({ queryKey: toolKeys.all, refetchType: 'active' }) }, onError: (error: any) => { - toast.dismiss('create-tool') - - console.error('创建工具失败:', error) - console.error('后端响应:', error?.response?.data || error) - - // 前端自己构造错误提示 - toast.error('创建工具失败,请查看控制台日志') + toastMessages.dismiss('create-tool') + console.error('Failed to create tool:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.tool.create.error') }, }) } @@ -71,34 +62,26 @@ export function useCreateTool() { // 更新工具 export function useUpdateTool() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: ({ id, data }: { id: number; data: UpdateToolRequest }) => ToolService.updateTool(id, data), onMutate: async () => { - toast.loading('正在更新工具...', { id: 'update-tool' }) + toastMessages.loading('common.status.updating', {}, 'update-tool') }, - onSuccess: (response) => { - toast.dismiss('update-tool') - - console.log('更新工具成功') - console.log('后端响应:', response) - - toast.success('更新成功') - - // 刷新工具列表 + onSuccess: () => { + toastMessages.dismiss('update-tool') + toastMessages.success('toast.tool.update.success') queryClient.invalidateQueries({ queryKey: toolKeys.all, refetchType: 'active' }) }, onError: (error: any) => { - toast.dismiss('update-tool') - - console.error('更新工具失败:', error) - console.error('后端响应:', error?.response?.data || error) - - toast.error('更新工具失败,请查看控制台日志') + toastMessages.dismiss('update-tool') + console.error('Failed to update tool:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.tool.update.error') }, }) } @@ -106,32 +89,25 @@ export function useUpdateTool() { // 删除工具 export function useDeleteTool() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (id: number) => ToolService.deleteTool(id), onMutate: async () => { - toast.loading('正在删除工具...', { id: 'delete-tool' }) + toastMessages.loading('common.status.deleting', {}, 'delete-tool') }, - onSuccess: (response) => { - toast.dismiss('delete-tool') - - console.log('删除工具成功') - - toast.success('删除成功') - - // 刷新工具列表 + onSuccess: () => { + toastMessages.dismiss('delete-tool') + toastMessages.success('toast.tool.delete.success') queryClient.invalidateQueries({ queryKey: toolKeys.all, refetchType: 'active' }) }, onError: (error: any) => { - toast.dismiss('delete-tool') - - console.error('删除工具失败:', error) - console.error('后端响应:', error?.response?.data || error) - - toast.error('删除工具失败,请查看控制台日志') + toastMessages.dismiss('delete-tool') + console.error('Failed to delete tool:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.tool.delete.error') }, }) } diff --git a/frontend/hooks/use-websites.ts b/frontend/hooks/use-websites.ts index 3d7976ba..0e7f6e17 100644 --- a/frontend/hooks/use-websites.ts +++ b/frontend/hooks/use-websites.ts @@ -1,5 +1,6 @@ import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query' -import { toast } from 'sonner' +import { useToastMessages } from '@/lib/toast-helpers' +import { getErrorCode } from '@/lib/response-parser' import { WebsiteService } from '@/services/website.service' import type { WebSite, WebSiteListResponse } from '@/types/website.types' @@ -108,28 +109,25 @@ export function useScanWebSites( // 删除单个网站(使用单独的 DELETE API) export function useDeleteWebSite() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: websiteService.deleteWebSite, onMutate: (id) => { - toast.loading('正在删除网站...', { id: `delete-website-${id}` }) + toastMessages.loading('common.status.deleting', {}, `delete-website-${id}`) }, onSuccess: (response, id) => { - toast.dismiss(`delete-website-${id}`) + toastMessages.dismiss(`delete-website-${id}`) + toastMessages.success('toast.asset.website.delete.success') - // 显示删除成功信息 - const { websiteUrl } = response - toast.success(`网站 "${websiteUrl}" 已成功删除`) - - // 刷新相关查询 queryClient.invalidateQueries({ queryKey: ['target-websites'] }) queryClient.invalidateQueries({ queryKey: ['scan-websites'] }) queryClient.invalidateQueries({ queryKey: ['targets'] }) queryClient.invalidateQueries({ queryKey: ['scans'] }) }, - onError: (error: Error, id) => { - toast.dismiss(`delete-website-${id}`) - toast.error(error.message || '删除网站失败') + onError: (error: any, id) => { + toastMessages.dismiss(`delete-website-${id}`) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.asset.website.delete.error') }, }) } @@ -137,39 +135,25 @@ export function useDeleteWebSite() { // 批量删除网站(使用统一的批量删除接口) export function useBulkDeleteWebSites() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: websiteService.bulkDeleteWebSites, onMutate: () => { - toast.loading('正在批量删除网站...', { id: 'bulk-delete-websites' }) + toastMessages.loading('common.status.batchDeleting', {}, 'bulk-delete-websites') }, onSuccess: (response) => { - toast.dismiss('bulk-delete-websites') + toastMessages.dismiss('bulk-delete-websites') + toastMessages.success('toast.asset.website.delete.bulkSuccess', { count: response.deletedCount }) - // 显示级联删除信息 - const cascadeInfo = Object.entries(response.cascadeDeleted || {}) - .filter(([key, count]) => key !== 'asset.WebSite' && count > 0) - .map(([key, count]) => { - const modelName = key.split('.')[1] - return `${modelName}: ${count}` - }) - .join(', ') - - if (cascadeInfo) { - toast.success(`成功删除 ${response.deletedCount} 个网站(级联删除: ${cascadeInfo})`) - } else { - toast.success(`成功删除 ${response.deletedCount} 个网站`) - } - - // 刷新相关查询 queryClient.invalidateQueries({ queryKey: ['target-websites'] }) queryClient.invalidateQueries({ queryKey: ['scan-websites'] }) queryClient.invalidateQueries({ queryKey: ['targets'] }) queryClient.invalidateQueries({ queryKey: ['scans'] }) }, - onError: (error: Error) => { - toast.dismiss('bulk-delete-websites') - toast.error(error.message || '批量删除网站失败') + onError: (error: any) => { + toastMessages.dismiss('bulk-delete-websites') + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.asset.website.delete.error') }, }) } @@ -178,32 +162,31 @@ export function useBulkDeleteWebSites() { // 批量创建网站(绑定到目标) export function useBulkCreateWebsites() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: { targetId: number; urls: string[] }) => WebsiteService.bulkCreateWebsites(data.targetId, data.urls), onMutate: async () => { - toast.loading('正在批量创建网站...', { id: 'bulk-create-websites' }) + toastMessages.loading('common.status.batchCreating', {}, 'bulk-create-websites') }, onSuccess: (response, { targetId }) => { - toast.dismiss('bulk-create-websites') + toastMessages.dismiss('bulk-create-websites') const { createdCount } = response if (createdCount > 0) { - toast.success(`成功创建 ${createdCount} 个网站`) + toastMessages.success('toast.asset.website.create.success', { count: createdCount }) } else { - toast.warning('没有新网站被创建(可能已存在)') + toastMessages.warning('toast.asset.website.create.partialSuccess', { success: 0, skipped: 0 }) } - // 刷新网站列表 queryClient.invalidateQueries({ queryKey: ['target-websites', targetId] }) queryClient.invalidateQueries({ queryKey: ['scan-websites'] }) }, onError: (error: any) => { - toast.dismiss('bulk-create-websites') - console.error('批量创建网站失败:', error) - const errorMessage = error?.response?.data?.error || '批量创建失败,请查看控制台日志' - toast.error(errorMessage) + toastMessages.dismiss('bulk-create-websites') + console.error('Failed to bulk create websites:', error) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.asset.website.create.error') }, }) } diff --git a/frontend/hooks/use-wordlists.ts b/frontend/hooks/use-wordlists.ts index 348206bf..7a1ff1f4 100644 --- a/frontend/hooks/use-wordlists.ts +++ b/frontend/hooks/use-wordlists.ts @@ -1,7 +1,8 @@ "use client" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { toast } from "sonner" +import { useToastMessages } from '@/lib/toast-helpers' +import { getErrorCode } from '@/lib/response-parser' import { getWordlists, uploadWordlist, @@ -25,20 +26,21 @@ export function useWordlists(params?: { page?: number; pageSize?: number }) { // Upload wordlist export function useUploadWordlist() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation<{}, Error, { name: string; description?: string; file: File }>({ mutationFn: (payload) => uploadWordlist(payload), onMutate: () => { - toast.loading("Uploading wordlist...", { id: "upload-wordlist" }) + toastMessages.loading('common.status.uploading', {}, 'upload-wordlist') }, onSuccess: () => { - toast.dismiss("upload-wordlist") - toast.success("Wordlist uploaded successfully") + toastMessages.dismiss('upload-wordlist') + toastMessages.success('toast.wordlist.upload.success') queryClient.invalidateQueries({ queryKey: ["wordlists"] }) }, - onError: (error) => { - toast.dismiss("upload-wordlist") - toast.error(`Upload failed: ${error.message}`) + onError: (error: any) => { + toastMessages.dismiss('upload-wordlist') + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.wordlist.upload.error') }, }) } @@ -46,20 +48,21 @@ export function useUploadWordlist() { // Delete wordlist export function useDeleteWordlist() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (id: number) => deleteWordlist(id), onMutate: (id) => { - toast.loading("Deleting wordlist...", { id: `delete-wordlist-${id}` }) + toastMessages.loading('common.status.deleting', {}, `delete-wordlist-${id}`) }, onSuccess: (_data, id) => { - toast.dismiss(`delete-wordlist-${id}`) - toast.success("Wordlist deleted successfully") + toastMessages.dismiss(`delete-wordlist-${id}`) + toastMessages.success('toast.wordlist.delete.success') queryClient.invalidateQueries({ queryKey: ["wordlists"] }) }, - onError: (error, id) => { - toast.dismiss(`delete-wordlist-${id}`) - toast.error(`Delete failed: ${error.message}`) + onError: (error: any, id) => { + toastMessages.dismiss(`delete-wordlist-${id}`) + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.wordlist.delete.error') }, }) } @@ -76,21 +79,22 @@ export function useWordlistContent(id: number | null) { // Update wordlist content export function useUpdateWordlistContent() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: ({ id, content }) => updateWordlistContent(id, content), onMutate: () => { - toast.loading("Saving...", { id: "update-wordlist-content" }) + toastMessages.loading('common.actions.saving', {}, 'update-wordlist-content') }, onSuccess: (data) => { - toast.dismiss("update-wordlist-content") - toast.success("Wordlist saved successfully") + toastMessages.dismiss('update-wordlist-content') + toastMessages.success('toast.wordlist.update.success') queryClient.invalidateQueries({ queryKey: ["wordlists"] }) queryClient.invalidateQueries({ queryKey: ["wordlist-content", data.id] }) }, - onError: (error) => { - toast.dismiss("update-wordlist-content") - toast.error(`Save failed: ${error.message}`) + onError: (error: any) => { + toastMessages.dismiss('update-wordlist-content') + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.wordlist.update.error') }, }) } diff --git a/frontend/hooks/use-workers.ts b/frontend/hooks/use-workers.ts index d78fa818..c3f45a05 100644 --- a/frontend/hooks/use-workers.ts +++ b/frontend/hooks/use-workers.ts @@ -5,7 +5,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { workerService } from '@/services/worker.service' import type { CreateWorkerRequest, UpdateWorkerRequest } from '@/types/worker.types' -import { toast } from 'sonner' +import { useToastMessages } from '@/lib/toast-helpers' +import { getErrorCode } from '@/lib/response-parser' // Query Keys export const workerKeys = { @@ -42,15 +43,16 @@ export function useWorker(id: number) { */ export function useCreateWorker() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (data: CreateWorkerRequest) => workerService.createWorker(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: workerKeys.lists() }) - toast.success('Worker 节点创建成功') + toastMessages.success('toast.worker.create.success') }, - onError: (error: Error) => { - toast.error(`创建失败: ${error.message}`) + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.worker.create.error') }, }) } @@ -60,6 +62,7 @@ export function useCreateWorker() { */ export function useUpdateWorker() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: ({ id, data }: { id: number; data: UpdateWorkerRequest }) => @@ -67,10 +70,10 @@ export function useUpdateWorker() { onSuccess: (_: unknown, { id }: { id: number; data: UpdateWorkerRequest }) => { queryClient.invalidateQueries({ queryKey: workerKeys.lists() }) queryClient.invalidateQueries({ queryKey: workerKeys.detail(id) }) - toast.success('Worker 节点更新成功') + toastMessages.success('toast.worker.update.success') }, - onError: (error: Error) => { - toast.error(`更新失败: ${error.message}`) + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.worker.update.error') }, }) } @@ -80,19 +83,19 @@ export function useUpdateWorker() { */ export function useDeleteWorker() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (id: number) => workerService.deleteWorker(id), onSuccess: () => { - // 立即刷新活跃的列表查询 queryClient.invalidateQueries({ queryKey: workerKeys.lists(), refetchType: 'active', }) - toast.success('Worker 节点已删除') + toastMessages.success('toast.worker.delete.success') }, - onError: (error: Error) => { - toast.error(`删除失败: ${error.message}`) + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.worker.delete.error') }, }) } @@ -102,16 +105,17 @@ export function useDeleteWorker() { */ export function useDeployWorker() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (id: number) => workerService.deployWorker(id), onSuccess: (_: unknown, id: number) => { queryClient.invalidateQueries({ queryKey: workerKeys.detail(id) }) queryClient.invalidateQueries({ queryKey: workerKeys.lists() }) - toast.success('部署已启动') + toastMessages.success('toast.worker.deploy.success') }, - onError: (error: Error) => { - toast.error(`部署失败: ${error.message}`) + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.worker.deploy.error') }, }) } @@ -121,16 +125,17 @@ export function useDeployWorker() { */ export function useRestartWorker() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (id: number) => workerService.restartWorker(id), onSuccess: (_: unknown, id: number) => { queryClient.invalidateQueries({ queryKey: workerKeys.detail(id) }) queryClient.invalidateQueries({ queryKey: workerKeys.lists() }) - toast.success('Worker 正在重启') + toastMessages.success('toast.worker.restart.success') }, - onError: (error: Error) => { - toast.error(`重启失败: ${error.message}`) + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.worker.restart.error') }, }) } @@ -140,16 +145,17 @@ export function useRestartWorker() { */ export function useStopWorker() { const queryClient = useQueryClient() + const toastMessages = useToastMessages() return useMutation({ mutationFn: (id: number) => workerService.stopWorker(id), onSuccess: (_: unknown, id: number) => { queryClient.invalidateQueries({ queryKey: workerKeys.detail(id) }) queryClient.invalidateQueries({ queryKey: workerKeys.lists() }) - toast.success('Worker 已停止') + toastMessages.success('toast.worker.stop.success') }, - onError: (error: Error) => { - toast.error(`停止失败: ${error.message}`) + onError: (error: any) => { + toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.worker.stop.error') }, }) } diff --git a/frontend/lib/error-code-map.ts b/frontend/lib/error-code-map.ts new file mode 100644 index 00000000..557fadfa --- /dev/null +++ b/frontend/lib/error-code-map.ts @@ -0,0 +1,70 @@ +/** + * 错误码到 i18n 键的映射 + * + * 采用简化方案(参考 Stripe、GitHub 等大厂做法): + * - 只映射通用错误码(5-10 个) + * - 未知错误码使用 errors.unknown + * - 错误码与后端 ErrorCodes 类保持一致 + * + * 后端错误码定义: backend/apps/common/error_codes.py + */ + +/** + * 错误码到 i18n 键的映射表 + * + * 键: 后端返回的错误码(大写字母和下划线) + * 值: 前端 i18n 键(在 messages/en.json 和 messages/zh.json 中定义) + */ +export const ERROR_CODE_MAP: Record = { + // 通用错误码(8 个,与后端 ErrorCodes 类一致) + VALIDATION_ERROR: 'errors.validation', + NOT_FOUND: 'errors.notFound', + PERMISSION_DENIED: 'errors.permissionDenied', + SERVER_ERROR: 'errors.serverError', + BAD_REQUEST: 'errors.badRequest', + CONFLICT: 'errors.conflict', + UNAUTHORIZED: 'errors.unauthorized', + RATE_LIMITED: 'errors.rateLimited', +}; + +/** + * 默认错误 i18n 键 + * 用于未知错误码的回退 + */ +export const DEFAULT_ERROR_KEY = 'errors.unknown'; + +/** + * 获取错误码对应的 i18n 键 + * + * @param code - 后端返回的错误码 + * @returns 对应的 i18n 键,未知错误码返回 'errors.unknown' + * + * @example + * const errorKey = getErrorI18nKey('NOT_FOUND'); + * // 返回: 'errors.notFound' + * + * const unknownKey = getErrorI18nKey('SOME_UNKNOWN_ERROR'); + * // 返回: 'errors.unknown' + */ +export function getErrorI18nKey(code: string): string { + return ERROR_CODE_MAP[code] ?? DEFAULT_ERROR_KEY; +} + +/** + * 检查错误码是否已知 + * + * @param code - 后端返回的错误码 + * @returns 如果错误码在映射表中返回 true + */ +export function isKnownErrorCode(code: string): boolean { + return code in ERROR_CODE_MAP; +} + +/** + * 获取所有已知的错误码列表 + * + * @returns 错误码数组 + */ +export function getAllErrorCodes(): string[] { + return Object.keys(ERROR_CODE_MAP); +} diff --git a/frontend/lib/response-parser.ts b/frontend/lib/response-parser.ts new file mode 100644 index 00000000..7fb0f1c9 --- /dev/null +++ b/frontend/lib/response-parser.ts @@ -0,0 +1,232 @@ +/** + * API 响应解析器 + * + * 统一处理后端 API 响应,支持新的标准化响应格式: + * - 成功响应: { data?: T, meta?: {...} } + * - 错误响应: { error: { code: string, message?: string, details?: unknown[] } } + * + * 同时保持对旧格式的向后兼容性: + * - 旧格式: { code: string, state: string, message: string, data?: T } + */ + +/** + * 标准化成功响应类型 + */ +export interface ApiSuccessResponse { + data?: T; + meta?: { + count?: number; + total?: number; + page?: number; + pageSize?: number; + [key: string]: unknown; + }; +} + +/** + * 标准化错误响应类型 + */ +export interface ApiErrorResponse { + error: { + code: string; + message?: string; + details?: unknown[]; + }; +} + +/** + * 统一 API 响应类型(新格式) + */ +export type ApiResponse = ApiSuccessResponse | ApiErrorResponse; + +/** + * 旧版 API 响应类型(向后兼容) + */ +export interface LegacyApiResponse { + code: string; + state: string; + message: string; + data?: T; +} + +/** + * 判断响应是否为错误响应 + * + * @param response - API 响应对象 + * @returns 如果是错误响应返回 true + * + * @example + * const response = await api.get('/scans'); + * if (isErrorResponse(response)) { + * console.error('Error:', response.error.code); + * } + */ +export function isErrorResponse(response: unknown): response is ApiErrorResponse { + return ( + typeof response === 'object' && + response !== null && + 'error' in response && + typeof (response as ApiErrorResponse).error === 'object' && + (response as ApiErrorResponse).error !== null && + typeof (response as ApiErrorResponse).error.code === 'string' + ); +} + +/** + * 判断响应是否为成功响应 + * + * @param response - API 响应对象 + * @returns 如果是成功响应返回 true + */ +export function isSuccessResponse( + response: unknown +): response is ApiSuccessResponse { + // 新格式:没有 error 字段 + if (typeof response !== 'object' || response === null) { + return false; + } + + // 如果有 error 字段,则不是成功响应 + if ('error' in response) { + return false; + } + + return true; +} + +/** + * 判断响应是否为旧版格式 + * + * @param response - API 响应对象 + * @returns 如果是旧版格式返回 true + */ +export function isLegacyResponse( + response: unknown +): response is LegacyApiResponse { + return ( + typeof response === 'object' && + response !== null && + 'state' in response && + 'code' in response && + typeof (response as LegacyApiResponse).state === 'string' + ); +} + +/** + * 判断旧版响应是否为错误 + * + * @param response - 旧版 API 响应对象 + * @returns 如果是错误响应返回 true + */ +export function isLegacyErrorResponse( + response: LegacyApiResponse +): boolean { + return response.state !== 'success'; +} + +/** + * 从响应中解析数据 + * + * 支持新旧两种响应格式: + * - 新格式: { data: T } + * - 旧格式: { state: 'success', data: T } + * + * @param response - API 响应对象 + * @returns 解析出的数据,如果是错误响应则返回 null + * + * @example + * const response = await api.get('/scans'); + * const data = parseResponse(response); + * if (data) { + * console.log('Scans:', data); + * } + */ +export function parseResponse(response: unknown): T | null { + // 处理新格式错误响应 + if (isErrorResponse(response)) { + return null; + } + + // 处理旧格式响应 + if (isLegacyResponse(response)) { + if (isLegacyErrorResponse(response)) { + return null; + } + return response.data ?? null; + } + + // 处理新格式成功响应 + if (isSuccessResponse(response)) { + return response.data ?? null; + } + + return null; +} + +/** + * 从响应中获取错误码 + * + * 支持新旧两种响应格式: + * - 新格式: { error: { code: 'ERROR_CODE' } } + * - 旧格式: { state: 'error', code: '400' } + * + * @param response - API 响应对象 + * @returns 错误码字符串,如果不是错误响应则返回 null + * + * @example + * const response = await api.delete('/scans/123'); + * const errorCode = getErrorCode(response); + * if (errorCode) { + * toast.error(t(`errors.${errorCode}`)); + * } + */ +export function getErrorCode(response: unknown): string | null { + // 处理新格式错误响应 + if (isErrorResponse(response)) { + return response.error.code; + } + + // 处理旧格式错误响应 + if (isLegacyResponse(response) && isLegacyErrorResponse(response)) { + // 旧格式的 code 是 HTTP 状态码,不是错误码 + // 返回通用错误码 + return 'SERVER_ERROR'; + } + + return null; +} + +/** + * 从响应中获取错误消息(用于调试) + * + * @param response - API 响应对象 + * @returns 错误消息字符串,如果不是错误响应则返回 null + */ +export function getErrorMessage(response: unknown): string | null { + // 处理新格式错误响应 + if (isErrorResponse(response)) { + return response.error.message ?? null; + } + + // 处理旧格式错误响应 + if (isLegacyResponse(response) && isLegacyErrorResponse(response)) { + return response.message; + } + + return null; +} + +/** + * 从响应中获取元数据 + * + * @param response - API 响应对象 + * @returns 元数据对象,如果没有则返回 null + */ +export function getResponseMeta( + response: unknown +): ApiSuccessResponse['meta'] | null { + if (isSuccessResponse(response)) { + return response.meta ?? null; + } + return null; +} diff --git a/frontend/lib/toast-helpers.ts b/frontend/lib/toast-helpers.ts new file mode 100644 index 00000000..bbe7d75a --- /dev/null +++ b/frontend/lib/toast-helpers.ts @@ -0,0 +1,234 @@ +/** + * Toast 消息辅助函数 + * + * 提供 i18n 感知的 toast 消息显示功能: + * - success(): 显示成功消息 + * - error(): 显示错误消息 + * - errorFromCode(): 根据错误码显示错误消息 + * - loading(): 显示加载消息 + * - dismiss(): 关闭指定 toast + * + * 使用方式: + * ```tsx + * function MyComponent() { + * const toastMessages = useToastMessages(); + * + * const handleDelete = async () => { + * try { + * await deleteItem(id); + * toastMessages.success('toast.item.delete.success', { name: item.name }); + * } catch (error) { + * toastMessages.errorFromCode(getErrorCode(error)); + * } + * }; + * } + * ``` + */ + +import { toast } from 'sonner'; +import { useTranslations } from 'next-intl'; +import { getErrorI18nKey, DEFAULT_ERROR_KEY } from './error-code-map'; + +/** + * Toast 消息参数类型 + * 支持字符串和数字类型的插值变量 + */ +export type ToastParams = Record; + +/** + * Toast 消息 Hook 返回类型 + */ +export interface ToastMessages { + /** + * 显示成功消息 + * @param key - i18n 消息键 + * @param params - 插值参数 + * @param toastId - 可选的 toast ID(用于替换或关闭) + */ + success: (key: string, params?: ToastParams, toastId?: string) => void; + + /** + * 显示错误消息 + * @param key - i18n 消息键 + * @param params - 插值参数 + * @param toastId - 可选的 toast ID(用于替换或关闭) + */ + error: (key: string, params?: ToastParams, toastId?: string) => void; + + /** + * 根据错误码显示错误消息 + * @param code - 后端返回的错误码 + * @param fallbackKey - 未知错误码的回退键(默认 'errors.unknown') + * @param toastId - 可选的 toast ID(用于替换或关闭) + */ + errorFromCode: (code: string | null, fallbackKey?: string, toastId?: string) => void; + + /** + * 显示加载消息 + * @param key - i18n 消息键 + * @param params - 插值参数 + * @param toastId - toast ID(用于后续关闭) + */ + loading: (key: string, params?: ToastParams, toastId?: string) => void; + + /** + * 显示警告消息 + * @param key - i18n 消息键 + * @param params - 插值参数 + * @param toastId - 可选的 toast ID(用于替换或关闭) + */ + warning: (key: string, params?: ToastParams, toastId?: string) => void; + + /** + * 关闭指定 toast + * @param toastId - toast ID + */ + dismiss: (toastId: string) => void; +} + +/** + * i18n 感知的 Toast 消息 Hook + * + * 提供统一的 toast 消息显示接口,自动处理 i18n 翻译和参数插值。 + * + * @returns ToastMessages 对象,包含 success、error、errorFromCode、loading、warning、dismiss 方法 + * + * @example + * ```tsx + * function DeleteButton({ item }) { + * const toastMessages = useToastMessages(); + * const { mutate: deleteItem } = useDeleteItem(); + * + * const handleDelete = () => { + * toastMessages.loading('toast.item.delete.loading', {}, 'delete-item'); + * + * deleteItem(item.id, { + * onSuccess: () => { + * toastMessages.dismiss('delete-item'); + * toastMessages.success('toast.item.delete.success', { name: item.name }); + * }, + * onError: (error) => { + * toastMessages.dismiss('delete-item'); + * toastMessages.errorFromCode(getErrorCode(error.response?.data)); + * } + * }); + * }; + * + * return ; + * } + * ``` + */ +export function useToastMessages(): ToastMessages { + // 使用根命名空间,允许访问所有翻译键 + const t = useTranslations(); + + return { + success: (key: string, params?: ToastParams, toastId?: string) => { + const message = t(key, params); + if (toastId) { + toast.success(message, { id: toastId }); + } else { + toast.success(message); + } + }, + + error: (key: string, params?: ToastParams, toastId?: string) => { + const message = t(key, params); + if (toastId) { + toast.error(message, { id: toastId }); + } else { + toast.error(message); + } + }, + + errorFromCode: (code: string | null, fallbackKey = DEFAULT_ERROR_KEY, toastId?: string) => { + const errorKey = code ? getErrorI18nKey(code) : fallbackKey; + const message = t(errorKey); + if (toastId) { + toast.error(message, { id: toastId }); + } else { + toast.error(message); + } + }, + + loading: (key: string, params?: ToastParams, toastId?: string) => { + const message = t(key, params); + if (toastId) { + toast.loading(message, { id: toastId }); + } else { + toast.loading(message); + } + }, + + warning: (key: string, params?: ToastParams, toastId?: string) => { + const message = t(key, params); + if (toastId) { + toast.warning(message, { id: toastId }); + } else { + toast.warning(message); + } + }, + + dismiss: (toastId: string) => { + toast.dismiss(toastId); + }, + }; +} + +/** + * 非 Hook 版本的 toast 辅助函数 + * + * 用于不在 React 组件中的场景(如 API 拦截器)。 + * 注意:这些函数不支持 i18n,只能显示原始字符串。 + * + * @example + * ```ts + * // 在 API 拦截器中使用 + * apiClient.interceptors.response.use( + * (response) => response, + * (error) => { + * if (error.response?.status === 401) { + * showToast.error('Session expired, please login again'); + * } + * return Promise.reject(error); + * } + * ); + * ``` + */ +export const showToast = { + success: (message: string, toastId?: string) => { + if (toastId) { + toast.success(message, { id: toastId }); + } else { + toast.success(message); + } + }, + + error: (message: string, toastId?: string) => { + if (toastId) { + toast.error(message, { id: toastId }); + } else { + toast.error(message); + } + }, + + loading: (message: string, toastId?: string) => { + if (toastId) { + toast.loading(message, { id: toastId }); + } else { + toast.loading(message); + } + }, + + warning: (message: string, toastId?: string) => { + if (toastId) { + toast.warning(message, { id: toastId }); + } else { + toast.warning(message); + } + }, + + dismiss: (toastId: string) => { + toast.dismiss(toastId); + }, +}; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b3204251..79a70f43 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -209,7 +209,18 @@ "active": "Active", "inactive": "Inactive", "enabled": "Enabled", - "disabled": "Disabled" + "disabled": "Disabled", + "creating": "Creating...", + "updating": "Updating...", + "deleting": "Deleting...", + "removing": "Removing...", + "unlinking": "Unlinking...", + "batchCreating": "Batch creating...", + "batchDeleting": "Batch deleting...", + "batchRemoving": "Batch removing...", + "updateSuccess": "Update successful", + "updateFailed": "Update failed", + "uploading": "Uploading..." }, "pagination": { "page": "Page {current} of {total}", @@ -1225,7 +1236,286 @@ "invalidJsonFile": "Invalid JSON file", "importSuccess": "Import successful", "importFailed": "Import failed", - "loadScanHistoryFailed": "Failed to load scan history" + "loadScanHistoryFailed": "Failed to load scan history", + "scan": { + "quick": { + "success": "Started {count} scan tasks", + "error": "Failed to start scan" + }, + "stop": { + "success": "Scan stopped, {count} tasks cancelled" + }, + "delete": { + "success": "Deleted scan task: {name}", + "bulkSuccess": "Deleted {count} scan tasks" + }, + "initiate": { + "success": "Scan initiated successfully", + "error": "Failed to initiate scan" + } + }, + "scheduledScan": { + "create": { + "success": "Scheduled scan created successfully", + "error": "Failed to create scheduled scan" + }, + "update": { + "success": "Scheduled scan updated successfully", + "error": "Failed to update scheduled scan" + }, + "delete": { + "success": "Scheduled scan deleted successfully", + "error": "Failed to delete scheduled scan" + }, + "toggle": { + "enabled": "Scheduled scan enabled", + "disabled": "Scheduled scan disabled", + "error": "Failed to toggle scheduled scan" + } + }, + "target": { + "create": { + "success": "Target created successfully", + "bulkSuccess": "Created {count} targets", + "error": "Failed to create target" + }, + "update": { + "success": "Target updated successfully", + "error": "Failed to update target" + }, + "delete": { + "success": "Target \"{name}\" deleted", + "bulkSuccess": "Deleted {count} targets", + "error": "Failed to delete target" + }, + "link": { + "success": "Target linked to organization", + "error": "Failed to link target" + }, + "unlink": { + "success": "Target unlinked from organization", + "bulkSuccess": "Unlinked {count} targets", + "error": "Failed to unlink target" + } + }, + "organization": { + "create": { + "success": "Organization created successfully", + "error": "Failed to create organization" + }, + "update": { + "success": "Organization updated successfully", + "error": "Failed to update organization" + }, + "delete": { + "success": "Organization \"{name}\" deleted", + "bulkSuccess": "Deleted {count} organizations", + "error": "Failed to delete organization" + } + }, + "asset": { + "subdomain": { + "create": { + "success": "Created {count} subdomains", + "partialSuccess": "Created {success} subdomains, skipped {skipped}", + "error": "Failed to create subdomains" + }, + "delete": { + "success": "Subdomain deleted", + "bulkSuccess": "Deleted {count} subdomains", + "error": "Failed to delete subdomain" + } + }, + "website": { + "create": { + "success": "Created {count} websites", + "partialSuccess": "Created {success} websites, skipped {skipped}", + "error": "Failed to create websites" + }, + "delete": { + "success": "Website deleted", + "bulkSuccess": "Deleted {count} websites", + "error": "Failed to delete website" + } + }, + "endpoint": { + "create": { + "success": "Created {count} endpoints", + "partialSuccess": "Created {success} endpoints, skipped {skipped}", + "error": "Failed to create endpoints" + }, + "delete": { + "success": "Endpoint deleted", + "bulkSuccess": "Deleted {count} endpoints", + "error": "Failed to delete endpoint" + } + }, + "directory": { + "create": { + "success": "Created {count} directories", + "partialSuccess": "Created {success} directories, skipped {skipped}", + "error": "Failed to create directories" + }, + "delete": { + "success": "Directory deleted", + "bulkSuccess": "Deleted {count} directories", + "error": "Failed to delete directory" + } + } + }, + "auth": { + "login": { + "success": "Login successful" + }, + "logout": { + "success": "Logged out" + }, + "changePassword": { + "success": "Password changed successfully", + "error": "Failed to change password" + } + }, + "worker": { + "create": { + "success": "Worker created successfully", + "error": "Failed to create worker" + }, + "update": { + "success": "Worker updated successfully", + "error": "Failed to update worker" + }, + "delete": { + "success": "Worker deleted successfully", + "error": "Failed to delete worker" + }, + "deploy": { + "success": "Worker deployment started", + "error": "Failed to deploy worker" + }, + "restart": { + "success": "Worker is restarting", + "error": "Failed to restart worker" + }, + "stop": { + "success": "Worker stopped", + "error": "Failed to stop worker" + } + }, + "nucleiRepo": { + "create": { + "success": "Repository added successfully", + "error": "Failed to add repository" + }, + "update": { + "success": "Repository updated successfully", + "error": "Failed to update repository" + }, + "delete": { + "success": "Repository deleted successfully", + "error": "Failed to delete repository" + }, + "sync": { + "success": "Repository synced successfully", + "error": "Failed to sync repository" + } + }, + "wordlist": { + "upload": { + "success": "Wordlist uploaded successfully", + "error": "Failed to upload wordlist" + }, + "update": { + "success": "Wordlist updated successfully", + "error": "Failed to update wordlist" + }, + "delete": { + "success": "Wordlist deleted successfully", + "error": "Failed to delete wordlist" + } + }, + "notification": { + "settings": { + "success": "Notification settings saved", + "error": "Failed to save notification settings" + }, + "markRead": { + "success": "Marked as read", + "error": "Failed to mark as read" + }, + "connection": { + "error": "Notification connection error: {message}" + } + }, + "tool": { + "create": { + "success": "Tool created successfully", + "error": "Failed to create tool" + }, + "update": { + "success": "Tool updated successfully", + "error": "Failed to update tool" + }, + "delete": { + "success": "Tool deleted successfully", + "error": "Failed to delete tool" + } + }, + "engine": { + "create": { + "success": "Engine created successfully", + "error": "Failed to create engine" + }, + "update": { + "success": "Engine updated successfully", + "error": "Failed to update engine" + }, + "delete": { + "success": "Engine deleted successfully", + "error": "Failed to delete engine" + } + }, + "command": { + "create": { + "success": "Command created successfully", + "error": "Failed to create command" + }, + "update": { + "success": "Command updated successfully", + "error": "Failed to update command" + }, + "delete": { + "success": "Command deleted successfully", + "bulkSuccess": "Deleted {count} commands", + "error": "Failed to delete command" + } + }, + "nucleiTemplate": { + "refresh": { + "loading": "Syncing templates...", + "success": "Templates synced successfully", + "error": "Failed to sync templates" + }, + "upload": { + "success": "Template uploaded successfully", + "error": "Failed to upload template" + }, + "save": { + "success": "Template saved successfully", + "error": "Failed to save template" + } + }, + "nucleiGit": { + "settings": { + "success": "Git settings saved successfully", + "error": "Failed to save Git settings" + } + }, + "systemLog": { + "fetch": { + "error": "Failed to fetch system logs, please check backend", + "recovered": "System log connection recovered" + } + } }, "quickScan": { "title": "Quick Scan", @@ -1488,5 +1778,16 @@ "bulkAdd": "Bulk Add", "formatInvalid": "Invalid format" } + }, + "errors": { + "unknown": "Operation failed, please try again later", + "validation": "Invalid input data", + "notFound": "Resource not found", + "permissionDenied": "Permission denied", + "serverError": "Server error, please try again later", + "badRequest": "Invalid request format", + "conflict": "Resource conflict, please check and try again", + "unauthorized": "Please login first", + "rateLimited": "Too many requests, please try again later" } } diff --git a/frontend/messages/zh.json b/frontend/messages/zh.json index c07502f1..315f52bd 100644 --- a/frontend/messages/zh.json +++ b/frontend/messages/zh.json @@ -209,7 +209,18 @@ "active": "活跃", "inactive": "未激活", "enabled": "已启用", - "disabled": "已禁用" + "disabled": "已禁用", + "creating": "正在创建...", + "updating": "正在更新...", + "deleting": "正在删除...", + "removing": "正在移除...", + "unlinking": "正在解除关联...", + "batchCreating": "正在批量创建...", + "batchDeleting": "正在批量删除...", + "batchRemoving": "正在批量移除...", + "updateSuccess": "更新成功", + "updateFailed": "更新失败", + "uploading": "正在上传..." }, "pagination": { "page": "第 {current} 页,共 {total} 页", @@ -1225,7 +1236,286 @@ "invalidJsonFile": "无效的 JSON 文件", "importSuccess": "导入成功", "importFailed": "导入失败", - "loadScanHistoryFailed": "加载扫描历史失败" + "loadScanHistoryFailed": "加载扫描历史失败", + "scan": { + "quick": { + "success": "已启动 {count} 个扫描任务", + "error": "启动扫描失败" + }, + "stop": { + "success": "扫描已停止,已撤销 {count} 个任务" + }, + "delete": { + "success": "已删除扫描任务: {name}", + "bulkSuccess": "已删除 {count} 个扫描任务" + }, + "initiate": { + "success": "扫描发起成功", + "error": "发起扫描失败" + } + }, + "scheduledScan": { + "create": { + "success": "定时扫描创建成功", + "error": "创建定时扫描失败" + }, + "update": { + "success": "定时扫描更新成功", + "error": "更新定时扫描失败" + }, + "delete": { + "success": "定时扫描删除成功", + "error": "删除定时扫描失败" + }, + "toggle": { + "enabled": "定时扫描已启用", + "disabled": "定时扫描已禁用", + "error": "切换定时扫描状态失败" + } + }, + "target": { + "create": { + "success": "目标创建成功", + "bulkSuccess": "已创建 {count} 个目标", + "error": "创建目标失败" + }, + "update": { + "success": "目标更新成功", + "error": "更新目标失败" + }, + "delete": { + "success": "目标 \"{name}\" 已删除", + "bulkSuccess": "已删除 {count} 个目标", + "error": "删除目标失败" + }, + "link": { + "success": "目标已关联到组织", + "error": "关联目标失败" + }, + "unlink": { + "success": "目标已从组织解除关联", + "bulkSuccess": "已解除 {count} 个目标的关联", + "error": "解除关联失败" + } + }, + "organization": { + "create": { + "success": "组织创建成功", + "error": "创建组织失败" + }, + "update": { + "success": "组织更新成功", + "error": "更新组织失败" + }, + "delete": { + "success": "组织 \"{name}\" 已删除", + "bulkSuccess": "已删除 {count} 个组织", + "error": "删除组织失败" + } + }, + "asset": { + "subdomain": { + "create": { + "success": "已创建 {count} 个子域名", + "partialSuccess": "已创建 {success} 个子域名,跳过 {skipped} 个", + "error": "创建子域名失败" + }, + "delete": { + "success": "子域名已删除", + "bulkSuccess": "已删除 {count} 个子域名", + "error": "删除子域名失败" + } + }, + "website": { + "create": { + "success": "已创建 {count} 个网站", + "partialSuccess": "已创建 {success} 个网站,跳过 {skipped} 个", + "error": "创建网站失败" + }, + "delete": { + "success": "网站已删除", + "bulkSuccess": "已删除 {count} 个网站", + "error": "删除网站失败" + } + }, + "endpoint": { + "create": { + "success": "已创建 {count} 个端点", + "partialSuccess": "已创建 {success} 个端点,跳过 {skipped} 个", + "error": "创建端点失败" + }, + "delete": { + "success": "端点已删除", + "bulkSuccess": "已删除 {count} 个端点", + "error": "删除端点失败" + } + }, + "directory": { + "create": { + "success": "已创建 {count} 个目录", + "partialSuccess": "已创建 {success} 个目录,跳过 {skipped} 个", + "error": "创建目录失败" + }, + "delete": { + "success": "目录已删除", + "bulkSuccess": "已删除 {count} 个目录", + "error": "删除目录失败" + } + } + }, + "auth": { + "login": { + "success": "登录成功" + }, + "logout": { + "success": "已登出" + }, + "changePassword": { + "success": "密码修改成功", + "error": "修改密码失败" + } + }, + "worker": { + "create": { + "success": "节点创建成功", + "error": "创建节点失败" + }, + "update": { + "success": "节点更新成功", + "error": "更新节点失败" + }, + "delete": { + "success": "节点删除成功", + "error": "删除节点失败" + }, + "deploy": { + "success": "节点部署已启动", + "error": "部署节点失败" + }, + "restart": { + "success": "节点正在重启", + "error": "重启节点失败" + }, + "stop": { + "success": "节点已停止", + "error": "停止节点失败" + } + }, + "nucleiRepo": { + "create": { + "success": "仓库添加成功", + "error": "添加仓库失败" + }, + "update": { + "success": "仓库更新成功", + "error": "更新仓库失败" + }, + "delete": { + "success": "仓库删除成功", + "error": "删除仓库失败" + }, + "sync": { + "success": "仓库同步成功", + "error": "同步仓库失败" + } + }, + "wordlist": { + "upload": { + "success": "字典上传成功", + "error": "上传字典失败" + }, + "update": { + "success": "字典更新成功", + "error": "更新字典失败" + }, + "delete": { + "success": "字典删除成功", + "error": "删除字典失败" + } + }, + "notification": { + "settings": { + "success": "通知设置已保存", + "error": "保存通知设置失败" + }, + "markRead": { + "success": "已标记为已读", + "error": "标记已读失败" + }, + "connection": { + "error": "通知连接错误: {message}" + } + }, + "tool": { + "create": { + "success": "工具创建成功", + "error": "创建工具失败" + }, + "update": { + "success": "工具更新成功", + "error": "更新工具失败" + }, + "delete": { + "success": "工具删除成功", + "error": "删除工具失败" + } + }, + "engine": { + "create": { + "success": "引擎创建成功", + "error": "创建引擎失败" + }, + "update": { + "success": "引擎更新成功", + "error": "更新引擎失败" + }, + "delete": { + "success": "引擎删除成功", + "error": "删除引擎失败" + } + }, + "command": { + "create": { + "success": "命令创建成功", + "error": "创建命令失败" + }, + "update": { + "success": "命令更新成功", + "error": "更新命令失败" + }, + "delete": { + "success": "命令删除成功", + "bulkSuccess": "已删除 {count} 个命令", + "error": "删除命令失败" + } + }, + "nucleiTemplate": { + "refresh": { + "loading": "正在同步模板...", + "success": "模板同步成功", + "error": "同步模板失败" + }, + "upload": { + "success": "模板上传成功", + "error": "上传模板失败" + }, + "save": { + "success": "模板保存成功", + "error": "保存模板失败" + } + }, + "nucleiGit": { + "settings": { + "success": "Git 设置保存成功", + "error": "保存 Git 设置失败" + } + }, + "systemLog": { + "fetch": { + "error": "系统日志获取失败,请检查后端接口", + "recovered": "系统日志连接已恢复" + } + } }, "quickScan": { "title": "快速扫描", @@ -1488,5 +1778,16 @@ "bulkAdd": "批量添加", "formatInvalid": "格式无效" } + }, + "errors": { + "unknown": "操作失败,请稍后重试", + "validation": "输入数据无效", + "notFound": "资源未找到", + "permissionDenied": "权限不足", + "serverError": "服务器错误,请稍后重试", + "badRequest": "请求格式错误", + "conflict": "资源冲突,请检查后重试", + "unauthorized": "请先登录", + "rateLimited": "请求过于频繁,请稍后重试" } }