diff --git a/backend/.env.example b/backend/.env.example index 0f92e76..c8bbd47 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,12 +1,20 @@ -# DataGuard Backend Configuration -ENV=development -DEBUG=true -SECRET_KEY=change-me-to-a-secure-random-string -ACCESS_TOKEN_EXPIRE_MINUTES=30 +# ── SQL-Platform 生产环境配置 ── +# 复制此文件为 .env 并修改为实际值 -# Platform Database (SQLite) +# 环境: development / production +ENV=production +DEBUG=false + +# JWT 密钥(生产环境必须更换为随机强密钥) +# 生成方式: python -c "import secrets; print(secrets.token_urlsafe(32))" +SECRET_KEY=dev-secret-key-change-in-production + +# 管理员初始密码(首次启动创建 admin 用户时使用) +ADMIN_PASSWORD=123456 + +# 数据库 DATABASE_URL=sqlite+aiosqlite:///./sqlite3.db -# Default Admin -ADMIN_USERNAME=admin -ADMIN_PASSWORD=123456 +# CORS 允许的前端域名(生产环境必填,逗号分隔) +# CORS_ORIGINS=https://sql.example.com,https://sql2.example.com +CORS_ORIGINS= diff --git a/backend/app/api/v1/endpoints/query.py b/backend/app/api/v1/endpoints/query.py index c70a039..89ceacc 100644 --- a/backend/app/api/v1/endpoints/query.py +++ b/backend/app/api/v1/endpoints/query.py @@ -65,6 +65,30 @@ async def execute_query( if current_user.role == "guest": raise HTTPException(status_code=403, detail="访客无权执行查询,请联系管理员申请权限") + # ── 系统设置集成 ── + from app.api.v1.endpoints.settings import get_setting_value + require_approval = await get_setting_value(db, "query_require_approval", False) + max_limit = await get_setting_value(db, "query_max_limit", 10000) + query_timeout = await get_setting_value(db, "query_timeout", 30) + max_length = await get_setting_value(db, "query_max_length", 10000) + auto_limit = await get_setting_value(db, "query_auto_limit", True) + enable_masking = await get_setting_value(db, "query_enable_masking", True) + save_hist = await get_setting_value(db, "query_save_history", True) + + # 查询审批检查 + if require_approval and current_user.role not in ("admin", "dba"): + raise HTTPException( + status_code=403, + detail="当前系统已开启查询审批,请通过工单提交查询申请" + ) + + # 查询语句长度检查 + if len(req.sql) > max_length: + raise HTTPException( + status_code=400, + detail=f"查询语句过长({len(req.sql)} 字符),上限 {max_length} 字符" + ) + # 查询实例信息 result = await db.execute(select(Instance).where(Instance.id == req.instance_id)) instance = result.scalar_one_or_none() diff --git a/backend/app/api/v1/endpoints/settings.py b/backend/app/api/v1/endpoints/settings.py new file mode 100644 index 0000000..b13dcd9 --- /dev/null +++ b/backend/app/api/v1/endpoints/settings.py @@ -0,0 +1,162 @@ +"""系统设置 API — GET/PUT 分组设置,自动初始化种子数据。""" + +from typing import List, Dict +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel + +from app.core.database import get_db +from app.core.security import require_role +from app.models.user import User +from app.models.system_setting import SystemSetting +from app.schemas.system_setting import SettingItem, SettingUpdateItem +from app.services.settings_seeds import SEED_SETTINGS, SETTING_GROUPS + +router = APIRouter() + + +async def _ensure_settings(db: AsyncSession) -> None: + """确保所有种子设置项存在(缺失的自动插入)。""" + result = await db.execute(select(SystemSetting.key)) + existing = {row[0] for row in result.all()} + missing = [s for s in SEED_SETTINGS if s["key"] not in existing] + if missing: + for s in missing: + db.add(SystemSetting(**s)) + await db.commit() + + +@router.get("", response_model=Dict[str, List[SettingItem]]) +async def get_settings( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """获取所有系统设置,按分组返回。""" + await _ensure_settings(db) + result = await db.execute( + select(SystemSetting).order_by(SystemSetting.group, SystemSetting.sort_order) + ) + settings = result.scalars().all() + + grouped: Dict[str, List[SettingItem]] = {} + for s in settings: + group = s.group + if group not in grouped: + grouped[group] = [] + grouped[group].append(SettingItem( + key=s.key, + value=s.value, + value_type=s.value_type, + group=s.group, + label=s.label, + description=s.description, + default_value=s.default_value, + sort_order=s.sort_order, + )) + return grouped + + +@router.get("/groups") +async def get_setting_groups( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """获取设置分组信息。""" + await _ensure_settings(db) + return SETTING_GROUPS + + +@router.put("") +async def update_settings( + items: List[SettingUpdateItem], + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """批量更新系统设置。""" + await _ensure_settings(db) + + updated = [] + for item in items: + result = await db.execute( + select(SystemSetting).where(SystemSetting.key == item.key) + ) + setting = result.scalar_one_or_none() + if not setting: + continue + + # 类型校验 + if setting.value_type == "boolean" and item.value not in ("true", "false"): + raise HTTPException(400, f"设置 '{setting.label}' 需要布尔值 (true/false)") + if setting.value_type == "integer": + try: + int(item.value) + except ValueError: + raise HTTPException(400, f"设置 '{setting.label}' 需要数字") + + # 范围校验 + if setting.key == "query_max_limit": + v = int(item.value) + if v < 100 or v > 100000: + raise HTTPException(400, "查询 LIMIT 上限范围: 100~100000") + if setting.key == "query_timeout": + v = int(item.value) + if v < 5 or v > 300: + raise HTTPException(400, "查询超时范围: 5~300 秒") + if setting.key == "query_max_length": + v = int(item.value) + if v < 500 or v > 50000: + raise HTTPException(400, "查询语句长度范围: 500~50000") + if setting.key == "security_password_min_length": + v = int(item.value) + if v < 4 or v > 32: + raise HTTPException(400, "密码最小长度范围: 4~32") + if setting.key == "security_max_login_attempts": + v = int(item.value) + if v < 0 or v > 20: + raise HTTPException(400, "最大登录失败次数范围: 0~20") + + setting.value = item.value + updated.append(setting.key) + + await db.commit() + return {"updated": updated} + + +# ── 供其他模块调用的工具函数 ── + +async def get_setting_value(db: AsyncSession, key: str, fallback=None): + """获取设置值的实际类型(给其他模块用)。""" + result = await db.execute( + select(SystemSetting).where(SystemSetting.key == key) + ) + setting = result.scalar_one_or_none() + if setting: + return setting.get_typed_value() + # fallback 到种子数据默认值 + for s in SEED_SETTINGS: + if s["key"] == key: + if s["value_type"] == "boolean": + return s["default_value"].lower() in ("true", "1", "yes") + if s["value_type"] == "integer": + return int(s["default_value"]) + return s["default_value"] + return fallback + + +@router.get("/public/query") +async def get_public_query_settings( + db: AsyncSession = Depends(get_db), +): + """前端查询页面获取查询相关设置(无需鉴权,只读)。""" + await _ensure_settings(db) + keys = [ + "query_require_approval", "query_max_limit", "query_timeout", + "query_max_length", "query_auto_limit", "query_enable_masking", + "query_save_history", + ] + result = await db.execute( + select(SystemSetting).where(SystemSetting.key.in_(keys)) + ) + settings = result.scalars().all() + return {s.key: s.get_typed_value() for s in settings} diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 4b99413..200b230 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -7,6 +7,7 @@ from app.api.v1.endpoints import ( instance_group, masking_rule, rollback, workflow, impact, schema_drift, slow_log, pool, mongo, query_history, sql_assistant, db_types, + settings, ) api_router = APIRouter(prefix="/api/v1") @@ -39,3 +40,4 @@ api_router.include_router(query_history.router) api_router.include_router(sql_assistant.router) api_router.include_router(db_types.router) +api_router.include_router(settings.router, prefix="/settings", tags=["系统设置"]) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 7d0ea42..00726e6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -15,6 +15,9 @@ class Settings(BaseSettings): ADMIN_USERNAME: str = "admin" ADMIN_PASSWORD: str = "123456" + # CORS: production comma-separated origins (e.g. "https://sql.example.com,https://sql2.example.com") + CORS_ORIGINS: str = "" + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") diff --git a/backend/app/main.py b/backend/app/main.py index f91e500..2c78e03 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -29,23 +29,16 @@ async def seed_workflows(): async def seed_data(): - """Create default admin user and seed data.""" + """Create default admin user from environment config.""" async with async_session() as db: result = await db.execute(select(User).where(User.username == settings.ADMIN_USERNAME)) if not result.scalar_one_or_none(): - users = [ - User(username="admin", display_name="管理员", role="admin", - hashed_password=hash_password("123456")), - User(username="dba", display_name="DBA 张三", role="dba", - hashed_password=hash_password("123456")), - User(username="dev1", display_name="开发者 李四", role="developer", - hashed_password=hash_password("123456")), - User(username="dev2", display_name="开发者 王五", role="developer", - hashed_password=hash_password("123456")), - User(username="auditor", display_name="审计员 赵六", role="developer", - email="auditor@example.com", hashed_password=hash_password("123456")), - ] - db.add_all(users) + db.add(User( + username=settings.ADMIN_USERNAME, + display_name="管理员", + role="admin", + hashed_password=hash_password(settings.ADMIN_PASSWORD), + )) await db.commit() @@ -85,9 +78,13 @@ app = FastAPI( docs_url="/docs" if settings.DEBUG else None, ) +# CORS: development allows *, production uses CORS_ORIGINS env var +_cors_origins = ["*"] if settings.DEBUG else [ + o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip() +] app.add_middleware( CORSMiddleware, - allow_origins=["*"] if settings.DEBUG else [], + allow_origins=_cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index bf05c9a..45c0bbb 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1 +1,2 @@ from app.models.instance_group import InstanceGroup, user_groups # noqa: F401 +from app.models.system_setting import SystemSetting # noqa: F401 diff --git a/backend/app/models/system_setting.py b/backend/app/models/system_setting.py new file mode 100644 index 0000000..e593723 --- /dev/null +++ b/backend/app/models/system_setting.py @@ -0,0 +1,36 @@ +"""系统设置模型 — 键值对存储,支持分组。""" + +from datetime import datetime, timezone +from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean +from app.core.database import Base + + +class SystemSetting(Base): + """系统设置表 — key-value 存储。 + + 每个设置项包含: key, value, value_type, group, description, default_value。 + value_type 用于前端控件渲染 (boolean → 开关, integer → 数字输入, string → 文本)。 + """ + __tablename__ = "system_settings" + + id = Column(Integer, primary_key=True, autoincrement=True) + key = Column(String(100), unique=True, nullable=False, index=True, comment="设置键名") + value = Column(Text, nullable=False, comment="设置值(字符串存储)") + value_type = Column(String(20), nullable=False, default="string", comment="值类型: boolean/integer/string") + group = Column(String(50), nullable=False, default="general", comment="分组: query/security/general") + label = Column(String(100), nullable=False, comment="显示名称") + description = Column(Text, nullable=True, comment="说明") + default_value = Column(Text, nullable=True, comment="默认值") + sort_order = Column(Integer, nullable=False, default=0, comment="排序权重") + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + def get_typed_value(self): + """将字符串值转为实际类型。""" + if self.value_type == "boolean": + return self.value.lower() in ("true", "1", "yes") + if self.value_type == "integer": + try: + return int(self.value) + except (ValueError, TypeError): + return int(self.default_value or 0) + return self.value diff --git a/backend/app/schemas/system_setting.py b/backend/app/schemas/system_setting.py new file mode 100644 index 0000000..86de60e --- /dev/null +++ b/backend/app/schemas/system_setting.py @@ -0,0 +1,39 @@ +"""系统设置 Schema。""" + +from typing import Optional, List +from pydantic import BaseModel, Field + + +class SettingItem(BaseModel): + """单个设置项。""" + key: str + value: str + value_type: str = "string" # boolean / integer / string + group: str = "general" + label: str + description: Optional[str] = None + default_value: Optional[str] = None + sort_order: int = 0 + + class Config: + from_attributes = True + + +class SettingUpdateItem(BaseModel): + """单个更新项。""" + key: str + value: str + + +class SettingBulkUpdate(BaseModel): + """批量更新设置项。""" + settings: List[SettingUpdateItem] = Field(..., min_length=1) + + +class SettingGroupResponse(BaseModel): + """分组设置响应。""" + group: str + label: str + icon: str = "" + description: str = "" + settings: List[SettingItem] diff --git a/backend/app/services/settings_seeds.py b/backend/app/services/settings_seeds.py new file mode 100644 index 0000000..9d661ca --- /dev/null +++ b/backend/app/services/settings_seeds.py @@ -0,0 +1,136 @@ +"""系统设置种子数据 — 首次启动自动初始化。""" + +SEED_SETTINGS = [ + # ── 查询控制 ── + { + "key": "query_require_approval", + "value": "false", + "value_type": "boolean", + "group": "query", + "label": "查询需要审批", + "description": "开启后,所有查询操作需要经过管理员或 DBA 审批才能执行", + "default_value": "false", + "sort_order": 1, + }, + { + "key": "query_max_limit", + "value": "10000", + "value_type": "integer", + "group": "query", + "label": "查询 LIMIT 上限", + "description": "SELECT 查询返回的最大行数,超出的行会被截断。范围: 100~100000", + "default_value": "10000", + "sort_order": 2, + }, + { + "key": "query_timeout", + "value": "30", + "value_type": "integer", + "group": "query", + "label": "查询超时(秒)", + "description": "单次查询最大执行时间,超时自动终止。范围: 5~300", + "default_value": "30", + "sort_order": 3, + }, + { + "key": "query_max_length", + "value": "10000", + "value_type": "integer", + "group": "query", + "label": "查询语句最大长度", + "description": "查询语句允许的最大字符数。范围: 500~50000", + "default_value": "10000", + "sort_order": 4, + }, + { + "key": "query_auto_limit", + "value": "true", + "value_type": "boolean", + "group": "query", + "label": "自动添加 LIMIT", + "description": "当 SELECT 语句没有 LIMIT 时自动追加,防止返回海量数据", + "default_value": "true", + "sort_order": 5, + }, + { + "key": "query_enable_masking", + "value": "true", + "value_type": "boolean", + "group": "query", + "label": "启用数据脱敏", + "description": "自动对手机号、身份证、邮箱等敏感字段进行脱敏处理", + "default_value": "true", + "sort_order": 6, + }, + { + "key": "query_save_history", + "value": "true", + "value_type": "boolean", + "group": "query", + "label": "保存查询历史", + "description": "自动记录每次查询内容,方便后续复用和审计", + "default_value": "true", + "sort_order": 7, + }, + + # ── 安全控制 ── + { + "key": "security_max_login_attempts", + "value": "5", + "value_type": "integer", + "group": "security", + "label": "最大登录失败次数", + "description": "连续登录失败达到次数后锁定账号(0 表示不限制)", + "default_value": "5", + "sort_order": 10, + }, + { + "key": "security_password_min_length", + "value": "6", + "value_type": "integer", + "group": "security", + "label": "密码最小长度", + "description": "用户密码的最小字符数", + "default_value": "6", + "sort_order": 11, + }, + { + "key": "security_enable_audit_log", + "value": "true", + "value_type": "boolean", + "group": "security", + "label": "启用审计日志", + "description": "记录所有关键操作(查询、工单、权限变更等)", + "default_value": "true", + "sort_order": 12, + }, + + # ── 工单控制 ── + { + "key": "ticket_default_workflow", + "value": "standard", + "value_type": "string", + "group": "ticket", + "label": "默认审批流程", + "description": "新建工单使用的默认审批流程: standard(标准三级) / simple(简单一级) / urgent(紧急快速)", + "default_value": "standard", + "sort_order": 20, + }, + { + "key": "ticket_allow_rollback", + "value": "true", + "value_type": "boolean", + "group": "ticket", + "label": "允许工单回滚", + "description": "执行失败的工单是否允许重新执行(回到 approved 状态)", + "default_value": "true", + "sort_order": 21, + }, +] + +# 分组元信息 +SETTING_GROUPS = { + "query": {"label": "查询控制", "icon": "🔍", "description": "控制查询行为、LIMIT、超时和脱敏策略"}, + "security": {"label": "安全设置", "icon": "🔒", "description": "登录安全、密码策略、审计日志"}, + "ticket": {"label": "工单设置", "icon": "📋", "description": "审批流程、回滚策略"}, +} diff --git a/backend/tests/test_system_settings.py b/backend/tests/test_system_settings.py new file mode 100644 index 0000000..98d1641 --- /dev/null +++ b/backend/tests/test_system_settings.py @@ -0,0 +1,243 @@ +"""系统设置 API 测试 — GET/PUT 设置 + 公共查询设置 + 查询集成。""" + +import pytest +from httpx import AsyncClient, ASGITransport +from app.main import app + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +async def _admin_token(client: AsyncClient) -> str: + resp = await client.post("/api/v1/auth/login", json={ + "username": "admin", "password": "123456" + }) + assert resp.status_code == 200 + return resp.json()["access_token"] + + +def _auth_header(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +async def _ensure_user(client: AsyncClient, admin_token: str, username: str, role: str = "developer") -> str: + """确保用户存在并返回 token。""" + resp = await client.post("/api/v1/auth/login", json={"username": username, "password": "123456"}) + if resp.status_code == 200: + return resp.json()["access_token"] + # 创建 + await client.post("/api/v1/users", headers=_auth_header(admin_token), json={ + "username": username, "password": "123456", + "display_name": username, "role": role + }) + resp = await client.post("/api/v1/auth/login", json={"username": username, "password": "123456"}) + assert resp.status_code == 200 + return resp.json()["access_token"] + + +# ── 1. GET /settings ── + +@pytest.mark.asyncio +async def test_get_settings(client): + """管理员可以获取所有系统设置。""" + token = await _admin_token(client) + resp = await client.get("/api/v1/settings", headers=_auth_header(token)) + assert resp.status_code == 200 + data = resp.json() + assert "query" in data + assert "security" in data + assert "ticket" in data + # 查询控制至少 7 项 + assert len(data["query"]) >= 7 + + +@pytest.mark.asyncio +async def test_get_settings_non_admin(client): + """非管理员不能获取设置。""" + admin_token = await _admin_token(client) + user_token = await _ensure_user(client, admin_token, "testuser_settings") + + resp = await client.get("/api/v1/settings", headers=_auth_header(user_token)) + # require_role("admin") 应该返回 403 + assert resp.status_code == 403 + + +# ── 2. PUT /settings ── + +@pytest.mark.asyncio +async def test_update_settings_boolean(client): + """更新布尔设置项。""" + token = await _admin_token(client) + resp = await client.put("/api/v1/settings", headers=_auth_header(token), json=[ + {"key": "query_require_approval", "value": "true"} + ]) + assert resp.status_code == 200 + assert "query_require_approval" in resp.json()["updated"] + + # 验证 + resp = await client.get("/api/v1/settings", headers=_auth_header(token)) + items = resp.json()["query"] + approval = next(i for i in items if i["key"] == "query_require_approval") + assert approval["value"] == "true" + + # 恢复 + await client.put("/api/v1/settings", headers=_auth_header(token), json=[ + {"key": "query_require_approval", "value": "false"} + ]) + + +@pytest.mark.asyncio +async def test_update_settings_integer(client): + """更新整数设置项。""" + token = await _admin_token(client) + resp = await client.put("/api/v1/settings", headers=_auth_header(token), json=[ + {"key": "query_max_limit", "value": "5000"} + ]) + assert resp.status_code == 200 + + # 恢复 + await client.put("/api/v1/settings", headers=_auth_header(token), json=[ + {"key": "query_max_limit", "value": "10000"} + ]) + + +@pytest.mark.asyncio +async def test_update_settings_validation(client): + """设置值校验。""" + token = await _admin_token(client) + + # 布尔值错误 + resp = await client.put("/api/v1/settings", headers=_auth_header(token), json=[ + {"key": "query_require_approval", "value": "yes"} + ]) + assert resp.status_code == 400 + + # 整数范围错误 + resp = await client.put("/api/v1/settings", headers=_auth_header(token), json=[ + {"key": "query_max_limit", "value": "10"} + ]) + assert resp.status_code == 400 + + # 超时范围错误 + resp = await client.put("/api/v1/settings", headers=_auth_header(token), json=[ + {"key": "query_timeout", "value": "1"} + ]) + assert resp.status_code == 400 + + +# ── 3. GET /settings/public/query ── + +@pytest.mark.asyncio +async def test_public_query_settings(client): + """公共查询设置无需鉴权。""" + resp = await client.get("/api/v1/settings/public/query") + assert resp.status_code == 200 + data = resp.json() + assert "query_require_approval" in data + assert "query_max_limit" in data + assert "query_timeout" in data + assert isinstance(data["query_require_approval"], bool) + assert isinstance(data["query_max_limit"], int) + + +# ── 4. 查询集成 — 审批开关 ── + +@pytest.mark.asyncio +async def test_query_require_approval(client): + """开启查询审批后普通用户无法直接查询。""" + admin_token = await _admin_token(client) + user_token = await _ensure_user(client, admin_token, "testuser_approval") + + # 开启查询审批 + await client.put("/api/v1/settings", headers=_auth_header(admin_token), json=[ + {"key": "query_require_approval", "value": "true"} + ]) + + # 普通用户查询应被拦截 + resp = await client.post("/api/v1/query", headers=_auth_header(user_token), json={ + "instance_id": 1, "database": "test", "sql": "SELECT 1" + }) + assert resp.status_code == 403 + assert "审批" in resp.json()["detail"] + + # 管理员仍然可以查询(不被审批限制拦截,可能因连接报其他错) + resp = await client.post("/api/v1/query", headers=_auth_header(admin_token), json={ + "instance_id": 1, "database": "test", "sql": "SELECT 1" + }) + assert resp.status_code != 403, "管理员不应被审批限制拦截" + + # 恢复 + await client.put("/api/v1/settings", headers=_auth_header(admin_token), json=[ + {"key": "query_require_approval", "value": "false"} + ]) + + +# ── 5. 查询集成 — 语句长度检查 ── + +@pytest.mark.asyncio +async def test_query_max_length(client): + """超长查询语句被拦截。""" + admin_token = await _admin_token(client) + + # 设置较短限制(在合法范围 500~50000 内) + await client.put("/api/v1/settings", headers=_auth_header(admin_token), json=[ + {"key": "query_max_length", "value": "500"} + ]) + + # 超长 SQL(> 500 字符) + long_sql = "SELECT * FROM users WHERE id = " + "1" * 600 + resp = await client.post("/api/v1/query", headers=_auth_header(admin_token), json={ + "instance_id": 1, "database": "test", "sql": long_sql + }) + assert resp.status_code == 400, f"Expected 400, got {resp.status_code}: {resp.text}" + assert "过长" in resp.json()["detail"] + + # 正常长度 SQL 不被拦截(可能因连接失败但不应是400长度错误) + short_sql = "SELECT 1" + resp = await client.post("/api/v1/query", headers=_auth_header(admin_token), json={ + "instance_id": 1, "database": "test", "sql": short_sql + }) + assert resp.status_code != 400 or "过长" not in resp.text + + # 恢复 + await client.put("/api/v1/settings", headers=_auth_header(admin_token), json=[ + {"key": "query_max_length", "value": "50000"} + ]) + + +# ── 6. 批量更新 ── + +@pytest.mark.asyncio +async def test_bulk_update(client): + """批量更新多个设置。""" + token = await _admin_token(client) + resp = await client.put("/api/v1/settings", headers=_auth_header(token), json=[ + {"key": "query_timeout", "value": "60"}, + {"key": "query_max_limit", "value": "2000"}, + ]) + assert resp.status_code == 200 + assert len(resp.json()["updated"]) == 2 + + # 恢复 + await client.put("/api/v1/settings", headers=_auth_header(token), json=[ + {"key": "query_timeout", "value": "30"}, + {"key": "query_max_limit", "value": "10000"}, + ]) + + +# ── 7. 设置种子数据自动初始化 ── + +@pytest.mark.asyncio +async def test_settings_auto_seed(client): + """设置接口自动初始化种子数据。""" + token = await _admin_token(client) + resp = await client.get("/api/v1/settings", headers=_auth_header(token)) + assert resp.status_code == 200 + data = resp.json() + # 确认至少 12 个设置项 + total = sum(len(v) for v in data.values()) + assert total >= 12 diff --git a/frontend/src/api/notification.ts b/frontend/src/api/notification.ts new file mode 100644 index 0000000..36918f5 --- /dev/null +++ b/frontend/src/api/notification.ts @@ -0,0 +1,9 @@ +import request from './request'; + +export const notificationApi = { + list: () => request.get('/notifications'), + create: (data: any) => request.post('/notifications', data), + update: (id: number, data: any) => request.put(`/notifications/${id}`, data), + delete: (id: number) => request.delete(`/notifications/${id}`), + test: (id: number) => request.post(`/notifications/${id}/test`), +}; diff --git a/frontend/src/api/workflow.ts b/frontend/src/api/workflow.ts new file mode 100644 index 0000000..1b5b830 --- /dev/null +++ b/frontend/src/api/workflow.ts @@ -0,0 +1,11 @@ +import request from './request'; + +export const workflowApi = { + list: () => request.get('/workflows'), + get: (id: number) => request.get(`/workflows/${id}`), + create: (data: any) => request.post('/workflows', data), + update: (id: number, data: any) => request.put(`/workflows/${id}`, data), + delete: (id: number) => request.delete(`/workflows/${id}`), + setDefault: (id: number) => request.post(`/workflows/${id}/set-default`), + clone: (id: number) => request.post(`/workflows/${id}/clone`), +}; diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..a9fdf8b --- /dev/null +++ b/frontend/src/components/ConfirmDialog.tsx @@ -0,0 +1,59 @@ +import { + Dialog, + DialogContent, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +interface Props { + open: boolean; + onConfirm: () => void; + onCancel: () => void; + title?: string; + description?: string; + confirmText?: string; + cancelText?: string; + variant?: 'danger' | 'default'; +} + +export function ConfirmDialog({ + open, + onConfirm, + onCancel, + title = '确认操作', + description, + confirmText = '确认', + cancelText = '取消', + variant = 'default', +}: Props) { + return ( + { if (!v) onCancel(); }}> + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + {title} + {description && ( + + {description} + + )} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/FormDialog.tsx b/frontend/src/components/FormDialog.tsx new file mode 100644 index 0000000..94a8464 --- /dev/null +++ b/frontend/src/components/FormDialog.tsx @@ -0,0 +1,53 @@ +import { + Dialog, + DialogContent, + DialogTitle, +} from '@/components/ui/dialog'; +import { XIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +interface Props { + open: boolean; + onClose: () => void; + title: string; + children: ReactNode; + width?: string; + footer?: ReactNode; +} + +export function FormDialog({ + open, + onClose, + title, + children, + width = 'max-w-lg', + footer, +}: Props) { + return ( + { if (!v) onClose(); }}> + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + {/* Header */} +
+ {title} + +
+ + {/* Scrollable content */} +
{children}
+ + {/* Optional footer */} + {footer &&
{footer}
} +
+
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 603f60f..b43c683 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -4,7 +4,7 @@ import { ClipboardList, Search, UserCog, Users, Lock, Shield, GitBranch, Bell, LogOut, Settings, Clock, Megaphone, Sun, Moon, BookOpen } from 'lucide-react'; -import { menuSections } from '@/lib/mock'; +import { menuSections } from '@/constants/menu'; import { useAuth } from '@/hooks/useAuth'; import { useTheme } from '@/hooks/useTheme'; diff --git a/frontend/src/constants/menu.ts b/frontend/src/constants/menu.ts new file mode 100644 index 0000000..2e4c7b2 --- /dev/null +++ b/frontend/src/constants/menu.ts @@ -0,0 +1,22 @@ +export const menuSections = [ + { id: 'overview', label: '概览' }, + { id: 'datasource', label: '数据源' }, + { id: 'ticket', label: '工单管理' }, + { id: 'permission', label: '权限管理' }, + { id: 'system', label: '系统配置' }, + { id: 'config', label: '配置平台' }, +]; + +export const menuItems = [ + { id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'guest'] }, + { id: 'instances', label: '数据源', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] }, + { id: 'instance-groups', label: '权限管理', icon: 'Lock', section: 'permission', roles: ['admin'] }, + { id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer'] }, + { id: 'queries', label: '查询数据', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] }, + { id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] }, + { id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'] }, + { id: 'audit-logs', label: '审计日志', icon: 'FileText', section: 'system', roles: ['admin', 'dba'] }, + { id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'system', roles: ['admin', 'dba'] }, + { id: 'auto-task', label: '自动任务', icon: 'Clock', section: 'system', roles: ['admin'] }, + { id: 'settings', label: '系统设置', icon: 'Settings', section: 'config', roles: ['admin'] }, +]; diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx index 0b46756..a330448 100644 --- a/frontend/src/hooks/useAuth.tsx +++ b/frontend/src/hooks/useAuth.tsx @@ -1,5 +1,14 @@ import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react'; -import { menuItems, currentUser as defaultUser } from '@/lib/mock'; +import { menuItems } from '@/constants/menu'; + +const defaultUser = { + id: 0, + username: 'guest', + email: '', + role: 'guest' as const, + roleName: '访客', + avatar: 'G', +}; import { authApi } from '@/api/auth'; export type UserRole = 'admin' | 'dba' | 'developer' | 'guest'; diff --git a/frontend/src/hooks/useFetchList.ts b/frontend/src/hooks/useFetchList.ts new file mode 100644 index 0000000..4862fc6 --- /dev/null +++ b/frontend/src/hooks/useFetchList.ts @@ -0,0 +1,24 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; + +export function useFetchList(fetchFn: () => Promise, deps: unknown[] = []) { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const fetchFnRef = useRef(fetchFn); + fetchFnRef.current = fetchFn; + + const refresh = useCallback(() => { + setLoading(true); + fetchFnRef + .current() + .then((res) => setData(res)) + .catch(() => setData([])) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return { data, loading, refresh }; +} diff --git a/frontend/src/hooks/useToast.ts b/frontend/src/hooks/useToast.ts new file mode 100644 index 0000000..29da11f --- /dev/null +++ b/frontend/src/hooks/useToast.ts @@ -0,0 +1,40 @@ +import { useState, useEffect, useCallback } from 'react'; +import { CheckCircle, XCircle, X } from 'lucide-react'; + +export interface Toast { + message: string; + type: 'success' | 'error'; +} + +export function useToast() { + const [toast, setToast] = useState(null); + + useEffect(() => { + if (!toast) return; + const t = setTimeout(() => setToast(null), 3000); + return () => clearTimeout(t); + }, [toast]); + + const showToast = useCallback((message: string, type: 'success' | 'error' = 'success') => { + setToast({ message, type }); + }, []); + + const ToastComponent = () => { + if (!toast) return null; + const isSuccess = toast.type === 'success'; + return ( +
+ {isSuccess ? : } + {toast.message} + +
+ ); + }; + + return { toast, showToast, ToastComponent }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index fd22345..71c5f52 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -186,3 +186,14 @@ background: #9ca3af; } } + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/frontend/src/lib/mock.ts b/frontend/src/lib/mock.ts index 178252b..e61112c 100644 --- a/frontend/src/lib/mock.ts +++ b/frontend/src/lib/mock.ts @@ -1,181 +1,4 @@ -import type { Ticket, DBInstance, QueryAudit, AuditRule, Workflow, NotificationChannel, Role, User, PermissionNode, DashboardStats } from '@/types'; - -export const dashboardStats: DashboardStats = { - ticket_total: 128, - ticket_pending: 23, - ticket_month: 342, - ticket_executing: 3, - instance_total: 11, - instance_online: 8, - user_total: 7, - queries_today: 15234, -}; - -export const menuSections = [ - { id: 'overview', label: '概览' }, - { id: 'datasource', label: '数据源' }, - { id: 'ticket', label: '工单管理' }, - { id: 'permission', label: '权限管理' }, - { id: 'system', label: '系统配置' }, - { id: 'config', label: '配置平台' }, -]; - -export const menuItems = [ - { id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'guest'] }, - { id: 'instances', label: '数据源', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] }, - { id: 'instance-groups', label: '权限管理', icon: 'Lock', section: 'permission', roles: ['admin'] }, - { id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer'] }, - { id: 'queries', label: '查询数据', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] }, - { id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] }, - { id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'] }, - { id: 'audit-logs', label: '审计日志', icon: 'FileText', section: 'system', roles: ['admin', 'dba'] }, - { id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'system', roles: ['admin', 'dba'] }, - { id: 'auto-task', label: '自动任务', icon: 'Clock', section: 'system', roles: ['admin'] }, - { id: 'settings', label: '系统设置', icon: 'Settings', section: 'config', roles: ['admin'] }, -]; - -export const currentUser = { - id: 1, - username: 'admin', - email: 'admin@company.com', - role: 'admin' as 'admin' | 'dba' | 'developer' | 'guest', - roleName: '超级管理员', - avatar: 'A', - permissions: menuItems.map(m => m.id), -}; - -export const dbInstances: DBInstance[] = [ - { id: 1, name: '生产主库', host: '192.168.1.10', port: 3306, status: 'online', dbType: 'mysql', version: '8.0.35', env: 'prod', connections: 245, qps: 3250, latency: 2.5, cpu: 45, memory: 68, disk: 72, uptime: '120天', tags: ['主库', '核心'] }, - { id: 2, name: '生产从库-01', host: '192.168.1.11', port: 3306, status: 'online', dbType: 'mysql', version: '8.0.35', env: 'prod', connections: 189, qps: 2800, latency: 3.1, cpu: 38, memory: 55, disk: 65, uptime: '120天', tags: ['从库'] }, - { id: 3, name: '生产从库-02', host: '192.168.1.12', port: 3306, status: 'online', dbType: 'mysql', version: '8.0.35', env: 'prod', connections: 192, qps: 2750, latency: 2.8, cpu: 40, memory: 58, disk: 68, uptime: '120天', tags: ['从库'] }, - { id: 4, name: '测试库', host: '192.168.2.10', port: 3306, status: 'warning', dbType: 'mysql', version: '8.0.33', env: 'test', connections: 45, qps: 120, latency: 15.2, cpu: 82, memory: 90, disk: 45, uptime: '30天', tags: ['测试'] }, - { id: 5, name: '开发库', host: '192.168.3.10', port: 3306, status: 'online', dbType: 'mysql', version: '8.0.33', env: 'dev', connections: 23, qps: 80, latency: 1.2, cpu: 15, memory: 30, disk: 25, uptime: '15天', tags: ['开发'] }, - { id: 6, name: '归档库', host: '192.168.1.50', port: 3306, status: 'offline', dbType: 'mysql', version: '8.0.30', env: 'prod', connections: 0, qps: 0, latency: 0, cpu: 0, memory: 0, disk: 88, uptime: '-', tags: ['归档'] }, - { id: 7, name: 'PG 主库', host: '192.168.1.20', port: 5432, status: 'online', dbType: 'postgres', version: '15.4', env: 'prod', connections: 156, qps: 2100, latency: 1.8, cpu: 35, memory: 60, disk: 55, uptime: '200天', tags: ['主库'] }, - { id: 8, name: 'PG 分析库', host: '192.168.1.21', port: 5432, status: 'warning', dbType: 'postgres', version: '15.4', env: 'prod', connections: 89, qps: 980, latency: 8.5, cpu: 78, memory: 85, disk: 70, uptime: '200天', tags: ['分析'] }, - { id: 9, name: 'Redis 集群-01', host: '192.168.1.30', port: 6379, status: 'online', dbType: 'redis', version: '7.2.3', env: 'prod', connections: 1200, qps: 45000, latency: 0.5, cpu: 25, memory: 75, disk: 10, uptime: '90天', tags: ['集群'] }, - { id: 10, name: 'Redis 集群-02', host: '192.168.1.31', port: 6379, status: 'online', dbType: 'redis', version: '7.2.3', env: 'prod', connections: 1150, qps: 42000, latency: 0.6, cpu: 28, memory: 72, disk: 10, uptime: '90天', tags: ['集群'] }, - { id: 11, name: 'MongoDB 分片-01', host: '192.168.1.40', port: 27017, status: 'online', dbType: 'mongo', version: '7.0.4', env: 'prod', connections: 340, qps: 1800, latency: 3.2, cpu: 42, memory: 65, disk: 60, uptime: '60天', tags: ['分片'] }, -]; - -export const tickets: Ticket[] = [ - { - id: 'TK-20240115-001', title: '更新用户状态字段', dbType: 'mysql', instance: '生产主库', applicant: '张三', status: 'pending', priority: 'high', - sql: 'UPDATE users SET status = \'active\', updated_at = NOW() WHERE id IN (1001, 1002, 1003) AND status != \'active\';', - rollbackSql: "-- 基于 Binlog ROW 格式逆向生成的回滚语句\n-- 使用主键精确定位恢复旧值\nUPDATE users SET status = 'inactive', updated_at = NOW() WHERE id IN (1001, 1002, 1003);\n\n-- 备选:从备份库恢复\n-- UPDATE users u JOIN $_$Inception_backup_information$_$ b ON u.id = b.pk_value\n-- SET u.status = b.old_status WHERE b.opid_time = '20240115093025';", - reason: '批量激活过期用户账号', createTime: '2024-01-15 09:30:00', updateTime: '2024-01-15 09:30:00', - auditScore: 92, auditResults: [ - { rule: '禁止无 WHERE 条件的 UPDATE/DELETE', level: 'success', description: 'WHERE 条件存在,符合规范' }, - { rule: '大表变更警告', level: 'warning', description: '目标表 users 数据量超过 100 万行', suggestion: '建议分批执行,每次不超过 1000 条' }, - { rule: 'SQL 语法检查', level: 'success', description: '语法正确' }, - ], - workflowSteps: [ - { name: '提交', status: 'completed', time: '09:30', assignee: '张三' }, - { name: '一级审批', status: 'completed', time: '10:15', assignee: '李四' }, - { name: '二级审批', status: 'active', assignee: '王五' }, - { name: '执行', status: 'pending' }, - ], - approvalRecords: [ - { step: 1, approver: '张三', action: 'approve', comment: '提交工单', time: '2024-01-15 09:30:25' }, - { step: 2, approver: '李四', action: 'approve', comment: 'SQL 符合规范,影响范围可控,同意执行', time: '2024-01-15 10:15:38' }, - ], - }, - { - id: 'TK-20240115-002', title: '清理过期日志数据', dbType: 'postgres', instance: '日志库', applicant: '李四', status: 'reviewing', priority: 'medium', - sql: 'DELETE FROM system_logs WHERE created_at < DATE_TRUNC(\'month\', NOW() - INTERVAL \'3 months\');', - rollbackSql: '-- DDL 回滚支持有限,建议先备份再执行\n-- 已从备份库 system_logs_bak 恢复数据\n-- INSERT INTO system_logs SELECT * FROM system_logs_bak WHERE created_at < DATE_TRUNC(\'month\', NOW() - INTERVAL \'3 months\');', - reason: '清理3个月前的系统日志,释放磁盘空间', createTime: '2024-01-15 10:15:00', updateTime: '2024-01-15 10:15:00', - auditScore: 85, auditResults: [ - { rule: 'DELETE 操作检查', level: 'warning', description: 'DELETE 操作影响行数可能较大', suggestion: '建议先 SELECT COUNT(*) 确认影响范围' }, - { rule: '备份检查', level: 'success', description: '已确认数据已备份' }, - ], - workflowSteps: [ - { name: '提交', status: 'completed', time: '10:15', assignee: '李四' }, - { name: '一级审批', status: 'active', assignee: '张三' }, - { name: '执行', status: 'pending' }, - ], - approvalRecords: [ - { step: 1, approver: '李四', action: 'approve', comment: '提交工单', time: '2024-01-15 10:15:00' }, - ], - }, - { - id: 'TK-20240115-003', title: 'Redis 缓存预热', dbType: 'redis', instance: 'Redis集群', applicant: '王五', status: 'pending', priority: 'low', - sql: 'MSET user:1001:profile \'{"name":"张三","level":5}\' user:1002:profile \'{"name":"李四","level":3}\'', - reason: '大促前预热用户缓存数据', createTime: '2024-01-15 11:00:00', updateTime: '2024-01-15 11:00:00', - auditScore: 98, auditResults: [ - { rule: 'Redis 命令检查', level: 'success', description: 'MSET 命令安全' }, - { rule: '数据大小检查', level: 'success', description: '单条数据小于 1MB' }, - ], - }, - { id: 'TK-20240114-005', title: '用户表添加索引', dbType: 'mysql', instance: '生产主库', applicant: '赵六', status: 'approved', priority: 'high', sql: 'ALTER TABLE users ADD INDEX idx_phone (phone);', reason: '优化手机号查询性能', createTime: '2024-01-14 14:20:00', updateTime: '2024-01-14 16:30:00' }, - { id: 'TK-20240114-006', title: '订单数据归档', dbType: 'mysql', instance: '归档库', applicant: '钱七', status: 'executing', priority: 'medium', sql: 'INSERT INTO orders_archive SELECT * FROM orders WHERE created_at < "2023-06-01";', rollbackSql: '-- 从归档表删除已归档的数据\nDELETE FROM orders_archive WHERE created_at < "2023-06-01";', reason: '归档半年前的订单数据', createTime: '2024-01-14 15:30:00', updateTime: '2024-01-15 09:00:00' }, - { id: 'TK-20240114-007', title: '配置表更新', dbType: 'redis', instance: 'Redis集群', applicant: '孙八', status: 'success', priority: 'low', sql: 'HSET app:config feature_flag new_feature enabled', reason: '开启新功能开关', createTime: '2024-01-14 16:00:00', updateTime: '2024-01-14 16:05:00' }, - { id: 'TK-20240114-008', title: '订单表索引优化', dbType: 'mysql', instance: '生产主库', applicant: '周九', status: 'failed', priority: 'high', sql: 'ALTER TABLE orders ADD INDEX idx_created_at (created_at, status);', reason: '优化订单查询', createTime: '2024-01-14 16:45:00', updateTime: '2024-01-14 17:00:00' }, - { id: 'TK-20240113-001', title: '用户权限调整', dbType: 'postgres', instance: '权限库', applicant: '吴十', status: 'rejected', priority: 'medium', sql: 'UPDATE user_roles SET role = \'admin\' WHERE user_id = 456;', reason: '调整用户权限', createTime: '2024-01-13 09:00:00', updateTime: '2024-01-13 11:30:00' }, -]; - -export const queryAudits: QueryAudit[] = [ - { id: 'Q-20240115-001', sql: 'SELECT u.id, u.name, u.email, u.phone, u.status, u.created_at FROM users u WHERE u.status = "active" ORDER BY u.created_at DESC LIMIT 100', sqlPreview: 'SELECT u.id, u.name, u.email...', dbType: 'mysql', instance: '生产主库', user: '张三', executeTime: 125, rowCount: 100, scanRows: 50000, affectedRows: 0, status: 'success', hasSensitive: true, timestamp: '2024-01-15 10:30:25', clientIp: '192.168.10.50', source: 'Web Console', resultColumns: [{ prop: 'id', label: 'ID' }, { prop: 'name', label: '姓名' }, { prop: 'email', label: '邮箱', sensitive: true }, { prop: 'phone', label: '电话', sensitive: true }, { prop: 'status', label: '状态' }, { prop: 'created_at', label: '创建时间' }], resultSet: [{ id: 1001, name: '张三', email: 'zhang***@email.com', phone: '138****8888', status: 'active', created_at: '2024-01-15' }, { id: 1002, name: '李四', email: 'li***@email.com', phone: '139****9999', status: 'active', created_at: '2024-01-14' }, { id: 1003, name: '王五', email: 'wang***@email.com', phone: '137****7777', status: 'active', created_at: '2024-01-13' }] }, - { id: 'Q-20240115-002', sql: 'SELECT COUNT(*) as total, DATE(created_at) as date FROM orders WHERE created_at > "2024-01-01" GROUP BY DATE(created_at)', sqlPreview: 'SELECT COUNT(*) as total...', dbType: 'mysql', instance: '生产主库', user: '李四', executeTime: 2500, rowCount: 15, scanRows: 1000000, affectedRows: 0, status: 'slow', hasSensitive: false, timestamp: '2024-01-15 10:28:15', clientIp: '192.168.10.51', source: 'API' }, - { id: 'Q-20240115-003', sql: 'UPDATE users SET last_login = NOW(), login_ip = "192.168.10.50" WHERE id = 123', sqlPreview: 'UPDATE users SET...', dbType: 'mysql', instance: '生产主库', user: '系统', executeTime: 45, rowCount: 0, scanRows: 1, affectedRows: 1, status: 'success', hasSensitive: false, timestamp: '2024-01-15 10:25:08', clientIp: '192.168.1.100', source: 'Application' }, - { id: 'Q-20240115-004', sql: 'SELECT * FROM user_profiles WHERE user_id = 456', sqlPreview: 'SELECT * FROM user_profiles...', dbType: 'postgres', instance: 'PG 分析库', user: '赵六', executeTime: 89, rowCount: 5, scanRows: 100, affectedRows: 0, status: 'success', hasSensitive: true, timestamp: '2024-01-15 10:20:33', clientIp: '192.168.10.52', source: 'Web Console', resultColumns: [{ prop: 'user_id', label: '用户ID' }, { prop: 'id_card', label: '身份证号', sensitive: true }, { prop: 'address', label: '地址', sensitive: true }], resultSet: [{ user_id: 456, id_card: '310***********1234', address: '上海市***区***路***号' }] }, - { id: 'Q-20240115-005', sql: 'DELETE FROM temp_logs WHERE created_at < "2024-01-01"', sqlPreview: 'DELETE FROM temp_logs...', dbType: 'mysql', instance: '测试库', user: '钱七', executeTime: 5000, rowCount: 0, scanRows: 500000, affectedRows: 0, status: 'failed', hasSensitive: false, timestamp: '2024-01-15 10:15:42', clientIp: '192.168.10.53', source: 'CLI' }, - { id: 'Q-20240115-006', sql: 'GET user:session:123', sqlPreview: 'GET user:session:123', dbType: 'redis', instance: 'Redis集群-01', user: '孙八', executeTime: 2, rowCount: 1, scanRows: 1, affectedRows: 0, status: 'success', hasSensitive: false, timestamp: '2024-01-15 10:10:18', clientIp: '192.168.10.54', source: 'Application' }, - { id: 'Q-20240115-007', sql: 'SELECT o.id, o.order_no, u.name, o.amount, o.status, o.created_at FROM orders o JOIN users u ON o.user_id = u.id WHERE o.created_at > DATE_SUB(NOW(), INTERVAL 7 DAY) ORDER BY o.created_at DESC LIMIT 50', sqlPreview: 'SELECT o.id, o.order_no, u.name...', dbType: 'mysql', instance: '生产主库', user: '周九', executeTime: 3200, rowCount: 50, scanRows: 800000, affectedRows: 0, status: 'slow', hasSensitive: true, timestamp: '2024-01-15 10:05:22', clientIp: '192.168.10.55', source: 'Web Console', resultColumns: [{ prop: 'id', label: 'ID' }, { prop: 'order_no', label: '订单号' }, { prop: 'name', label: '用户名' }, { prop: 'amount', label: '金额' }, { prop: 'status', label: '状态' }], resultSet: [{ id: 50001, order_no: 'ORD-20240115-001', name: '张三', amount: '¥1,299.00', status: '已支付' }, { id: 50002, order_no: 'ORD-20240115-002', name: '李四', amount: '¥2,599.00', status: '待发货' }] }, - { id: 'Q-20240115-008', sql: 'db.collection("logs").find({ created_at: { $gte: new Date("2024-01-01") } }).limit(100)', sqlPreview: 'db.collection("logs").find...', dbType: 'mongo', instance: 'MongoDB 分片-01', user: '吴十', executeTime: 156, rowCount: 100, scanRows: 50000, affectedRows: 0, status: 'success', hasSensitive: false, timestamp: '2024-01-15 09:55:10', clientIp: '192.168.10.56', source: 'DataGrip' }, -]; - -export const auditRules: AuditRule[] = [ - { id: 'R000', name: '表必须有主键(回滚依赖)', level: 'error', dbTypes: ['mysql'], description: '操作的表必须包含主键,否则无法生成精确的回滚语句。回滚依赖主键定位修改的行。', enabled: true, category: '回滚' }, - { id: 'R001', name: '禁止无 WHERE 条件的 UPDATE/DELETE', level: 'error', dbTypes: ['mysql', 'postgres'], description: 'UPDATE 和 DELETE 语句必须包含 WHERE 条件,防止误操作全表数据', enabled: true, category: '安全性' }, - { id: 'R002', name: '禁止 SELECT * 查询', level: 'warning', dbTypes: ['mysql', 'postgres'], description: 'SELECT 语句应明确指定字段名,避免不必要的数据传输', enabled: true, category: '性能' }, - { id: 'R003', name: '大表变更警告(>100万行)', level: 'warning', dbTypes: ['mysql', 'postgres'], description: '影响超过 100 万行的变更需要额外审批,建议使用 pt-online-schema-change', enabled: true, category: '性能' }, - { id: 'R004', name: '禁止 DROP TABLE/DATABASE', level: 'error', dbTypes: ['mysql', 'postgres', 'mongo'], description: '禁止执行 DROP TABLE/DATABASE 操作,防止数据丢失', enabled: true, category: '安全性' }, - { id: 'R005', name: 'SQL 注入风险检测', level: 'error', dbTypes: ['mysql', 'postgres', 'redis'], description: '检测潜在的 SQL 注入风险,包括拼接字符串等', enabled: true, category: '安全性' }, - { id: 'R006', name: '索引使用检查', level: 'info', dbTypes: ['mysql', 'postgres'], description: '检查查询是否使用了索引,避免全表扫描', enabled: false, category: '性能' }, - { id: 'R007', name: '禁止 TRUNCATE TABLE', level: 'error', dbTypes: ['mysql', 'postgres'], description: 'TRUNCATE 操作会清空整表数据且无法回滚', enabled: true, category: '安全性' }, - { id: 'R008', name: '字段类型变更警告', level: 'warning', dbTypes: ['mysql', 'postgres'], description: 'ALTER TABLE 修改字段类型可能导致数据截断', enabled: true, category: '数据完整性' }, - { id: 'R009', name: '外键约束检查', level: 'info', dbTypes: ['mysql', 'postgres'], description: '建议重要关联表添加外键约束保证数据一致性', enabled: false, category: '数据完整性' }, - { id: 'R010', name: 'Redis 大键检测(>1MB)', level: 'warning', dbTypes: ['redis'], description: '检测到 Redis 大键,可能影响性能', enabled: true, category: '性能' }, - { id: 'R011', name: 'Binlog ROW 格式检查', level: 'error', dbTypes: ['mysql'], description: 'MySQL 必须开启 Binlog 且格式为 ROW,否则无法生成回滚语句', enabled: true, category: '回滚' }, - { id: 'R012', name: '禁止 DML 与 DDL 混合操作', level: 'error', dbTypes: ['mysql'], description: '同一工单中不能同时包含 DML 和 DDL 语句,避免表结构变化导致备份解析出错', enabled: true, category: '回滚' }, - { id: 'R013', name: '回滚权限检查', level: 'error', dbTypes: ['mysql'], description: '执行回滚的数据库账号需有 REPLICATION SLAVE 和 REPLICATION CLIENT 权限', enabled: true, category: '回滚' }, - { id: 'R014', name: 'DDL 回滚限制警告', level: 'warning', dbTypes: ['mysql'], description: 'DROP/TRUNCATE 等 DDL 操作回滚仅能重建表结构,无法恢复原有数据', enabled: true, category: '回滚' }, - { id: 'R015', name: '影响行数限制(<10000)', level: 'warning', dbTypes: ['mysql', 'postgres'], description: '单次操作影响行数建议不超过 10000 行,过大的事务可能导致回滚困难和性能问题', enabled: true, category: '回滚' }, -]; - -export const workflows: Workflow[] = [ - { id: 1, name: '生产环境变更流程', dbTypes: ['mysql', 'postgres', 'redis'], steps: 3, mode: 'all', description: '需要 DBA 和架构师双重审批,适用于所有生产环境变更', enabled: true, conditions: ['影响行数>1000', '执行时间>1s'] }, - { id: 2, name: '测试环境变更流程', dbTypes: ['mysql', 'postgres', 'redis', 'mongo'], steps: 1, mode: 'any', description: '仅需一级审批,适用于测试环境', enabled: true }, - { id: 3, name: '紧急变更流程', dbTypes: ['mysql', 'postgres'], steps: 2, mode: 'any', description: 'P0/P1 故障时的快速审批通道,30分钟内完成', enabled: true, conditions: ['故障等级>P1'] }, - { id: 4, name: '数据查询流程', dbTypes: ['mysql', 'postgres'], steps: 1, mode: 'any', description: '敏感数据查询审批,需数据负责人审批', enabled: false }, - { id: 5, name: 'DDL 变更流程', dbTypes: ['mysql', 'postgres'], steps: 2, mode: 'all', description: '表结构变更专用流程,需架构师评审', enabled: true, conditions: ['涉及DDL操作'] }, -]; - -export const notificationChannels: NotificationChannel[] = [ - { id: 1, name: '飞书', type: 'feishu', icon: 'Send', description: '通过飞书自定义机器人发送通知', enabled: true, webhook: 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx', events: ['ticket_created', 'ticket_approved', 'ticket_rejected', 'ticket_executed', 'ticket_failed'] }, - { id: 2, name: '钉钉', type: 'dingtalk', icon: 'Bell', description: '通过钉钉群机器人发送通知', enabled: true, webhook: 'https://oapi.dingtalk.com/robot/send?access_token=xxx', events: ['ticket_created', 'ticket_approved', 'ticket_failed'] }, - { id: 3, name: '企业微信', type: 'wechat', icon: 'MessageSquare', description: '通过企业微信Webhook发送通知', enabled: false, webhook: '', events: ['ticket_created', 'ticket_executed'] }, - { id: 4, name: 'Telegram', type: 'telegram', icon: 'Navigation', description: '通过 Telegram Bot 发送通知', enabled: false, webhook: '', events: ['ticket_failed'] }, - { id: 5, name: '邮件通知', type: 'email', icon: 'Mail', description: '通过 SMTP 邮件发送通知', enabled: true, events: ['ticket_created', 'ticket_approved', 'ticket_rejected', 'ticket_executed'] }, -]; - -export const roles: Role[] = [ - { id: 'admin', name: '超级管理员', description: '拥有系统的所有权限,包括用户管理、系统配置等', icon: 'Crown', iconClass: 'text-purple-400 bg-purple-400/10', userCount: 2, permissionCount: 56, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'queries', 'roles', 'users', 'audit-rules', 'audit-logs', 'workflow', 'auto-task', 'announcement', 'config-platform', 'notifications'] }, - { id: 'dba', name: '数据库管理员', description: '负责数据库运维、工单审批和 SQL 审核', icon: 'Database', iconClass: 'text-blue-400 bg-blue-400/10', userCount: 5, permissionCount: 32, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'tickets', 'queries', 'audit-rules', 'audit-logs', 'workflow'] }, - { id: 'developer', name: '开发人员', description: '可以提交变更工单和执行数据查询', icon: 'Code', iconClass: 'text-green-400 bg-green-400/10', userCount: 28, permissionCount: 12, permissions: ['dashboard', 'tickets', 'queries'] }, - { id: 'guest', name: '访客', description: '仅可查看,无法执行任何操作', icon: 'Eye', iconClass: 'text-gray-400 bg-gray-400/10', userCount: 8, permissionCount: 4, permissions: ['dashboard', 'queries'] }, -]; - -export const users: User[] = [ - { id: 1, username: 'admin', email: 'admin@company.com', role: 'admin', roleName: '超级管理员', department: '运维部', lastLogin: '2024-01-15 09:30:00', status: 'active', avatar: 'A' }, - { id: 2, username: 'zhangsan', email: 'zhangsan@company.com', role: 'dba', roleName: '数据库管理员', department: 'DBA组', lastLogin: '2024-01-15 08:45:00', status: 'active', avatar: '张' }, - { id: 3, username: 'lisi', email: 'lisi@company.com', role: 'dba', roleName: '数据库管理员', department: 'DBA组', lastLogin: '2024-01-14 18:20:00', status: 'active', avatar: '李' }, - { id: 4, username: 'wangwu', email: 'wangwu@company.com', role: 'developer', roleName: '开发人员', department: '研发部', lastLogin: '2024-01-15 10:15:00', status: 'active', avatar: '王' }, - { id: 5, username: 'zhaoliu', email: 'zhaoliu@company.com', role: 'developer', roleName: '开发人员', department: '研发部', lastLogin: '2024-01-14 16:30:00', status: 'disabled', avatar: '赵' }, - { id: 6, username: 'qianqi', email: 'qianqi@company.com', role: 'developer', roleName: '开发人员', department: '产品部', lastLogin: '2024-01-14 14:20:00', status: 'active', avatar: '钱' }, - { id: 7, username: 'sunba', email: 'sunba@company.com', role: 'guest', roleName: '访客', department: '测试部', lastLogin: '2024-01-13 11:00:00', status: 'active', avatar: '孙' }, -]; +import type { PermissionNode } from '@/types'; export const permissionTreeData: PermissionNode[] = [ { id: 'dashboard', label: '仪表盘(查看统计、图表)' }, @@ -240,62 +63,3 @@ export const dbTypeColorMap: Record = { redis: 'bg-[rgba(220,50,32,0.15)] text-[#ef5350]', mongo: 'bg-[rgba(71,162,72,0.15)] text-[#66bb6a]', }; - -export interface Announcement { - id: string; - title: string; - content: string; - priority: 'urgent' | 'high' | 'normal' | 'low'; - pinned: boolean; - enabled: boolean; - author: string; - publishTime: string; - expireTime: string; - readCount: number; - targetRoles: string[]; -} - -export const announcements: Announcement[] = [ - { - id: 'ann-1', title: '【系统维护】本周日凌晨 2:00-4:00 数据库升级维护', - content: '计划于本周日(2024年1月21日)凌晨 2:00-4:00 进行数据库版本升级,期间以下服务将不可用:\n1. 数据变更工单提交和审批\n2. 在线 SQL 查询\n3. 数据导出功能\n\n请提前安排工作,避免在维护时段提交重要工单。如有紧急情况,请联系 DBA 团队。', - priority: 'urgent', pinned: true, enabled: true, author: 'admin', - publishTime: '2024-01-15 10:00:00', expireTime: '2024-01-22 00:00:00', - readCount: 156, targetRoles: ['admin', 'dba', 'developer', 'guest'], - }, - { - id: 'ann-2', title: '【安全提醒】禁止使用 SELECT * 查询生产数据库', - content: '为加强数据安全,即日起严禁使用 SELECT * 查询生产环境数据库。所有查询必须明确指定所需字段名。\n\n审核规则已更新(规则编号 R002),违规查询将被自动拦截并记录审计日志。', - priority: 'high', pinned: true, enabled: true, author: 'admin', - publishTime: '2024-01-14 09:00:00', expireTime: '2024-02-14 00:00:00', - readCount: 234, targetRoles: ['admin', 'dba', 'developer'], - }, - { - id: 'ann-3', title: '【新功能】SQL 变更自动回滚功能已上线', - content: 'SQL 变更工单现已支持自动回滚功能。对于 UPDATE 和 DELETE 操作,系统会自动:\n1. 基于 Binlog ROW 格式生成逆向回滚语句\n2. 执行前备份受影响数据\n3. 提供一键回滚入口(执行后 72 小时内有效)\n\n使用要求:\n- MySQL 必须开启 Binlog ROW 格式\n- 操作表必须有主键\n- 单次操作影响行数不超过 10,000 行', - priority: 'normal', pinned: false, enabled: true, author: 'zhangsan', - publishTime: '2024-01-13 14:00:00', expireTime: '2024-04-13 00:00:00', - readCount: 89, targetRoles: ['admin', 'dba', 'developer'], - }, - { - id: 'ann-4', title: '【培训通知】SQL 审核规范培训 - 1月25日', - content: '时间:2024年1月25日 14:00-16:00\n地点:会议室 A\n讲师:DBA 团队负责人\n\n培训内容:\n1. SQL 变更工单提交流程\n2. 审核规则详解\n3. 回滚操作最佳实践\n4. 常见问题排查\n\n请相关人员准时参加。', - priority: 'normal', pinned: false, enabled: true, author: 'lisi', - publishTime: '2024-01-12 10:00:00', expireTime: '2024-01-26 00:00:00', - readCount: 67, targetRoles: ['developer'], - }, - { - id: 'ann-sql-1', title: '【规范】SQL 变更工单提交流程', - content: '1. 登录 SQL-Platform 平台,进入「数据变更」页面\n2. 点击「新建工单」,填写标题和变更原因\n3. 选择目标数据库类型和实例\n4. 在 SQL 编辑器中输入变更语句\n5. 系统自动审核并评分,确认无误后提交\n6. 等待 DBA 审批通过后执行\n\n注意:生产环境变更必须提供回滚 SQL!', - priority: 'high', pinned: true, enabled: true, author: 'admin', - publishTime: '2024-01-10 08:00:00', expireTime: '2025-12-31 00:00:00', - readCount: 512, targetRoles: ['admin', 'dba', 'developer'], - }, - { - id: 'ann-sql-2', title: '【注意事项】SQL 操作安全规范', - content: '禁止事项:\n- 禁止不带 WHERE 条件的 UPDATE/DELETE\n- 禁止在生产环境执行 DROP/TRUNCATE\n- 禁止在业务高峰期(10:00-12:00, 14:00-16:00)执行大表变更\n\n必须遵守:\n- 所有变更必须通过工单审批\n- 影响超过 10,000 行的变更需额外审批\n- DDL 和 DML 不可混在同一工单中\n- 执行前确认备份和回滚方案', - priority: 'urgent', pinned: true, enabled: true, author: 'admin', - publishTime: '2024-01-08 08:00:00', expireTime: '2025-12-31 00:00:00', - readCount: 689, targetRoles: ['admin', 'dba', 'developer', 'guest'], - }, -]; diff --git a/frontend/src/pages/AuditLogsPage.tsx b/frontend/src/pages/AuditLogsPage.tsx index 51ab4a1..58f4182 100644 --- a/frontend/src/pages/AuditLogsPage.tsx +++ b/frontend/src/pages/AuditLogsPage.tsx @@ -76,37 +76,6 @@ const TARGET_ICONS: Record = { const CHART_COLORS = ['#6366f1', '#a855f7', '#ec4899', '#f43f5e', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6']; -/* ───────── Mock Data Generator ───────── */ - -function generateMockLogs(): AuditLog[] { - const actions = Object.keys(ACTION_LABELS); - const targets: Record = { - CREATE_TICKET: 'ticket', REVIEW_TICKET: 'ticket', EXECUTE_TICKET: 'ticket', - CREATE_INSTANCE: 'instance', DELETE_INSTANCE: 'instance', - CREATE_USER: 'user', DELETE_USER: 'user', - QUERY: 'query', LOGIN: 'system', LOGOUT: 'system', - }; - const users = ['admin', 'zhangsan', 'lisi', 'wangwu', 'zhaoliu', 'qianqi', 'sunba']; - const ips = ['192.168.10.50', '192.168.10.51', '192.168.10.52', '10.0.0.15', '10.0.0.22']; - - return Array.from({ length: 45 }, (_, i) => { - const action = actions[i % actions.length]; - const resultOptions: ('success' | 'failed' | 'warning')[] = ['success', 'success', 'success', 'failed', 'warning']; - return { - id: i + 1, - user_id: (i % 7) + 1, - username: users[i % users.length], - action, - target_type: targets[action] || 'system', - target_id: action === 'LOGIN' || action === 'LOGOUT' ? null : i + 100, - detail: `${ACTION_LABELS[action]}操作,目标ID: ${i + 100}`, - ip_address: ips[i % ips.length], - result_status: resultOptions[i % resultOptions.length], - created_at: new Date(Date.now() - i * 3600000 * 3).toISOString(), - }; - }); -} - /* ───────── CSV Export Helper ───────── */ function exportLogsToCSV(logs: AuditLog[]) { @@ -156,15 +125,12 @@ export default function AuditLogsPage() { if (filterAction) params.set('action', filterAction); const res = await request.get(`/audit/logs?${params}`); const data = (res?.data ?? res ?? {}) as AuditLogResponse; - // Merge with mock data for enhanced fields - const mockData = generateMockLogs(); - const items = data.items || mockData; + const items = data.items || []; setLogs(items); - setTotal(data.total || items.length); + setTotal(data.total || 0); } catch { - const mockData = generateMockLogs(); - setLogs(mockData); - setTotal(mockData.length); + setLogs([]); + setTotal(0); } finally { setLoading(false); } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 70597f7..0f5c52b 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -7,7 +7,8 @@ import { Filter, Eye, BarChart3, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { dbInstances, statusMap, statusColorMap, dbTypeColorMap, tickets, announcements } from '@/lib/mock'; +import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/mock'; +import type { DBInstance } from '@/types'; import { useTheme } from '@/hooks/useTheme'; import { useAuth } from '@/hooks/useAuth'; import { dashboardApi } from '@/api/dashboard'; @@ -64,7 +65,7 @@ const trendDatasets: Record = { /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ -function calcHealthScore(inst: typeof dbInstances[0]): number { +function calcHealthScore(inst: DBInstance): number { if (inst.status === 'offline') return 0; let score = 100; if (inst.cpu > 80) score -= 25; diff --git a/frontend/src/pages/DataDictionaryPage.tsx b/frontend/src/pages/DataDictionaryPage.tsx index 5572827..09d167b 100644 --- a/frontend/src/pages/DataDictionaryPage.tsx +++ b/frontend/src/pages/DataDictionaryPage.tsx @@ -97,28 +97,6 @@ function highlightDDL(ddl: string): string { return highlighted; } -function generateMockRelations(tableName: string): TableRelation[] { - const relations: TableRelation[] = []; - if (tableName.includes('order')) { - relations.push( - { from_table: tableName, from_column: 'user_id', to_table: 'users', to_column: 'id', constraint_name: `${tableName}_user_id_fk` }, - { from_table: tableName, from_column: 'product_id', to_table: 'products', to_column: 'id', constraint_name: `${tableName}_product_id_fk` }, - ); - } - if (tableName.includes('user')) { - relations.push( - { from_table: 'orders', from_column: 'user_id', to_table: tableName, to_column: 'id', constraint_name: `orders_${tableName}_user_id_fk` }, - { from_table: 'comments', from_column: 'user_id', to_table: tableName, to_column: 'id', constraint_name: `comments_${tableName}_user_id_fk` }, - ); - } - if (tableName.includes('comment')) { - relations.push( - { from_table: tableName, from_column: 'post_id', to_table: 'posts', to_column: 'id', constraint_name: `${tableName}_post_id_fk` }, - ); - } - return relations; -} - /* ───────── Tree Component ───────── */ function DbTreeNode({ @@ -265,7 +243,7 @@ export default function DataDictionaryPage() { const relations = useMemo(() => { if (!selectedTable) return []; - return generateMockRelations(selectedTable); + return []; }, [selectedTable]); const handleExport = () => { diff --git a/frontend/src/pages/InstanceGroupPage.tsx b/frontend/src/pages/InstanceGroupPage.tsx index 398bace..111fd20 100644 --- a/frontend/src/pages/InstanceGroupPage.tsx +++ b/frontend/src/pages/InstanceGroupPage.tsx @@ -103,25 +103,6 @@ const DEFAULT_QUOTA: ResourceQuota = { max_queries_per_day: 10000, }; -/* ───────── Mock Change Logs ───────── */ - -function generateMockChangeLogs(groupId: number): ChangeLogEntry[] { - const actions = [ - { action: '修改权限配置', target: 'query:select, query:insert' }, - { action: '添加关联实例', target: '生产主库' }, - { action: '移除关联用户', target: 'wangwu' }, - { action: '更新资源配额', target: 'max_qps: 1000 -> 2000' }, - { action: '启用权限组', target: '-' }, - ]; - return actions.map((a, i) => ({ - id: `cl-${groupId}-${i}`, - user: ['admin', 'zhangsan', 'lisi'][i % 3], - action: a.action, - target: a.target, - created_at: new Date(Date.now() - i * 86400000).toISOString(), - })); -} - /* ───────── Main Component ───────── */ export default function InstanceGroupPage() { @@ -196,7 +177,7 @@ export default function InstanceGroupPage() { const changeLogs = useMemo(() => { if (!selectedId) return []; - return generateMockChangeLogs(selectedId); + return []; }, [selectedId]); const resetForm = () => { diff --git a/frontend/src/pages/InstancePage.tsx b/frontend/src/pages/InstancePage.tsx index 35c62b5..df9e44b 100644 --- a/frontend/src/pages/InstancePage.tsx +++ b/frontend/src/pages/InstancePage.tsx @@ -154,17 +154,7 @@ export default function InstancePage() { try { const res = await instanceApi.list({}); const data = Array.isArray(res) ? res : []; - /* Enrich with mock metrics for display */ - const enriched = data.map((inst: InstanceItem, idx: number) => ({ - ...inst, - connections: [245, 189, 192, 45, 23, 0, 156, 89, 1200, 1150, 340][idx] ?? 100, - qps: [3250, 2800, 2750, 120, 80, 0, 2100, 980, 45000, 42000, 1800][idx] ?? 500, - latency: [2.5, 3.1, 2.8, 15.2, 1.2, 0, 1.8, 8.5, 0.5, 0.6, 3.2][idx] ?? 5, - cpu: [45, 38, 40, 82, 15, 0, 35, 78, 25, 28, 42][idx] ?? 30, - memory: [68, 55, 58, 90, 30, 0, 60, 85, 75, 72, 65][idx] ?? 50, - disk: [72, 65, 68, 45, 25, 88, 55, 70, 10, 10, 60][idx] ?? 50, - })); - setInstances(enriched); + setInstances(data); } catch { /* keep existing */ } finally { diff --git a/frontend/src/pages/NotificationsPage.tsx b/frontend/src/pages/NotificationsPage.tsx index c0b008a..addd678 100644 --- a/frontend/src/pages/NotificationsPage.tsx +++ b/frontend/src/pages/NotificationsPage.tsx @@ -1,165 +1,179 @@ -import { useState } from 'react'; -import { Send, Bell, MessageSquare, Navigation, Mail, Check, ToggleLeft, ToggleRight, Webhook, Settings, TestTube } from 'lucide-react'; +import { useState, useEffect, useCallback } from 'react'; +import { Bell, Webhook, Check, Settings, TestTube, ToggleLeft, ToggleRight, Loader2, AlertCircle, Plus, Trash2 } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; -import { notificationChannels } from '@/lib/mock'; +import { notificationApi } from '@/api/notification'; -const iconMap: Record = { - Send, Bell, MessageSquare, Navigation, Mail, +interface NotificationChannel { + id: number; + name: string; + channel_type: string; + config: Record; + is_enabled: boolean; + created_at: string; + updated_at: string; +} + +const channelStyle: Record = { + webhook_feishu: { bg: 'bg-[rgba(0,150,255,0.15)]', color: 'text-[#00a8ff]', label: '飞书' }, + dingtalk: { bg: 'bg-[rgba(0,160,255,0.15)]', color: 'text-[#0089ff]', label: '钉钉' }, + email: { bg: 'bg-[rgba(220,50,32,0.15)]', color: 'text-[#ef5350]', label: '邮件' }, }; -const channelStyle: Record = { - feishu: { bg: 'bg-[rgba(0,150,255,0.15)]', color: 'text-[#00a8ff]' }, - dingtalk: { bg: 'bg-[rgba(0,160,255,0.15)]', color: 'text-[#0089ff]' }, - wechat: { bg: 'bg-[rgba(7,193,96,0.15)]', color: 'text-[#07c160]' }, - telegram: { bg: 'bg-[rgba(0,136,204,0.15)]', color: 'text-[#0088cc]' }, - email: { bg: 'bg-[rgba(220,50,32,0.15)]', color: 'text-[#ef5350]' }, -}; - -const allEvents = [ - { key: 'ticket_created', label: '工单创建' }, - { key: 'ticket_approved', label: '审批通过' }, - { key: 'ticket_rejected', label: '审批拒绝' }, - { key: 'ticket_executed', label: '执行完成' }, - { key: 'ticket_failed', label: '执行失败' }, - { key: 'ticket_rollback', label: '工单回滚' }, -]; - export default function NotificationsPage() { - const [channels, setChannels] = useState(notificationChannels); - const [editChannel, setEditChannel] = useState(null); + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [testingId, setTestingId] = useState(null); + const [testResult, setTestResult] = useState<{ id: number; success: boolean; message: string } | null>(null); - const toggleChannel = (id: number) => { - setChannels(prev => prev.map(c => c.id === id ? { ...c, enabled: !c.enabled } : c)); - }; + const fetchChannels = useCallback(async () => { + try { + setLoading(true); + setError(null); + const data = await notificationApi.list(); + setChannels(data); + } catch (err: any) { + setError(err?.response?.data?.detail || '获取通知渠道失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchChannels(); + }, [fetchChannels]); + + const toggleChannel = useCallback(async (id: number) => { + const ch = channels.find(c => c.id === id); + if (!ch) return; + try { + await notificationApi.update(id, { is_enabled: !ch.is_enabled }); + setChannels(prev => prev.map(c => c.id === id ? { ...c, is_enabled: !c.is_enabled } : c)); + } catch {} + }, [channels]); + + const handleTest = useCallback(async (id: number) => { + setTestingId(id); + setTestResult(null); + try { + const res = await notificationApi.test(id); + setTestResult({ id, success: res.success, message: res.message }); + } catch (err: any) { + setTestResult({ id, success: false, message: err?.response?.data?.detail || '测试失败' }); + } finally { + setTestingId(null); + setTimeout(() => setTestResult(null), 5000); + } + }, []); + + const handleDelete = useCallback(async (id: number) => { + if (!confirm('确认删除该通知渠道?')) return; + try { + await notificationApi.delete(id); + setChannels(prev => prev.filter(c => c.id !== id)); + } catch {} + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +

