From b3d402522a07683ed9dbc5cecc493dbfc2158daf Mon Sep 17 00:00:00 2001 From: vp01273 Date: Fri, 4 Jul 2025 11:00:03 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0API=20Key?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85=E6=8B=AC?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E3=80=81=E6=9B=B4=E6=96=B0=E3=80=81=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=92=8C=E5=88=97=E5=87=BAAPI=20Key=E7=9A=84=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=8F=8A=E5=AE=A1=E8=AE=A1=E6=97=A5=E5=BF=97=E8=AE=B0?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bisheng/api/services/api_key_auth.py | 66 ++ src/backend/bisheng/api/services/audit_log.py | 66 ++ .../bisheng/api/services/user_service.py | 25 +- src/backend/bisheng/api/v1/chat.py | 2 +- src/backend/bisheng/api/v1/user.py | 143 ++++ .../bisheng/database/models/api_key.py | 172 +++++ .../bisheng/database/models/audit_log.py | 5 + .../platform/public/locales/en/bs.json | 46 +- .../platform/public/locales/zh/bs.json | 46 +- .../platform/src/controllers/API/user.ts | 30 + .../platform/src/layout/MainLayout.tsx | 8 +- .../platform/src/pages/LogPage/utils/index.ts | 4 + .../pages/SystemPage/components/ApiKey.tsx | 613 ++++++++++++++++++ src/frontend/platform/src/routes.tsx | 2 + 14 files changed, 1215 insertions(+), 13 deletions(-) create mode 100644 src/backend/bisheng/api/services/api_key_auth.py create mode 100644 src/backend/bisheng/database/models/api_key.py create mode 100644 src/frontend/platform/src/pages/SystemPage/components/ApiKey.tsx diff --git a/src/backend/bisheng/api/services/api_key_auth.py b/src/backend/bisheng/api/services/api_key_auth.py new file mode 100644 index 000000000..c8a49c51a --- /dev/null +++ b/src/backend/bisheng/api/services/api_key_auth.py @@ -0,0 +1,66 @@ +from fastapi import Request +from typing import Optional +import threading + +from bisheng.database.models.api_key import ApiKeyDao +from bisheng.database.models.user import UserDao + + +class ApiKeyAuth: + """API Key认证类""" + + @staticmethod + def get_api_key_from_request(request: Request) -> Optional[str]: + """从请求中提取API Key""" + # 优先从Header中获取 + api_key = request.headers.get("X-API-Key") or request.headers.get("Authorization") + + if api_key and api_key.startswith("Bearer "): + api_key = api_key[7:] # 移除 "Bearer " 前缀 + + # 如果Header中没有,从查询参数中获取 + if not api_key: + api_key = request.query_params.get("api_key") + + return api_key + + @staticmethod + def authenticate_api_key(api_key: str) -> Optional[dict]: + """验证API Key并返回用户信息""" + if not api_key: + return None + + # 验证API Key + api_key_obj = ApiKeyDao.validate_api_key(api_key) + if not api_key_obj: + return None + + # 获取用户信息 + user = UserDao.get_user(api_key_obj.user_id) + if not user or user.delete == 1: # 用户不存在或被禁用 + return None + + # 异步更新使用统计 + threading.Thread(target=ApiKeyDao.update_usage, args=(api_key_obj.id,)).start() + + # 构造用户信息 + from bisheng.api.services.user_service import gen_user_role + + role, _ = gen_user_role(user) + + return { + "user_name": user.user_name, + "user_id": user.user_id, + "role": role + } + + +def get_current_user_by_api_key(request: Request) -> Optional[dict]: + """通过API Key获取当前用户""" + api_key = ApiKeyAuth.get_api_key_from_request(request) + if not api_key: + return None + + return ApiKeyAuth.authenticate_api_key(api_key) + + diff --git a/src/backend/bisheng/api/services/audit_log.py b/src/backend/bisheng/api/services/audit_log.py index 70677876e..433d101ce 100644 --- a/src/backend/bisheng/api/services/audit_log.py +++ b/src/backend/bisheng/api/services/audit_log.py @@ -563,3 +563,69 @@ def get_chat_messages(cls, chat_list: List[AppChatList]) -> List[AppChatList]: chat.messages = [message for message in chat_messages if message.category != WorkflowEventType.UserInput.value] return chat_list + + @staticmethod + def create_api_key(user: UserPayload, ip: str, api_key): + """记录创建API Key的审计日志""" + logger.info(f"act=create_api_key user={user.user_name} ip={ip} key_name={api_key.key_name}") + + # 获取用户所属的分组 + user_groups = UserGroupDao.get_user_group(user.user_id) + group_ids = [one.group_id for one in user_groups] + + audit_log = AuditLog( + operator_id=user.user_id, + operator_name=user.user_name, + group_ids=group_ids, + system_id=SystemId.SYSTEM.value, + event_type=EventType.CREATE_API_KEY.value, + object_type=ObjectType.API_KEY_CONF.value, + object_id=str(api_key.id), + object_name=api_key.key_name, + ip_address=ip, + ) + AuditLogDao.insert_audit_logs([audit_log]) + + @staticmethod + def update_api_key(user: UserPayload, ip: str, api_key): + """记录更新API Key的审计日志""" + logger.info(f"act=update_api_key user={user.user_name} ip={ip} key_name={api_key.key_name}") + + # 获取用户所属的分组 + user_groups = UserGroupDao.get_user_group(user.user_id) + group_ids = [one.group_id for one in user_groups] + + audit_log = AuditLog( + operator_id=user.user_id, + operator_name=user.user_name, + group_ids=group_ids, + system_id=SystemId.SYSTEM.value, + event_type=EventType.UPDATE_API_KEY.value, + object_type=ObjectType.API_KEY_CONF.value, + object_id=str(api_key.id), + object_name=api_key.key_name, + ip_address=ip, + ) + AuditLogDao.insert_audit_logs([audit_log]) + + @staticmethod + def delete_api_key(user: UserPayload, ip: str, api_key): + """记录删除API Key的审计日志""" + logger.info(f"act=delete_api_key user={user.user_name} ip={ip} key_name={api_key.key_name}") + + # 获取用户所属的分组 + user_groups = UserGroupDao.get_user_group(user.user_id) + group_ids = [one.group_id for one in user_groups] + + audit_log = AuditLog( + operator_id=user.user_id, + operator_name=user.user_name, + group_ids=group_ids, + system_id=SystemId.SYSTEM.value, + event_type=EventType.DELETE_API_KEY.value, + object_type=ObjectType.API_KEY_CONF.value, + object_id=str(api_key.id), + object_name=api_key.key_name, + ip_address=ip, + ) + AuditLogDao.insert_audit_logs([audit_log]) diff --git a/src/backend/bisheng/api/services/user_service.py b/src/backend/bisheng/api/services/user_service.py index 12dec24d5..261765ca9 100644 --- a/src/backend/bisheng/api/services/user_service.py +++ b/src/backend/bisheng/api/services/user_service.py @@ -8,6 +8,7 @@ from bisheng.api.errcode.user import (UserLoginOfflineError, UserNameAlreadyExistError, UserNeedGroupAndRoleError) from bisheng.api.JWT import ACCESS_TOKEN_EXPIRE_TIME +from bisheng.api.services.api_key_auth import ApiKeyAuth from bisheng.api.utils import md5_hash from bisheng.api.v1.schemas import CreateUserReq from bisheng.cache.redis import redis_client @@ -285,16 +286,22 @@ def get_assistant_list_by_access(role_id: int, name: str, page_num: int, page_si } -async def get_login_user(authorize: AuthJWT = Depends()) -> UserPayload: +async def get_login_user(request: Request, authorize: AuthJWT = Depends()) -> UserPayload: """ 获取当前登录的用户 """ - # 校验是否过期,过期则直接返回http 状态码的 401 - authorize.jwt_required() - - current_user = json.loads(authorize.get_jwt_subject()) - user = UserPayload(**current_user) - + # 获取API Key + api_key = ApiKeyAuth.get_api_key_from_request(request) + user = None + if api_key: + user = ApiKeyAuth.authenticate_api_key(api_key) + user = UserPayload(**user) if user else None + if not user or not api_key: + # 校验是否过期,过期则直接返回http 状态码的 401 + authorize.jwt_required() + + current_user = json.loads(authorize.get_jwt_subject()) + user = UserPayload(**current_user) # 判断是否允许多点登录 if not settings.get_system_login_method().allow_multi_login: # 获取access_token @@ -305,11 +312,11 @@ async def get_login_user(authorize: AuthJWT = Depends()) -> UserPayload: return user -async def get_admin_user(authorize: AuthJWT = Depends()) -> UserPayload: +async def get_admin_user(request: Request, authorize: AuthJWT = Depends()) -> UserPayload: """ 获取超级管理账号,非超级管理员用户,抛出异常 """ - login_user = await get_login_user(authorize) + login_user = await get_login_user(request, authorize) if not login_user.is_admin(): raise UnAuthorizedError.http_exception() return login_user diff --git a/src/backend/bisheng/api/v1/chat.py b/src/backend/bisheng/api/v1/chat.py index 16717541a..ffd65a704 100644 --- a/src/backend/bisheng/api/v1/chat.py +++ b/src/backend/bisheng/api/v1/chat.py @@ -502,7 +502,7 @@ async def chat( Authorize._token = t else: Authorize.jwt_required(auth_from='websocket', websocket=websocket) - login_user = await get_login_user(Authorize) + login_user = await get_login_user(websocket, Authorize) user_id = login_user.user_id if chat_id: with session_getter() as session: diff --git a/src/backend/bisheng/api/v1/user.py b/src/backend/bisheng/api/v1/user.py index e60a429ce..c38853dee 100644 --- a/src/backend/bisheng/api/v1/user.py +++ b/src/backend/bisheng/api/v1/user.py @@ -30,6 +30,7 @@ from bisheng.cache.redis import redis_client from bisheng.database.base import session_getter +from bisheng.database.models.api_key import ApiKeyDao, ApiKeyCreate, ApiKeyRead, ApiKeyUpdate from bisheng.database.models.group import GroupDao from bisheng.database.models.role import Role, RoleCreate, RoleDao, RoleUpdate from bisheng.database.constants import AdminRole, DefaultRole @@ -824,3 +825,145 @@ def md5_hash(string): md5 = hashlib.md5() md5.update(string.encode('utf-8')) return md5.hexdigest() + + +@router.post('/user/api_key/create', status_code=200) +async def create_api_key(*, + request: Request, + api_key_req: ApiKeyCreate, + login_user: UserPayload = Depends(get_login_user)): + """ + 创建API Key + """ + try: + # 检查用户是否已有太多API Key(可选限制) + existing_keys = ApiKeyDao.get_user_api_keys(login_user.user_id) + if len(existing_keys) >= 10: # 限制每个用户最多10个API Key + raise HTTPException(status_code=400, detail='API Key数量已达上限') + + # 创建API Key + api_key = ApiKeyDao.create_api_key(login_user.user_id, api_key_req) + + # 记录审计日志 + AuditLogService.create_api_key(login_user, get_request_ip(request), api_key) + + # 返回完整的API Key(只在创建时返回一次) + return resp_200({ + 'id': api_key.id, + 'key_name': api_key.key_name, + 'api_key': api_key.api_key, # 完整的key + 'expires_at': api_key.expires_at, + 'create_time': api_key.create_time + }) + except Exception as e: + logger.exception(f'create_api_key error: {e}') + raise HTTPException(status_code=500, detail='创建API Key失败') + + +@router.get('/user/api_key/list', status_code=200) +async def list_api_keys(login_user: UserPayload = Depends(get_login_user)): + """ + 获取用户的API Key列表 + """ + try: + api_keys = ApiKeyDao.get_user_api_keys(login_user.user_id) + result = [] + for key in api_keys: + key_read = ApiKeyRead(**key.__dict__) + key_read.mask_api_key() # 遮盖API Key + result.append(key_read.__dict__) + + return resp_200(result) + except Exception as e: + logger.exception(f'list_api_keys error: {e}') + raise HTTPException(status_code=500, detail='获取API Key列表失败') + + +@router.patch('/user/api_key/{api_key_id}', status_code=200) +async def update_api_key(*, + request: Request, + api_key_id: int, + api_key_update: ApiKeyUpdate, + login_user: UserPayload = Depends(get_login_user)): + """ + 更新API Key + """ + try: + # 检查API Key是否属于当前用户 + existing_key = ApiKeyDao.get_api_key_by_id(api_key_id) + if not existing_key or existing_key.user_id != login_user.user_id: + raise HTTPException(status_code=404, detail='API Key不存在') + + # 更新API Key + updated_key = ApiKeyDao.update_api_key(api_key_id, api_key_update) + if not updated_key: + raise HTTPException(status_code=404, detail='API Key不存在') + + # 记录审计日志 + AuditLogService.update_api_key(login_user, get_request_ip(request), updated_key) + + # 返回遮盖后的结果 + key_read = ApiKeyRead(**updated_key.__dict__) + key_read.mask_api_key() + return resp_200(key_read.__dict__) + except HTTPException: + raise + except Exception as e: + logger.exception(f'update_api_key error: {e}') + raise HTTPException(status_code=500, detail='更新API Key失败') + + +@router.delete('/user/api_key/{api_key_id}', status_code=200) +async def delete_api_key(*, + request: Request, + api_key_id: int, + login_user: UserPayload = Depends(get_login_user)): + """ + 删除API Key + """ + try: + # 检查API Key是否属于当前用户 + existing_key = ApiKeyDao.get_api_key_by_id(api_key_id) + if not existing_key or existing_key.user_id != login_user.user_id: + raise HTTPException(status_code=404, detail='API Key不存在') + + # 删除API Key + success = ApiKeyDao.delete_api_key(api_key_id) + if not success: + raise HTTPException(status_code=404, detail='API Key不存在') + + # 记录审计日志 + AuditLogService.delete_api_key(login_user, get_request_ip(request), existing_key) + + return resp_200() + except HTTPException: + raise + except Exception as e: + logger.exception(f'delete_api_key error: {e}') + raise HTTPException(status_code=500, detail='删除API Key失败') + + +@router.get('/user/api_key/{api_key_id}/usage', status_code=200) +async def get_api_key_usage(*, + api_key_id: int, + login_user: UserPayload = Depends(get_login_user)): + """ + 获取API Key使用统计 + """ + try: + # 检查API Key是否属于当前用户 + api_key = ApiKeyDao.get_api_key_by_id(api_key_id) + if not api_key or api_key.user_id != login_user.user_id: + raise HTTPException(status_code=404, detail='API Key不存在') + + return resp_200({ + 'total_uses': api_key.total_uses, + 'last_used_at': api_key.last_used_at, + 'is_active': api_key.is_active, + 'expires_at': api_key.expires_at + }) + except HTTPException: + raise + except Exception as e: + logger.exception(f'get_api_key_usage error: {e}') + raise HTTPException(status_code=500, detail='获取API Key使用统计失败') diff --git a/src/backend/bisheng/database/models/api_key.py b/src/backend/bisheng/database/models/api_key.py new file mode 100644 index 000000000..1d4cde315 --- /dev/null +++ b/src/backend/bisheng/database/models/api_key.py @@ -0,0 +1,172 @@ +from datetime import datetime +from typing import Optional +from sqlalchemy import Column, DateTime, text +from sqlmodel import Field, select, delete + +from bisheng.database.base import session_getter +from bisheng.database.models.base import SQLModelSerializable +import secrets +import string + + +class ApiKeyBase(SQLModelSerializable): + key_name: str = Field(index=True, description='API Key名称') + api_key: str = Field(index=True, unique=True, description='API Key值') + user_id: int = Field(index=True, description='用户ID') + is_active: bool = Field(default=True, description='是否启用') + last_used_at: Optional[datetime] = Field(default=None, nullable=True, description='最后使用时间') + total_uses: int = Field(default=0, description='使用次数') + expires_at: Optional[datetime] = Field(default=None, nullable=True, description='过期时间') + remark: Optional[str] = Field(default=None, description='备注') + create_time: Optional[datetime] = Field(default=None, sa_column=Column( + DateTime, nullable=False, index=True, server_default=text('CURRENT_TIMESTAMP'))) + update_time: Optional[datetime] = Field(default=None, sa_column=Column( + DateTime, nullable=False, server_default=text('CURRENT_TIMESTAMP'), onupdate=text('CURRENT_TIMESTAMP'))) + + +class ApiKey(ApiKeyBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + + +class ApiKeyCreate(SQLModelSerializable): + key_name: str + expires_at: Optional[datetime] = None + remark: Optional[str] = None + + +class ApiKeyRead(SQLModelSerializable): + id: int + key_name: str + api_key: str # 会被mask处理 + user_id: int + is_active: bool + last_used_at: Optional[datetime] + total_uses: int + expires_at: Optional[datetime] + remark: Optional[str] + create_time: datetime + update_time: datetime + + def mask_api_key(self): + """遮盖API Key,只显示前8位""" + if len(self.api_key) > 8: + self.api_key = f"{self.api_key[:8]}{'*' * (len(self.api_key) - 8)}" + return self + + +class ApiKeyUpdate(SQLModelSerializable): + key_name: Optional[str] = None + is_active: Optional[bool] = None + expires_at: Optional[datetime] = None + remark: Optional[str] = None + + +class ApiKeyDao: + + @classmethod + def generate_api_key(cls) -> str: + """生成API Key""" + # 生成前缀 + 32位随机字符 + prefix = "bsk_" # bisheng key + random_part = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(32)) + return f"{prefix}{random_part}" + + @classmethod + def create_api_key(cls, user_id: int, api_key_create: ApiKeyCreate) -> ApiKey: + """创建API Key""" + api_key_value = cls.generate_api_key() + + api_key = ApiKey( + key_name=api_key_create.key_name, + api_key=api_key_value, + user_id=user_id, + expires_at=api_key_create.expires_at, + remark=api_key_create.remark + ) + + with session_getter() as session: + session.add(api_key) + session.commit() + session.refresh(api_key) + return api_key + + @classmethod + def get_user_api_keys(cls, user_id: int) -> list[ApiKey]: + """获取用户的所有API Key""" + with session_getter() as session: + statement = select(ApiKey).where(ApiKey.user_id == user_id).order_by(ApiKey.create_time.desc()) + return session.exec(statement).all() + + @classmethod + def get_api_key_by_value(cls, api_key_value: str) -> Optional[ApiKey]: + """根据API Key值获取记录""" + with session_getter() as session: + statement = select(ApiKey).where(ApiKey.api_key == api_key_value, ApiKey.is_active == True) + return session.exec(statement).first() + + @classmethod + def get_api_key_by_id(cls, api_key_id: int) -> Optional[ApiKey]: + """根据ID获取API Key""" + with session_getter() as session: + statement = select(ApiKey).where(ApiKey.id == api_key_id) + return session.exec(statement).first() + + @classmethod + def update_api_key(cls, api_key_id: int, api_key_update: ApiKeyUpdate) -> Optional[ApiKey]: + """更新API Key""" + with session_getter() as session: + api_key = session.get(ApiKey, api_key_id) + if not api_key: + return None + + if api_key_update.key_name is not None: + api_key.key_name = api_key_update.key_name + if api_key_update.is_active is not None: + api_key.is_active = api_key_update.is_active + if api_key_update.expires_at is not None: + api_key.expires_at = api_key_update.expires_at + if api_key_update.remark is not None: + api_key.remark = api_key_update.remark + + session.add(api_key) + session.commit() + session.refresh(api_key) + return api_key + + @classmethod + def delete_api_key(cls, api_key_id: int) -> bool: + """删除API Key""" + with session_getter() as session: + statement = delete(ApiKey).where(ApiKey.id == api_key_id) + result = session.exec(statement) + session.commit() + return result.rowcount > 0 + + @classmethod + def update_usage(cls, api_key_id: int): + """更新使用统计""" + with session_getter() as session: + api_key = session.get(ApiKey, api_key_id) + if api_key: + api_key.total_uses += 1 + api_key.last_used_at = datetime.now() + session.add(api_key) + session.commit() + + @classmethod + def validate_api_key(cls, api_key_value: str) -> Optional[ApiKey]: + """验证API Key是否有效""" + api_key = cls.get_api_key_by_value(api_key_value) + + if not api_key: + return None + + # 检查是否过期 + if api_key.expires_at and api_key.expires_at < datetime.now(): + return None + + # 检查是否启用 + if not api_key.is_active: + return None + + return api_key diff --git a/src/backend/bisheng/database/models/audit_log.py b/src/backend/bisheng/database/models/audit_log.py index 637a47358..2561664b4 100644 --- a/src/backend/bisheng/database/models/audit_log.py +++ b/src/backend/bisheng/database/models/audit_log.py @@ -43,6 +43,10 @@ class EventType(Enum): USER_LOGIN = "user_login" # 用户登录 + CREATE_API_KEY = "create_api_key" # 创建API Key + UPDATE_API_KEY = "update_api_key" # 更新API Key + DELETE_API_KEY = "delete_api_key" # 删除API Key + # 操作对象类型枚举 class ObjectType(Enum): @@ -55,6 +59,7 @@ class ObjectType(Enum): USER_CONF = "user_conf" # 用户配置 USER_GROUP_CONF = "user_group_conf" # 用户组配置 ROLE_CONF = "role_conf" # 角色配置 + API_KEY_CONF = "api_key_conf" # API Key配置 class AuditLogBase(SQLModelSerializable): diff --git a/src/frontend/platform/public/locales/en/bs.json b/src/frontend/platform/public/locales/en/bs.json index db91ea0ab..1cb1170e8 100644 --- a/src/frontend/platform/public/locales/en/bs.json +++ b/src/frontend/platform/public/locales/en/bs.json @@ -42,7 +42,8 @@ "logoutContent": "Are you sure to log out", "forBestExperience": "For the best experience, please access this website on a PC", "onlineDocumentation": "Online Documentation", - "changePwd": "Password" + "changePwd": "Password", + "apiKeyManagement": "API Key Management" }, "system": { "userManagement": "User Management", @@ -637,6 +638,49 @@ "resetFailed": "Pwd Reset Failed", "notEmpty": "The new password cannot be empty" }, + "apiKey": { + "title": "API Key Management", + "description": "Manage your API Keys for third-party application access", + "createNew": "Create New API Key", + "keyName": "Key Name", + "keyNamePlaceholder": "Enter key name", + "keyNameRequired": "Key name is required", + "expiresAt": "Expires At", + "expiresAtPlaceholder": "Select expiration time (optional)", + "remark": "Remark", + "remarkPlaceholder": "Enter remark (optional)", + "create": "Create", + "created": "Created", + "lastUsed": "Last Used", + "totalUses": "Total Uses", + "status": "Status", + "active": "Active", + "inactive": "Inactive", + "actions": "Actions", + "edit": "Edit", + "delete": "Delete", + "copy": "Copy", + "copyAndClose": "Copy and Close", + "copied": "Copied", + "deleteConfirm": "Are you sure to delete this API Key?", + "deleteSuccess": "Delete successfully", + "createSuccess": "Create successfully", + "updateSuccess": "Update successfully", + "createFailed": "Create failed", + "updateFailed": "Update failed", + "deleteFailed": "Delete failed", + "copySuccess": "API Key copied to clipboard", + "copyFailed": "Copy failed", + "maxKeysReached": "Maximum number of API Keys reached", + "keyCreatedWarning": "API Key will only be displayed once when created, please save it properly", + "neverUsed": "Never used", + "expired": "Expired", + "noExpiration": "Never expires", + "editTitle": "Edit API Key", + "enable": "Enable", + "disable": "Disable", + "statusChanged": "Status changed" + }, "log": { "appUsage": "App Usage", "systemOperations": "System Operations", diff --git a/src/frontend/platform/public/locales/zh/bs.json b/src/frontend/platform/public/locales/zh/bs.json index ddc27e2e4..ef5b2b176 100644 --- a/src/frontend/platform/public/locales/zh/bs.json +++ b/src/frontend/platform/public/locales/zh/bs.json @@ -43,7 +43,8 @@ "logoutContent": "确认退出登录吗", "forBestExperience": "为了您的良好体验,请在 PC 端访问该网站", "onlineDocumentation": "在线文档", - "changePwd": "修改密码" + "changePwd": "修改密码", + "apiKeyManagement": "API Key 管理" }, "system": { "userManagement": "用户管理", @@ -634,6 +635,49 @@ "resetFailed": "密码重置失败", "notEmpty": "新密码不能为空" }, + "apiKey": { + "title": "API Key 管理", + "description": "管理您的 API Key,用于第三方应用访问", + "createNew": "创建新的 API Key", + "keyName": "Key 名称", + "keyNamePlaceholder": "请输入 Key 名称", + "keyNameRequired": "Key 名称不能为空", + "expiresAt": "过期时间", + "expiresAtPlaceholder": "选择过期时间(可选)", + "remark": "备注", + "remarkPlaceholder": "请输入备注信息(可选)", + "create": "创建", + "created": "创建时间", + "lastUsed": "最后使用", + "totalUses": "使用次数", + "status": "状态", + "active": "启用", + "inactive": "禁用", + "actions": "操作", + "edit": "编辑", + "delete": "删除", + "copy": "复制", + "copyAndClose": "复制并关闭", + "copied": "已复制", + "deleteConfirm": "确认删除此 API Key?", + "deleteSuccess": "删除成功", + "createSuccess": "创建成功", + "updateSuccess": "更新成功", + "createFailed": "创建失败", + "updateFailed": "更新失败", + "deleteFailed": "删除失败", + "copySuccess": "API Key 已复制到剪贴板", + "copyFailed": "复制失败", + "maxKeysReached": "API Key 数量已达上限", + "keyCreatedWarning": "API Key 只会在创建时显示一次,请妥善保存", + "neverUsed": "从未使用", + "expired": "已过期", + "noExpiration": "永不过期", + "editTitle": "编辑 API Key", + "enable": "启用", + "disable": "禁用", + "statusChanged": "状态已更改" + }, "log": { "appUsage": "应用使用", "systemOperations": "系统操作", diff --git a/src/frontend/platform/src/controllers/API/user.ts b/src/frontend/platform/src/controllers/API/user.ts index 31ac7b32a..b58c76672 100644 --- a/src/frontend/platform/src/controllers/API/user.ts +++ b/src/frontend/platform/src/controllers/API/user.ts @@ -307,4 +307,34 @@ export async function loggedChangePasswordApi(password, new_password): Promise { + return axios.post(`/api/v1/user/api_key/create`, { + key_name: keyName, + expires_at: expiresAt, + remark + }); +} + +export async function getApiKeysApi(): Promise { + return axios.get(`/api/v1/user/api_key/list`); +} + +export async function updateApiKeyApi(apiKeyId: number, keyName?: string, isActive?: boolean, expiresAt?: string, remark?: string): Promise { + return axios.patch(`/api/v1/user/api_key/${apiKeyId}`, { + key_name: keyName, + is_active: isActive, + expires_at: expiresAt, + remark + }); +} + +export async function deleteApiKeyApi(apiKeyId: number): Promise { + return axios.delete(`/api/v1/user/api_key/${apiKeyId}`); +} + +export async function getApiKeyUsageApi(apiKeyId: number): Promise { + return axios.get(`/api/v1/user/api_key/${apiKeyId}/usage`); } \ No newline at end of file diff --git a/src/frontend/platform/src/layout/MainLayout.tsx b/src/frontend/platform/src/layout/MainLayout.tsx index 2b30f07b1..7bd43324e 100755 --- a/src/frontend/platform/src/layout/MainLayout.tsx +++ b/src/frontend/platform/src/layout/MainLayout.tsx @@ -17,7 +17,7 @@ import { bsConfirm } from "@/components/bs-ui/alertDialog/useConfirm"; import { SelectHover, SelectHoverItem } from "@/components/bs-ui/select/hover"; import { locationContext } from "@/contexts/locationContext"; import i18next from "i18next"; -import { ChevronDown, Globe, Lock, MoonStar, Sun } from "lucide-react"; +import { ChevronDown, Globe, Lock, MoonStar, Sun, Key } from "lucide-react"; import { useContext, useEffect, useMemo, useState } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { useTranslation } from "react-i18next"; @@ -61,6 +61,11 @@ export default function MainLayout() { navigator('/reset') } + // 跳转到API Key管理页面 + const JumpApiKeyPage = () => { + navigator('/apikey') + } + // 系统管理员(超管、组超管) const isAdmin = useMemo(() => { return ['admin', 'group_admin'].includes(user.role) @@ -123,6 +128,7 @@ export default function MainLayout() { }> {t('menu.changePwd')} + {t('menu.apiKeyManagement')} {t('menu.logout')} diff --git a/src/frontend/platform/src/pages/LogPage/utils/index.ts b/src/frontend/platform/src/pages/LogPage/utils/index.ts index e5710a246..5af8ba97c 100644 --- a/src/frontend/platform/src/pages/LogPage/utils/index.ts +++ b/src/frontend/platform/src/pages/LogPage/utils/index.ts @@ -29,6 +29,9 @@ export function transformEvent(event: string): string { case 'delete_role': return '删除角色'; case 'update_role': return '编辑角色'; case 'user_login': return '用户登录'; + case 'create_api_key': return '新建API Key'; + case 'delete_api_key': return '删除API Key'; + case 'update_api_key': return '编辑API Key'; default: return '转换失败' } } @@ -44,6 +47,7 @@ export function transformObjectType(object: string): string { case 'user_conf': return '用户配置' case 'user_group_conf': return '用户组配置' case 'role_conf': return '角色配置' + case 'api_key_conf': return 'API Key配置' default: return '转换失败' } } \ No newline at end of file diff --git a/src/frontend/platform/src/pages/SystemPage/components/ApiKey.tsx b/src/frontend/platform/src/pages/SystemPage/components/ApiKey.tsx new file mode 100644 index 000000000..eb8016fbf --- /dev/null +++ b/src/frontend/platform/src/pages/SystemPage/components/ApiKey.tsx @@ -0,0 +1,613 @@ +import { useToast } from "@/components/bs-ui/toast/use-toast"; +import { ArrowLeft, Key, Copy, Edit, Trash2, Plus, CalendarDays, Clock } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { Button } from "@/components/bs-ui/button"; +import { Input } from "@/components/bs-ui/input"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/bs-ui/dialog"; +import { Label } from "@/components/bs-ui/label"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/bs-ui/table"; +import { Badge } from "@/components/bs-ui/badge"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/bs-ui/tooltip"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/bs-ui/popover"; +import { Calendar } from "@/components/bs-ui/calendar"; +import { bsConfirm } from "@/components/bs-ui/alertDialog/useConfirm"; +import { createApiKeyApi, getApiKeysApi, updateApiKeyApi, deleteApiKeyApi } from "@/controllers/API/user"; +import { captureAndAlertRequestErrorHoc } from "@/controllers/request"; +import { formatDate as utilFormatDate } from "@/util/utils"; + +interface ApiKey { + id: number; + key_name: string; + api_key: string; + is_active: boolean; + last_used_at: string | null; + total_uses: number; + expires_at: string | null; + remark: string | null; + create_time: string; + update_time: string; +} + +export const ApiKeyPage = () => { + const { t } = useTranslation(); + const { message } = useToast(); + const navigate = useNavigate(); + + const [apiKeys, setApiKeys] = useState([]); + const [loading, setLoading] = useState(false); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [showEditDialog, setShowEditDialog] = useState(false); + const [editingKey, setEditingKey] = useState(null); + const [newApiKey, setNewApiKey] = useState(''); + + // 表单数据 + const [formData, setFormData] = useState({ + keyName: '', + expiresAt: '', + remark: '' + }); + + // 日期选择器状态 + const [createExpiresDate, setCreateExpiresDate] = useState(); + const [createExpiresTime, setCreateExpiresTime] = useState(''); + const [editExpiresDate, setEditExpiresDate] = useState(); + const [editExpiresTime, setEditExpiresTime] = useState(''); + + // 加载API Keys + const loadApiKeys = async () => { + setLoading(true); + try { + const response = await captureAndAlertRequestErrorHoc(getApiKeysApi()); + if (response) { + setApiKeys(response); + } + } catch (error) { + console.error('Failed to load API keys:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadApiKeys(); + }, []); + + // 创建API Key + const handleCreateApiKey = async () => { + if (!formData.keyName.trim()) { + message({ + title: t('prompt'), + variant: 'warning', + description: [t('apiKey.keyNameRequired')] + }); + return; + } + + // 构建过期时间 + let expiresAtValue = undefined; + if (createExpiresDate) { + const timeValue = createExpiresTime || '23:59'; + const [hours, minutes] = timeValue.split(':'); + const expiresDateTime = new Date(createExpiresDate); + expiresDateTime.setHours(parseInt(hours), parseInt(minutes), 0, 0); + expiresAtValue = expiresDateTime.toISOString(); + } + + try { + const response = await captureAndAlertRequestErrorHoc( + createApiKeyApi( + formData.keyName, + expiresAtValue, + formData.remark || undefined + ) + ); + + if (response) { + setNewApiKey(response.api_key); + message({ + title: t('prompt'), + variant: 'success', + description: [t('apiKey.createSuccess')] + }); + setFormData({ keyName: '', expiresAt: '', remark: '' }); + setCreateExpiresDate(undefined); + setCreateExpiresTime(''); + loadApiKeys(); + } + } catch (error) { + message({ + title: t('prompt'), + variant: 'error', + description: [t('apiKey.createFailed')] + }); + } + }; + + // 更新API Key + const handleUpdateApiKey = async () => { + if (!editingKey || !formData.keyName.trim()) { + message({ + title: t('prompt'), + variant: 'warning', + description: [t('apiKey.keyNameRequired')] + }); + return; + } + + // 构建过期时间 + let expiresAtValue = undefined; + if (editExpiresDate) { + const timeValue = editExpiresTime || '23:59'; + const [hours, minutes] = timeValue.split(':'); + const expiresDateTime = new Date(editExpiresDate); + expiresDateTime.setHours(parseInt(hours), parseInt(minutes), 0, 0); + expiresAtValue = expiresDateTime.toISOString(); + } + + try { + await captureAndAlertRequestErrorHoc( + updateApiKeyApi( + editingKey.id, + formData.keyName, + undefined, + expiresAtValue, + formData.remark || undefined + ) + ); + + message({ + title: t('prompt'), + variant: 'success', + description: [t('apiKey.updateSuccess')] + }); + setShowEditDialog(false); + setEditingKey(null); + setFormData({ keyName: '', expiresAt: '', remark: '' }); + setEditExpiresDate(undefined); + setEditExpiresTime(''); + loadApiKeys(); + } catch (error) { + message({ + title: t('prompt'), + variant: 'error', + description: [t('apiKey.updateFailed')] + }); + } + }; + + // 删除API Key + const handleDeleteApiKey = (apiKey: ApiKey) => { + bsConfirm({ + title: t('prompt'), + desc: t('apiKey.deleteConfirm'), + okTxt: t('confirm'), + onOk: async (next) => { + try { + await captureAndAlertRequestErrorHoc(deleteApiKeyApi(apiKey.id)); + message({ + title: t('prompt'), + variant: 'success', + description: [t('apiKey.deleteSuccess')] + }); + loadApiKeys(); + } catch (error) { + message({ + title: t('prompt'), + variant: 'error', + description: [t('apiKey.deleteFailed')] + }); + } + next(); + } + }); + }; + + // 切换API Key状态 + const handleToggleStatus = async (apiKey: ApiKey) => { + try { + await captureAndAlertRequestErrorHoc( + updateApiKeyApi(apiKey.id, undefined, !apiKey.is_active) + ); + message({ + title: t('prompt'), + variant: 'success', + description: [t('apiKey.statusChanged')] + }); + loadApiKeys(); + } catch (error) { + message({ + title: t('prompt'), + variant: 'error', + description: [t('apiKey.updateFailed')] + }); + } + }; + + // 复制API Key + const handleCopyApiKey = (apiKey: string) => { + navigator.clipboard.writeText(apiKey).then(() => { + message({ + title: t('prompt'), + variant: 'success', + description: [t('apiKey.copySuccess')] + }); + }).catch(() => { + message({ + title: t('prompt'), + variant: 'error', + description: [t('apiKey.copyFailed')] + }); + }); + }; + + // 打开编辑对话框 + const openEditDialog = (apiKey: ApiKey) => { + setEditingKey(apiKey); + setFormData({ + keyName: apiKey.key_name, + expiresAt: apiKey.expires_at ? apiKey.expires_at.split('T')[0] : '', + remark: apiKey.remark || '' + }); + + // 设置编辑时的日期和时间 + if (apiKey.expires_at) { + const expiresDate = new Date(apiKey.expires_at); + setEditExpiresDate(expiresDate); + setEditExpiresTime( + expiresDate.getHours().toString().padStart(2, '0') + ':' + + expiresDate.getMinutes().toString().padStart(2, '0') + ); + } else { + setEditExpiresDate(undefined); + setEditExpiresTime(''); + } + + setShowEditDialog(true); + }; + + // 格式化日期 + const formatDate = (dateString: string | null) => { + if (!dateString) return t('apiKey.neverUsed'); + return new Date(dateString).toLocaleString(); + }; + + // 检查是否过期 + const isExpired = (expiresAt: string | null) => { + if (!expiresAt) return false; + return new Date(expiresAt) < new Date(); + }; + + // 日期时间选择器组件 + const DateTimePicker = ({ + date, + time, + onDateChange, + onTimeChange, + placeholder = "选择过期时间" + }: { + date: Date | undefined; + time: string; + onDateChange: (date: Date | undefined) => void; + onTimeChange: (time: string) => void; + placeholder?: string; + }) => { + const dateStr = date ? utilFormatDate(date, 'yyyy-MM-dd') : ''; + const displayText = date ? `${dateStr} ${time || '23:59'}` : placeholder; + + return ( +
+ + + + + + date < new Date(new Date().setHours(0, 0, 0, 0))} + /> + {date && ( +
+
+ + + onTimeChange(e.target.value)} + className="w-auto" + /> +
+

+ 如不设置时间,默认为当天 23:59 +

+
+ )} +
+
+
+ ); + }; + + return ( +
+
+
+
+ +
+

+ + {t('apiKey.title')} +

+

+ {t('apiKey.description')} +

+
+
+ +
+ +
+ + + + {t('apiKey.keyName')} + API Key + {t('apiKey.status')} + {t('apiKey.created')} + {t('apiKey.lastUsed')} + {t('apiKey.totalUses')} + {t('apiKey.expiresAt')} + {t('apiKey.actions')} + + + + {apiKeys.map((apiKey) => ( + + + {apiKey.key_name} + {apiKey.remark && ( +
+ {apiKey.remark} +
+ )} +
+ + + {apiKey.api_key} + + + + handleToggleStatus(apiKey)} + > + {apiKey.is_active ? t('apiKey.active') : t('apiKey.inactive')} + + + + {formatDate(apiKey.create_time)} + + + {formatDate(apiKey.last_used_at)} + + {apiKey.total_uses} + + {apiKey.expires_at ? ( + + {isExpired(apiKey.expires_at) ? t('apiKey.expired') : formatDate(apiKey.expires_at)} + + ) : ( + {t('apiKey.noExpiration')} + )} + + +
+ + + + + + +

{t('apiKey.edit')}

+
+
+
+ + + + + + +

{t('apiKey.delete')}

+
+
+
+
+
+
+ ))} +
+
+ + {apiKeys.length === 0 && !loading && ( +
+ +

暂无API Key

+
+ )} +
+
+ + {/* 创建API Key对话框 */} + + + + {t('apiKey.createNew')} + +
+
+ + setFormData({...formData, keyName: e.target.value})} + placeholder={t('apiKey.keyNamePlaceholder')} + /> +
+
+ + +
+
+ + setFormData({...formData, remark: e.target.value})} + placeholder={t('apiKey.remarkPlaceholder')} + /> +
+ {newApiKey && ( +
+

+ {t('apiKey.keyCreatedWarning')} +

+
+ + {newApiKey} + + +
+
+ )} +
+ + + + +
+
+ + {/* 编辑API Key对话框 */} + + + + {t('apiKey.editTitle')} + +
+
+ + setFormData({...formData, keyName: e.target.value})} + placeholder={t('apiKey.keyNamePlaceholder')} + /> +
+
+ + +
+
+ + setFormData({...formData, remark: e.target.value})} + placeholder={t('apiKey.remarkPlaceholder')} + /> +
+
+ + + + +
+
+
+ ); +}; + +export default ApiKeyPage; \ No newline at end of file diff --git a/src/frontend/platform/src/routes.tsx b/src/frontend/platform/src/routes.tsx index c9948ae41..9655520d0 100755 --- a/src/frontend/platform/src/routes.tsx +++ b/src/frontend/platform/src/routes.tsx @@ -37,6 +37,7 @@ import Report from "./pages/Report"; import SystemPage from "./pages/SystemPage"; import ResoucePage from "./pages/resoucePage"; import { AppNumType } from "./types/app"; +import { ApiKeyPage } from "./pages/SystemPage/components/ApiKey"; // react 与 react router dom版本不匹配 // const FileLibPage = lazy(() => import(/* webpackChunkName: "FileLibPage" */ "./pages/FileLibPage")); @@ -129,6 +130,7 @@ const privateRouter = [ { path: "/report/:id/", element: }, { path: "/diff/:id/:vid/:cid", element: }, { path: "/reset", element: }, + { path: "/apikey", element: }, { path: "/403", element: }, { path: "*", element: } ] From 55a4267e67a0316edc00c6ae191098a601cad062 Mon Sep 17 00:00:00 2001 From: vp01273 Date: Fri, 4 Jul 2025 15:28:23 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0API=20Key=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E5=A4=B1=E8=B4=A5=E6=8F=90=E7=A4=BA=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=8A=A0=E6=89=8B=E5=8A=A8=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=E7=9A=84=E8=AF=B4=E6=98=8E=EF=BC=9B=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E6=A0=BC=E5=BC=8F=E5=92=8C=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../platform/public/locales/en/bs.json | 2 +- .../platform/public/locales/zh/bs.json | 2 +- .../pages/SystemPage/components/ApiKey.tsx | 92 ++++++++++++------- 3 files changed, 59 insertions(+), 37 deletions(-) diff --git a/src/frontend/platform/public/locales/en/bs.json b/src/frontend/platform/public/locales/en/bs.json index 1cb1170e8..be2153182 100644 --- a/src/frontend/platform/public/locales/en/bs.json +++ b/src/frontend/platform/public/locales/en/bs.json @@ -670,7 +670,7 @@ "updateFailed": "Update failed", "deleteFailed": "Delete failed", "copySuccess": "API Key copied to clipboard", - "copyFailed": "Copy failed", + "copyFailed": "Copy failed, please copy manually", "maxKeysReached": "Maximum number of API Keys reached", "keyCreatedWarning": "API Key will only be displayed once when created, please save it properly", "neverUsed": "Never used", diff --git a/src/frontend/platform/public/locales/zh/bs.json b/src/frontend/platform/public/locales/zh/bs.json index ef5b2b176..fdc0de9aa 100644 --- a/src/frontend/platform/public/locales/zh/bs.json +++ b/src/frontend/platform/public/locales/zh/bs.json @@ -667,7 +667,7 @@ "updateFailed": "更新失败", "deleteFailed": "删除失败", "copySuccess": "API Key 已复制到剪贴板", - "copyFailed": "复制失败", + "copyFailed": "复制失败,请手动复制", "maxKeysReached": "API Key 数量已达上限", "keyCreatedWarning": "API Key 只会在创建时显示一次,请妥善保存", "neverUsed": "从未使用", diff --git a/src/frontend/platform/src/pages/SystemPage/components/ApiKey.tsx b/src/frontend/platform/src/pages/SystemPage/components/ApiKey.tsx index eb8016fbf..c3f771659 100644 --- a/src/frontend/platform/src/pages/SystemPage/components/ApiKey.tsx +++ b/src/frontend/platform/src/pages/SystemPage/components/ApiKey.tsx @@ -34,14 +34,14 @@ export const ApiKeyPage = () => { const { t } = useTranslation(); const { message } = useToast(); const navigate = useNavigate(); - + const [apiKeys, setApiKeys] = useState([]); const [loading, setLoading] = useState(false); const [showCreateDialog, setShowCreateDialog] = useState(false); const [showEditDialog, setShowEditDialog] = useState(false); const [editingKey, setEditingKey] = useState(null); const [newApiKey, setNewApiKey] = useState(''); - + // 表单数据 const [formData, setFormData] = useState({ keyName: '', @@ -103,7 +103,7 @@ export const ApiKeyPage = () => { formData.remark || undefined ) ); - + if (response) { setNewApiKey(response.api_key); message({ @@ -156,7 +156,7 @@ export const ApiKeyPage = () => { formData.remark || undefined ) ); - + message({ title: t('prompt'), variant: 'success', @@ -226,20 +226,32 @@ export const ApiKeyPage = () => { }; // 复制API Key - const handleCopyApiKey = (apiKey: string) => { - navigator.clipboard.writeText(apiKey).then(() => { - message({ - title: t('prompt'), - variant: 'success', - description: [t('apiKey.copySuccess')] - }); - }).catch(() => { + const handleCopyApiKey = async (apiKey: string) => { + if (navigator.clipboard) { + try { + await navigator.clipboard.writeText(apiKey); + message({ + title: t('prompt'), + variant: 'success', + description: [t('apiKey.copySuccess')] + }); + return true; + } catch { + message({ + title: t('prompt'), + variant: 'error', + description: [t('apiKey.copyFailed')] + }); + return false; + } + } else { message({ title: t('prompt'), variant: 'error', description: [t('apiKey.copyFailed')] }); - }); + return false; + } }; // 打开编辑对话框 @@ -250,7 +262,7 @@ export const ApiKeyPage = () => { expiresAt: apiKey.expires_at ? apiKey.expires_at.split('T')[0] : '', remark: apiKey.remark || '' }); - + // 设置编辑时的日期和时间 if (apiKey.expires_at) { const expiresDate = new Date(apiKey.expires_at); @@ -263,7 +275,7 @@ export const ApiKeyPage = () => { setEditExpiresDate(undefined); setEditExpiresTime(''); } - + setShowEditDialog(true); }; @@ -280,12 +292,12 @@ export const ApiKeyPage = () => { }; // 日期时间选择器组件 - const DateTimePicker = ({ - date, - time, - onDateChange, - onTimeChange, - placeholder = "选择过期时间" + const DateTimePicker = ({ + date, + time, + onDateChange, + onTimeChange, + placeholder = "选择过期时间" }: { date: Date | undefined; time: string; @@ -401,7 +413,7 @@ export const ApiKeyPage = () => { - handleToggleStatus(apiKey)} @@ -465,7 +477,7 @@ export const ApiKeyPage = () => { ))} - + {apiKeys.length === 0 && !loading && (
@@ -476,7 +488,15 @@ export const ApiKeyPage = () => {
{/* 创建API Key对话框 */} - + { + setShowCreateDialog(open); + if (!open) { + setFormData({ keyName: '', expiresAt: '', remark: '' }); + setCreateExpiresDate(undefined); + setCreateExpiresTime(''); + setNewApiKey(''); + } + }}> {t('apiKey.createNew')} @@ -487,7 +507,7 @@ export const ApiKeyPage = () => { setFormData({...formData, keyName: e.target.value})} + onChange={(e) => setFormData({ ...formData, keyName: e.target.value })} placeholder={t('apiKey.keyNamePlaceholder')} /> @@ -506,7 +526,7 @@ export const ApiKeyPage = () => { setFormData({...formData, remark: e.target.value})} + onChange={(e) => setFormData({ ...formData, remark: e.target.value })} placeholder={t('apiKey.remarkPlaceholder')} /> @@ -521,13 +541,15 @@ export const ApiKeyPage = () => {