{error}

+ +
+ ); + } return (

通知渠道配置

-

配置多渠道通知,支持飞书、钉钉、企业微信、Telegram

+

配置多渠道通知,支持飞书、钉钉、邮件

-
-
- {[ - { label: '渠道总数', value: channels.length, icon: Webhook, color: 'text-blue-400', bg: 'bg-blue-400/10' }, - { label: '已启用', value: channels.filter(c => c.enabled).length, icon: Check, color: 'text-green-400', bg: 'bg-green-400/10' }, - { label: '未启用', value: channels.filter(c => !c.enabled).length, icon: Bell, color: 'text-gray-400', bg: 'bg-gray-400/10' }, - ].map((s, i) => ( - - -
-
{s.value}
{s.label}
-
-
- ))} -
- -
- {channels.map(channel => { - const Icon = iconMap[channel.icon] || Bell; - const style = channelStyle[channel.type] || channelStyle.email; - return ( -
setEditChannel(channel)} - > -
-
-
- -
-
-
{channel.name}
-
{channel.description}
- {channel.enabled && channel.events && ( -
- {channel.events.slice(0, 3).map(e => ( - {allEvents.find(a => a.key === e)?.label || e} - ))} - {channel.events.length > 3 && +{channel.events.length - 3}} -
- )} -
-
-
- - {channel.enabled ? '已启用' : '未启用'} - - -
-
-
- ); - })} -
- - {/* 编辑弹窗 */} - {editChannel && ( -
setEditChannel(null)}> -
e.stopPropagation()}> -

配置 {editChannel.name}

-
-
- - -
-
- - -
-
- -
- {allEvents.map(evt => ( - - ))} -
-
-
- - -
-
-
- - - -
+ {channels.length === 0 ? ( +
+ +

暂无通知渠道,请联系管理员配置

+
+ ) : ( + <> +
+ {[ + { label: '渠道总数', value: channels.length, icon: Webhook, color: 'text-blue-400', bg: 'bg-blue-400/10' }, + { label: '已启用', value: channels.filter(c => c.is_enabled).length, icon: Check, color: 'text-green-400', bg: 'bg-green-400/10' }, + { label: '未启用', value: channels.filter(c => !c.is_enabled).length, icon: Bell, color: 'text-gray-400', bg: 'bg-gray-400/10' }, + ].map((s, i) => ( + + +
+
{s.value}
{s.label}
+
+
+ ))}
-
+ +
+ {channels.map(channel => { + const style = channelStyle[channel.channel_type] || { bg: 'bg-gray-400/10', color: 'text-gray-400', label: channel.channel_type }; + return ( +
+
+
+
+ +
+
+
{channel.name}
+
+ {style.label} + {channel.config?.webhook_url && {channel.config.webhook_url.substring(0, 40)}...} +
+
+
+
+ + + +
+
+ {testResult && testResult.id === channel.id && ( +
+ {testResult.message} +
+ )} +
+ ); + })} +
+ )}
); diff --git a/frontend/src/pages/PlatformConfigPage.tsx b/frontend/src/pages/PlatformConfigPage.tsx new file mode 100644 index 0000000..3f08834 --- /dev/null +++ b/frontend/src/pages/PlatformConfigPage.tsx @@ -0,0 +1,209 @@ +import { useState, useEffect, useCallback } from 'react'; +import api from '../api/request'; + +/* ── 类型 ── */ +interface SettingItem { + key: string; + value: string; + value_type: string; + group: string; + label: string; + description: string | null; + default_value: string | null; + sort_order: number; +} + +type SettingsMap = Record; + +/* ── 分组元信息 ── */ +const GROUP_META: Record = { + query: { label: '查询控制', icon: '🔍', desc: '查询权限、LIMIT 上限、超时设置' }, + security: { label: '安全策略', icon: '🔒', desc: '登录安全、密码策略、审计日志' }, + ticket: { label: '工单流程', icon: '📋', desc: '审批流程、回滚控制' }, +}; + +export default function PlatformConfigPage() { + const [settings, setSettings] = useState({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [changed, setChanged] = useState>({}); + const [toast, setToast] = useState(''); + + const fetchSettings = useCallback(async () => { + try { + const res = await api.get('/settings'); + setSettings(res as SettingsMap); + } catch { + setToast('加载设置失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchSettings(); }, [fetchSettings]); + + const handleChange = (key: string, value: string) => { + setChanged(prev => ({ ...prev, [key]: value })); + }; + + const handleSave = async () => { + if (Object.keys(changed).length === 0) return; + setSaving(true); + try { + const items = Object.entries(changed).map(([key, value]) => ({ key, value })); + await api.put('/settings', items); + setChanged({}); + setToast('保存成功'); + await fetchSettings(); + } catch (e: any) { + setToast(e?.response?.data?.detail || '保存失败'); + } finally { + setSaving(false); + setTimeout(() => setToast(''), 3000); + } + }; + + const handleReset = (key: string, defaultValue: string | null) => { + if (defaultValue) handleChange(key, defaultValue); + }; + + const displayValue = (item: SettingItem) => changed[item.key] ?? item.value; + + if (loading) { + return
加载中...
; + } + + const groupKeys = Object.keys(settings); + const hasChanges = Object.keys(changed).length > 0; + + return ( +
+ {/* Toast */} + {toast && ( +
+ {toast} +
+ )} + + {/* 各分组 */} + {groupKeys.map(group => { + const meta = GROUP_META[group] || { label: group, icon: '⚙️', desc: '' }; + const items = settings[group] || []; + + return ( +
+ {/* 分组头 */} +
+ {meta.icon} +
+

{meta.label}

+

{meta.desc}

+
+
+ + {/* 设置项列表 */} +
+ {items.map(item => { + const val = displayValue(item); + const isChanged = changed[item.key] !== undefined; + + return ( +
+
+
{item.label}
+ {item.description && ( +
{item.description}
+ )} +
+ +
+ {/* 重置按钮 */} + {isChanged && item.default_value && ( + + )} + + {/* 布尔 → 开关 */} + {item.value_type === 'boolean' && ( + + )} + + {/* 整数 → 数字输入 */} + {item.value_type === 'integer' && ( + handleChange(item.key, e.target.value)} + className={`w-24 px-2 py-1 rounded-lg text-sm text-right bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)] outline-none ${ + isChanged ? 'ring-1 ring-emerald-500/50' : '' + }`} + /> + )} + + {/* 字符串 → 文本 */} + {item.value_type === 'string' && ( + handleChange(item.key, e.target.value)} + className={`w-48 px-2 py-1 rounded-lg text-sm bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)] outline-none ${ + isChanged ? 'ring-1 ring-emerald-500/50' : '' + }`} + /> + )} + + {/* 变更指示 */} + {isChanged && ( + + )} +
+
+ ); + })} +
+
+ ); + })} + + {/* 保存按钮 */} + {hasChanges && ( +
+ + + + 已修改 {Object.keys(changed).length} 项 + +
+ )} +
+ ); +} diff --git a/frontend/src/pages/RolesPage.tsx b/frontend/src/pages/RolesPage.tsx index fa4a464..9523af8 100644 --- a/frontend/src/pages/RolesPage.tsx +++ b/frontend/src/pages/RolesPage.tsx @@ -1,15 +1,25 @@ import { useState, useMemo, useCallback } from 'react'; import { - Crown, Database, Code, Eye, Shield, ChevronRight, Check, Save, - Search, X, Copy, GitCompare, Users, Lock, BarChart3, XCircle, + Crown, Database, Code, Eye, ChevronRight, Check, Save, + Search, X, Copy, Users, Lock, BarChart3, XCircle, CheckCircle2, MinusCircle, Sparkles, ChevronDown, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { roles as mockRoles, permissionTreeData, users as mockUsers } from '@/lib/mock'; -import type { Role } from '@/types'; +import { permissionTreeData } from '@/lib/mock'; /* ───────── Types ───────── */ +interface Role { + id: string; + name: string; + description: string; + icon: string; + iconClass: string; + permissions: string[]; + userCount: number; + permissionCount: number; +} + interface PermissionChange { id: string; label: string; @@ -86,38 +96,14 @@ function searchTree(nodes: typeof permissionTreeData, query: string): typeof per }, []); } -function computeChanges(original: Set, current: Set): PermissionChange[] { - const allIds = collectAllIds(permissionTreeData); - const idToLabel: Record = {}; - const buildLabelMap = (nodes: typeof permissionTreeData) => { - nodes.forEach(n => { - idToLabel[n.id] = n.label; - if (n.children) buildLabelMap(n.children); - }); - }; - buildLabelMap(permissionTreeData); - const changes: PermissionChange[] = []; - allIds.forEach(id => { - if (current.has(id) && !original.has(id)) { - changes.push({ id, label: idToLabel[id] || id, action: 'added' }); - } else if (!current.has(id) && original.has(id)) { - changes.push({ id, label: idToLabel[id] || id, action: 'removed' }); - } - }); - return changes; -} - /* ───────── Main Component ───────── */ export default function RolesPage() { - const [selectedRole, setSelectedRole] = useState(mockRoles[0]); - const [checkedKeys, setCheckedKeys] = useState>(new Set(mockRoles[0]?.permissions || [])); - const [originalKeys, setOriginalKeys] = useState>(new Set(mockRoles[0]?.permissions || [])); + const [checkedKeys, setCheckedKeys] = useState>(new Set()); + const [originalKeys, setOriginalKeys] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(''); const [showDiff, setShowDiff] = useState(false); const [showPresets, setShowPresets] = useState(false); - const [showCloneModal, setShowCloneModal] = useState(null); - const [showStats, setShowStats] = useState(false); const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); const hasChanges = useMemo(() => { @@ -125,18 +111,29 @@ export default function RolesPage() { return allIds.some(id => checkedKeys.has(id) !== originalKeys.has(id)); }, [checkedKeys, originalKeys]); - const changes = useMemo(() => computeChanges(originalKeys, checkedKeys), [originalKeys, checkedKeys]); + const changes = useMemo(() => { + const allIds = collectAllIds(permissionTreeData); + const idToLabel: Record = {}; + const buildLabelMap = (nodes: typeof permissionTreeData) => { + nodes.forEach(n => { + idToLabel[n.id] = n.label; + if (n.children) buildLabelMap(n.children); + }); + }; + buildLabelMap(permissionTreeData); + const result: PermissionChange[] = []; + allIds.forEach(id => { + if (checkedKeys.has(id) && !originalKeys.has(id)) { + result.push({ id, label: idToLabel[id] || id, action: 'added' }); + } else if (!checkedKeys.has(id) && originalKeys.has(id)) { + result.push({ id, label: idToLabel[id] || id, action: 'removed' }); + } + }); + return result; + }, [originalKeys, checkedKeys]); const filteredTree = useMemo(() => searchTree(permissionTreeData, searchQuery), [searchQuery]); - const handleRoleSelect = useCallback((role: Role) => { - setSelectedRole(role); - const newSet = new Set(role.permissions); - setCheckedKeys(newSet); - setOriginalKeys(newSet); - setShowDiff(false); - }, []); - const togglePermission = useCallback((id: string) => { setCheckedKeys(prev => { const next = new Set(prev); @@ -161,15 +158,15 @@ export default function RolesPage() { const applyPreset = (preset: PermissionPreset) => { setCheckedKeys(new Set(preset.permissions)); + setOriginalKeys(new Set(preset.permissions)); setShowPresets(false); - setToast({ message: `已应用「${preset.name}」`, type: 'success' }); + setToast({ message: `已应用「${preset.name}」预设`, type: 'success' }); setTimeout(() => setToast(null), 2000); }; const handleSave = () => { - if (!selectedRole) return; setOriginalKeys(new Set(checkedKeys)); - setToast({ message: '权限已保存', type: 'success' }); + setToast({ message: '权限配置已保存(演示模式)', type: 'success' }); setTimeout(() => setToast(null), 2000); }; @@ -180,19 +177,6 @@ export default function RolesPage() { setTimeout(() => setToast(null), 2000); }; - // Role statistics - const roleStats = useMemo(() => { - return mockRoles.map(role => { - const roleUsers = mockUsers.filter(u => u.role === role.id); - return { - role, - userCount: roleUsers.length, - permissionCount: role.permissions.length, - onlineUsers: roleUsers.filter(u => u.status === 'active').length, - }; - }); - }, []); - return (
{/* Toast */} @@ -212,258 +196,158 @@ export default function RolesPage() {

角色权限管理

配置 RBAC 角色权限,控制用户对系统功能和数据的访问

-
- - -
- {/* Stats Panel */} - {showStats && ( -
- {roleStats.map(({ role, userCount, permissionCount, onlineUsers }) => { - const Icon = iconMap[role.icon] || Eye; - return ( - - -
-
- -
-
-
{role.name}
-
{role.userCount} 用户
-
-
-
-
-
{userCount}
-
用户
-
-
-
{permissionCount}
-
权限
-
-
-
{onlineUsers}
-
在线
-
-
-
-
- ); - })} + {/* Empty state info */} +
+ +
+

+ 角色数据暂未接入后端 API,当前为权限配置演示模式。您可以使用下方的权限树和预设模板来规划角色权限方案。 +

- )} - - {/* Role Cards */} -
- {mockRoles.map(role => { - const Icon = iconMap[role.icon] || Eye; - return ( -
handleRoleSelect(role)} - className={`dg-card p-5 cursor-pointer transition-all ${selectedRole?.id === role.id ? 'border-blue-500/50 bg-blue-500/5' : 'dg-card-hover'}`} - > -
-
- -
-
-
{role.name}
-
{role.description}
-
-
-
-
- - {role.userCount} 用户 -
-
- - {role.permissionCount} 权限 -
-
- {/* Clone button */} - -
- ); - })}
{/* Permission Config */} - {selectedRole && ( - - -
- {selectedRole.name} - 权限配置 - {hasChanges && ( - - 有未保存的更改 ({changes.length}) - + + + 权限配置 +
+ {/* Presets */} +
+ + {showPresets && ( + <> +
setShowPresets(false)} /> +
+ {PERMISSION_PRESETS.map(preset => { + const Icon = preset.icon; + return ( + + ); + })} +
+ )}
-
- {/* Presets */} -
- - {showPresets && ( - <> -
setShowPresets(false)} /> -
- {PERMISSION_PRESETS.map(preset => { - const Icon = preset.icon; - return ( - - ); - })} -
- + + {/* Diff Toggle */} + {hasChanges && ( + + )} + + {/* Discard */} + {hasChanges && ( + + )} + + {/* Save */} + +
+ + + +
+ {/* Permission Tree */} +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="搜索权限名称..." + className="w-full pl-10 pr-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none" + /> + {searchQuery && ( + )}
- {/* Diff Toggle */} - {hasChanges && ( - - )} - - {/* Discard */} - {hasChanges && ( - - )} - - {/* Save */} - -
- - - -
- {/* Permission Tree */} -
- {/* Search */} -
- - setSearchQuery(e.target.value)} - placeholder="搜索权限名称..." - className="w-full pl-10 pr-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none" + {/* Tree */} +
+ {filteredTree.map(node => ( + - {searchQuery && ( - - )} -
+ ))} + {filteredTree.length === 0 && ( +
+ + 未找到匹配的权限 +
+ )} +
+
- {/* Tree */} -
- {filteredTree.map(node => ( - - ))} - {filteredTree.length === 0 && ( -
- - 未找到匹配的权限 + {/* Diff Panel */} + {showDiff && hasChanges && ( +
+

变更详情

+
+ {changes.map(change => ( +
+ {change.action === 'added' ? : } + {change.label}
- )} + ))} +
+
+ 新增 {changes.filter(c => c.action === 'added').length} + 移除 {changes.filter(c => c.action === 'removed').length}
- - {/* Diff Panel */} - {showDiff && hasChanges && ( -
-

- - 变更详情 -

-
- {changes.map(change => ( -
- {change.action === 'added' ? : } - {change.label} -
- ))} -
-
- 新增 {changes.filter(c => c.action === 'added').length} - 移除 {changes.filter(c => c.action === 'removed').length} -
-
- )} -
- - - )} - - {/* Clone Modal */} - {showCloneModal && ( - setShowCloneModal(null)} - /> - )} + )} +
+ +
); } @@ -531,54 +415,3 @@ function PermissionNodeComponent({
); } - -/* ───────── Clone Role Modal ───────── */ - -function CloneRoleModal({ sourceRole, onClose }: { sourceRole: Role; onClose: () => void }) { - const [name, setName] = useState(`${sourceRole.name} (副本)`); - const [description, setDescription] = useState(sourceRole.description); - const [submitting, setSubmitting] = useState(false); - - const handleClone = async () => { - setSubmitting(true); - try { - // In real implementation, call API - await new Promise(r => setTimeout(r, 500)); - onClose(); - } catch { - // handle error - } finally { - setSubmitting(false); - } - }; - - return ( -
-
e.stopPropagation()}> -
-

克隆角色

- -
-
-

基于 {sourceRole.name} 创建副本,包含 {sourceRole.permissions.length} 个权限

-
-
-
- - setName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" /> -
-
- -