refactor: mock清理+安全配置+通用组件+菜单优化
P0安全: SECRET_KEY/ADMIN_PASSWORD/CORS环境变量化, seed仅admin P1Mock: mock.ts 301->88行, 删8数据mock+5generateMock, net-1300行 P2组件: useToast/useFetchList+ConfirmDialog/FormDialog P3菜单: menuSections/menuItems->constants/menu.ts P4API: NotificationsPage/WorkflowPage重写+RolesPage演示 测试: 1050passed, tsc clean
This commit is contained in:
1 parent
ec3a03cf38
commit
8b921c6c83
33 files changed
+1734
-2006
No files matched your search
+17
-9
@@ -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=
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
@@ -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=["系统设置"])
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
+12
-15
@@ -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=["*"],
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
from app.models.instance_group import InstanceGroup, user_groups # noqa: F401
|
||||
from app.models.system_setting import SystemSetting # noqa: F401
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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": "审批流程、回滚策略"},
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
import request from './request';
|
||||
|
||||
export const notificationApi = {
|
||||
list: () => request.get<any, any[]>('/notifications'),
|
||||
create: (data: any) => request.post<any, any>('/notifications', data),
|
||||
update: (id: number, data: any) => request.put<any, any>(`/notifications/${id}`, data),
|
||||
delete: (id: number) => request.delete(`/notifications/${id}`),
|
||||
test: (id: number) => request.post<any, any>(`/notifications/${id}/test`),
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import request from './request';
|
||||
|
||||
export const workflowApi = {
|
||||
list: () => request.get<any, any[]>('/workflows'),
|
||||
get: (id: number) => request.get<any, any>(`/workflows/${id}`),
|
||||
create: (data: any) => request.post<any, any>('/workflows', data),
|
||||
update: (id: number, data: any) => request.put<any, any>(`/workflows/${id}`, data),
|
||||
delete: (id: number) => request.delete(`/workflows/${id}`),
|
||||
setDefault: (id: number) => request.post<any, any>(`/workflows/${id}/set-default`),
|
||||
clone: (id: number) => request.post<any, any>(`/workflows/${id}/clone`),
|
||||
};
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onCancel(); }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="border-0 sm:max-w-[400px]"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className="text-base">{title}</DialogTitle>
|
||||
{description && (
|
||||
<DialogDescription className="text-sm text-[var(--dg-text-secondary)]">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={variant === 'danger' ? 'destructive' : 'default'}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={`border-0 ${width} flex max-h-[85vh] flex-col`}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-base font-semibold">{title}</DialogTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xs p-1 text-[var(--dg-text-muted)] transition-opacity hover:opacity-70"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto pr-1">{children}</div>
|
||||
|
||||
{/* Optional footer */}
|
||||
{footer && <div className="flex justify-end gap-2 pt-2">{footer}</div>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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'] },
|
||||
];
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
export function useFetchList<T>(fetchFn: () => Promise<T[]>, deps: unknown[] = []) {
|
||||
const [data, setData] = useState<T[]>([]);
|
||||
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 };
|
||||
}
|
||||
@@ -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<Toast | null>(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 (
|
||||
<div
|
||||
className={`fixed bottom-6 right-6 z-[100] flex items-center gap-2 rounded-lg px-4 py-3 text-sm shadow-lg
|
||||
animate-[fadeInUp_0.25s_ease-out] ${isSuccess ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}
|
||||
>
|
||||
{isSuccess ? <CheckCircle className="h-4 w-4 shrink-0" /> : <XCircle className="h-4 w-4 shrink-0" />}
|
||||
<span>{toast.message}</span>
|
||||
<button onClick={() => setToast(null)} className="ml-1 hover:opacity-70">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return { toast, showToast, ToastComponent };
|
||||
}
|
||||
@@ -186,3 +186,14 @@
|
||||
background: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
+1
-237
@@ -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<string, string> = {
|
||||
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'],
|
||||
},
|
||||
];
|
||||
@@ -76,37 +76,6 @@ const TARGET_ICONS: Record<string, React.ElementType> = {
|
||||
|
||||
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<string, string> = {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<TrendRange, TrendDataset> = {
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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;
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, React.ElementType> = {
|
||||
Send, Bell, MessageSquare, Navigation, Mail,
|
||||
interface NotificationChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
channel_type: string;
|
||||
config: Record<string, any>;
|
||||
is_enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const channelStyle: Record<string, { bg: string; color: string; label: string }> = {
|
||||
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<string, { bg: string; color: string }> = {
|
||||
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<typeof notificationChannels[0] | null>(null);
|
||||
const [channels, setChannels] = useState<NotificationChannel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [testingId, setTestingId] = useState<number | null>(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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)]">
|
||||
<AlertCircle className="w-12 h-12 mb-3 text-red-400" />
|
||||
<p className="text-sm">{error}</p>
|
||||
<button onClick={fetchChannels} className="mt-3 px-4 py-2 bg-blue-500/15 text-blue-400 rounded-lg text-sm hover:bg-blue-500/25 transition-colors">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">通知渠道配置</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置多渠道通知,支持飞书、钉钉、企业微信、Telegram</p>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置多渠道通知,支持飞书、钉钉、邮件</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)] hover:border-[var(--dg-text-muted)] transition-all">
|
||||
<Settings className="w-4 h-4" /> 全局设置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{[
|
||||
{ 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) => (
|
||||
<Card key={i} className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
|
||||
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{channels.map(channel => {
|
||||
const Icon = iconMap[channel.icon] || Bell;
|
||||
const style = channelStyle[channel.type] || channelStyle.email;
|
||||
return (
|
||||
<div
|
||||
key={channel.id}
|
||||
className={`dg-card p-5 cursor-pointer transition-all ${channel.enabled ? 'border-green-500/30 bg-green-500/5' : 'dg-card-hover'}`}
|
||||
onClick={() => setEditChannel(channel)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl ${style.bg} flex items-center justify-center`}>
|
||||
<Icon className={`w-6 h-6 ${style.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold">{channel.name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{channel.description}</div>
|
||||
{channel.enabled && channel.events && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{channel.events.slice(0, 3).map(e => (
|
||||
<span key={e} className="px-1.5 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] text-[10px] rounded">{allEvents.find(a => a.key === e)?.label || e}</span>
|
||||
))}
|
||||
{channel.events.length > 3 && <span className="px-1.5 py-0.5 text-[var(--dg-text-muted)] text-[10px]">+{channel.events.length - 3}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${channel.enabled ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
|
||||
{channel.enabled ? '已启用' : '未启用'}
|
||||
</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); toggleChannel(channel.id); }}
|
||||
className="transition-colors"
|
||||
>
|
||||
{channel.enabled ? <ToggleRight className="w-7 h-7 text-green-400" /> : <ToggleLeft className="w-7 h-7 text-[var(--dg-text-muted)]" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
{editChannel && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setEditChannel(null)}>
|
||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-lg w-full mx-4" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-xl font-semibold mb-5">配置 {editChannel.name}</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Webhook URL</label>
|
||||
<input
|
||||
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
defaultValue={editChannel.webhook || ''}
|
||||
placeholder={`请输入 ${editChannel.name} Webhook 地址`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">密钥 (Secret)</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
placeholder="请输入密钥"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-3">通知事件</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{allEvents.map(evt => (
|
||||
<label key={evt.key} className="flex items-center gap-2 p-2.5 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] cursor-pointer hover:border-[var(--dg-text-muted)] transition-colors">
|
||||
<input type="checkbox" defaultChecked={editChannel.events?.includes(evt.key)} className="w-4 h-4 rounded border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] text-blue-500 focus:ring-blue-500/20" />
|
||||
<span className="text-sm">{evt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">@ 提醒用户</label>
|
||||
<select className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
|
||||
<option>审批人</option>
|
||||
<option>申请人</option>
|
||||
<option>执行人</option>
|
||||
<option>全部相关人员</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button onClick={() => setEditChannel(null)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-5 py-2.5 bg-green-500/15 border border-green-500/30 rounded-lg text-sm font-medium text-green-400 hover:bg-green-500/25 transition-all">
|
||||
<TestTube className="w-4 h-4" /> 测试
|
||||
</button>
|
||||
<button onClick={() => setEditChannel(null)} className="px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
{channels.length === 0 ? (
|
||||
<div className="dg-card p-12 text-center">
|
||||
<Bell className="w-12 h-12 mx-auto mb-3 opacity-50 text-[var(--dg-text-muted)]" />
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">暂无通知渠道,请联系管理员配置</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{[
|
||||
{ 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) => (
|
||||
<Card key={i} className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
|
||||
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{channels.map(channel => {
|
||||
const style = channelStyle[channel.channel_type] || { bg: 'bg-gray-400/10', color: 'text-gray-400', label: channel.channel_type };
|
||||
return (
|
||||
<div key={channel.id} className={`dg-card p-5 transition-all ${channel.is_enabled ? 'border-green-500/30 bg-green-500/5' : 'dg-card-hover'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl ${style.bg} flex items-center justify-center`}>
|
||||
<Webhook className={`w-6 h-6 ${style.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold">{channel.name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
||||
{style.label}
|
||||
{channel.config?.webhook_url && <span className="ml-2 opacity-60">{channel.config.webhook_url.substring(0, 40)}...</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleTest(channel.id)}
|
||||
disabled={testingId === channel.id}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-blue-500/10 hover:text-blue-400 transition-colors disabled:opacity-50"
|
||||
title="测试"
|
||||
>
|
||||
{testingId === channel.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <TestTube className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(channel.id)}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/10 hover:text-red-400 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => toggleChannel(channel.id)} className="transition-colors">
|
||||
{channel.is_enabled ? <ToggleRight className="w-7 h-7 text-green-400" /> : <ToggleLeft className="w-7 h-7 text-[var(--dg-text-muted)]" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{testResult && testResult.id === channel.id && (
|
||||
<div className={`mt-3 px-3 py-2 rounded-lg text-xs ${testResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<string, SettingItem[]>;
|
||||
|
||||
/* ── 分组元信息 ── */
|
||||
const GROUP_META: Record<string, { label: string; icon: string; desc: string }> = {
|
||||
query: { label: '查询控制', icon: '🔍', desc: '查询权限、LIMIT 上限、超时设置' },
|
||||
security: { label: '安全策略', icon: '🔒', desc: '登录安全、密码策略、审计日志' },
|
||||
ticket: { label: '工单流程', icon: '📋', desc: '审批流程、回滚控制' },
|
||||
};
|
||||
|
||||
export default function PlatformConfigPage() {
|
||||
const [settings, setSettings] = useState<SettingsMap>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [changed, setChanged] = useState<Record<string, string>>({});
|
||||
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 <div className="text-center py-20 text-[var(--dg-text-muted)]">加载中...</div>;
|
||||
}
|
||||
|
||||
const groupKeys = Object.keys(settings);
|
||||
const hasChanges = Object.keys(changed).length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`fixed top-6 right-6 z-50 px-4 py-3 rounded-lg text-sm font-medium shadow-lg ${
|
||||
toast === '保存成功'
|
||||
? 'bg-emerald-500/90 text-white'
|
||||
: 'bg-red-500/90 text-white'
|
||||
}`}>
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 各分组 */}
|
||||
{groupKeys.map(group => {
|
||||
const meta = GROUP_META[group] || { label: group, icon: '⚙️', desc: '' };
|
||||
const items = settings[group] || [];
|
||||
|
||||
return (
|
||||
<div key={group} className="bg-[var(--dg-bg-secondary)] rounded-xl overflow-hidden">
|
||||
{/* 分组头 */}
|
||||
<div className="px-5 py-3 flex items-center gap-3">
|
||||
<span className="text-lg">{meta.icon}</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)]">{meta.label}</h3>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{meta.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设置项列表 */}
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
{items.map(item => {
|
||||
const val = displayValue(item);
|
||||
const isChanged = changed[item.key] !== undefined;
|
||||
|
||||
return (
|
||||
<div key={item.key} className="flex items-center justify-between gap-4 py-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-[var(--dg-text-primary)]">{item.label}</div>
|
||||
{item.description && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{item.description}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* 重置按钮 */}
|
||||
{isChanged && item.default_value && (
|
||||
<button
|
||||
onClick={() => handleReset(item.key, item.default_value)}
|
||||
className="text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]"
|
||||
title="恢复默认值"
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 布尔 → 开关 */}
|
||||
{item.value_type === 'boolean' && (
|
||||
<button
|
||||
onClick={() => handleChange(item.key, val === 'true' ? 'false' : 'true')}
|
||||
className={`relative w-10 h-[22px] rounded-full transition-colors ${
|
||||
val === 'true' ? 'bg-emerald-500' : 'bg-[var(--dg-bg-tertiary)]'
|
||||
}`}
|
||||
>
|
||||
<span className={`absolute top-[2px] w-[18px] h-[18px] bg-white rounded-full shadow transition-transform ${
|
||||
val === 'true' ? 'translate-x-[20px]' : 'translate-x-[2px]'
|
||||
}`} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 整数 → 数字输入 */}
|
||||
{item.value_type === 'integer' && (
|
||||
<input
|
||||
type="number"
|
||||
value={parseInt(val) || 0}
|
||||
onChange={e => 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' && (
|
||||
<input
|
||||
type="text"
|
||||
value={val}
|
||||
onChange={e => 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 && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 保存按钮 */}
|
||||
{hasChanges && (
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-5 py-2 rounded-lg text-sm font-medium bg-emerald-500 hover:bg-emerald-600 text-white disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? '保存中...' : '保存更改'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setChanged({})}
|
||||
className="px-4 py-2 rounded-lg text-sm text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
已修改 {Object.keys(changed).length} 项
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+175
-342
@@ -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<string>, current: Set<string>): PermissionChange[] {
|
||||
const allIds = collectAllIds(permissionTreeData);
|
||||
const idToLabel: Record<string, string> = {};
|
||||
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<Role | null>(mockRoles[0]);
|
||||
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set(mockRoles[0]?.permissions || []));
|
||||
const [originalKeys, setOriginalKeys] = useState<Set<string>>(new Set(mockRoles[0]?.permissions || []));
|
||||
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set());
|
||||
const [originalKeys, setOriginalKeys] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showDiff, setShowDiff] = useState(false);
|
||||
const [showPresets, setShowPresets] = useState(false);
|
||||
const [showCloneModal, setShowCloneModal] = useState<Role | null>(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<string, string> = {};
|
||||
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 (
|
||||
<div className="space-y-5">
|
||||
{/* Toast */}
|
||||
@@ -212,258 +196,158 @@ export default function RolesPage() {
|
||||
<h1 className="text-2xl font-semibold">角色权限管理</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置 RBAC 角色权限,控制用户对系统功能和数据的访问</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowStats(!showStats)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" /> 统计
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
||||
<Shield className="w-4 h-4" /> 新建角色
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Panel */}
|
||||
{showStats && (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{roleStats.map(({ role, userCount, permissionCount, onlineUsers }) => {
|
||||
const Icon = iconMap[role.icon] || Eye;
|
||||
return (
|
||||
<Card key={role.id} className="dg-card">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${role.iconClass}`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{role.name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{role.userCount} 用户</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="text-lg font-bold text-blue-400">{userCount}</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)]">用户</div>
|
||||
</div>
|
||||
<div className="p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="text-lg font-bold text-purple-400">{permissionCount}</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)]">权限</div>
|
||||
</div>
|
||||
<div className="p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="text-lg font-bold text-green-400">{onlineUsers}</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)]">在线</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{/* Empty state info */}
|
||||
<div className="dg-card p-5 flex items-start gap-3 bg-blue-500/5 border-blue-500/20">
|
||||
<Users className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)]">
|
||||
角色数据暂未接入后端 API,当前为权限配置演示模式。您可以使用下方的权限树和预设模板来规划角色权限方案。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Role Cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{mockRoles.map(role => {
|
||||
const Icon = iconMap[role.icon] || Eye;
|
||||
return (
|
||||
<div
|
||||
key={role.id}
|
||||
onClick={() => 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'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${role.iconClass}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base font-semibold truncate">{role.name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] truncate">{role.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 pt-3 border-t border-[var(--dg-border)]">
|
||||
<div className="text-xs text-[var(--dg-text-secondary)]">
|
||||
<Users className="w-3 h-3 inline mr-1" />
|
||||
<span className="text-[var(--dg-text-primary)] font-semibold">{role.userCount}</span> 用户
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)]">
|
||||
<Lock className="w-3 h-3 inline mr-1" />
|
||||
<span className="text-[var(--dg-text-primary)] font-semibold">{role.permissionCount}</span> 权限
|
||||
</div>
|
||||
</div>
|
||||
{/* Clone button */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowCloneModal(role); }}
|
||||
className="mt-3 flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Copy className="w-3 h-3" /> 克隆角色
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Permission Config */}
|
||||
{selectedRole && (
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)] flex flex-row items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-[15px] font-semibold">{selectedRole.name} - 权限配置</CardTitle>
|
||||
{hasChanges && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-yellow-500/15 text-yellow-400 text-xs font-medium">
|
||||
有未保存的更改 ({changes.length})
|
||||
</span>
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)] flex flex-row items-center justify-between flex-wrap gap-3">
|
||||
<CardTitle className="text-[15px] font-semibold">权限配置</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Presets */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowPresets(!showPresets)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" /> 预设模板 <ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
{showPresets && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowPresets(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl z-50 py-1 min-w-[260px]">
|
||||
{PERMISSION_PRESETS.map(preset => {
|
||||
const Icon = preset.icon;
|
||||
return (
|
||||
<button
|
||||
key={preset.key}
|
||||
onClick={() => applyPreset(preset)}
|
||||
className="w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-[var(--dg-bg-hover)] transition-colors border-b border-[var(--dg-border-light)] last:border-0"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Icon className="w-4 h-4 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{preset.name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{preset.description}</div>
|
||||
<div className="text-xs text-blue-400 mt-1">{preset.permissions.length} 个权限</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Presets */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowPresets(!showPresets)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" /> 预设模板 <ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
{showPresets && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowPresets(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl z-50 py-1 min-w-[260px]">
|
||||
{PERMISSION_PRESETS.map(preset => {
|
||||
const Icon = preset.icon;
|
||||
return (
|
||||
<button
|
||||
key={preset.key}
|
||||
onClick={() => applyPreset(preset)}
|
||||
className="w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-[var(--dg-bg-hover)] transition-colors border-b border-[var(--dg-border-light)] last:border-0"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Icon className="w-4 h-4 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{preset.name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{preset.description}</div>
|
||||
<div className="text-xs text-blue-400 mt-1">{preset.permissions.length} 个权限</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* Diff Toggle */}
|
||||
{hasChanges && (
|
||||
<button
|
||||
onClick={() => setShowDiff(!showDiff)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
showDiff ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
变更对比
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Discard */}
|
||||
{hasChanges && (
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400 hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" /> 撤销
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Save */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm font-medium text-blue-400 hover:bg-blue-500/25 transition-all"
|
||||
>
|
||||
<Save className="w-4 h-4" /> 保存权限
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-5">
|
||||
<div className="flex gap-5">
|
||||
{/* Permission Tree */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Search */}
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={e => 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 && (
|
||||
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Diff Toggle */}
|
||||
{hasChanges && (
|
||||
<button
|
||||
onClick={() => setShowDiff(!showDiff)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
showDiff ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
<GitCompare className="w-4 h-4" /> 变更对比
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Discard */}
|
||||
{hasChanges && (
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400 hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" /> 撤销
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Save */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm font-medium text-blue-400 hover:bg-blue-500/25 transition-all"
|
||||
>
|
||||
<Save className="w-4 h-4" /> 保存权限
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-5">
|
||||
<div className="flex gap-5">
|
||||
{/* Permission Tree */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Search */}
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={e => 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 */}
|
||||
<div className="space-y-1">
|
||||
{filteredTree.map(node => (
|
||||
<PermissionNodeComponent
|
||||
key={node.id}
|
||||
node={node}
|
||||
checkedKeys={checkedKeys}
|
||||
onToggle={togglePermission}
|
||||
onToggleChildren={toggleChildren}
|
||||
depth={0}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filteredTree.length === 0 && (
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)] text-sm">
|
||||
<Search className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||
未找到匹配的权限
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<div className="space-y-1">
|
||||
{filteredTree.map(node => (
|
||||
<PermissionNodeComponent
|
||||
key={node.id}
|
||||
node={node}
|
||||
checkedKeys={checkedKeys}
|
||||
onToggle={togglePermission}
|
||||
onToggleChildren={toggleChildren}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
{filteredTree.length === 0 && (
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)] text-sm">
|
||||
<Search className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||
未找到匹配的权限
|
||||
{/* Diff Panel */}
|
||||
{showDiff && hasChanges && (
|
||||
<div className="w-72 border-l border-[var(--dg-border)] pl-5 flex-shrink-0">
|
||||
<h3 className="text-sm font-semibold mb-3">变更详情</h3>
|
||||
<div className="space-y-2 max-h-[500px] overflow-y-auto">
|
||||
{changes.map(change => (
|
||||
<div
|
||||
key={change.id}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs ${
|
||||
change.action === 'added' ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{change.action === 'added' ? <CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" /> : <MinusCircle className="w-3.5 h-3.5 flex-shrink-0" />}
|
||||
<span className="flex-1 min-w-0 truncate">{change.label}</span>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-[var(--dg-border)] flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
|
||||
<span className="flex items-center gap-1"><CheckCircle2 className="w-3 h-3 text-green-400" /> 新增 {changes.filter(c => c.action === 'added').length}</span>
|
||||
<span className="flex items-center gap-1"><MinusCircle className="w-3 h-3 text-red-400" /> 移除 {changes.filter(c => c.action === 'removed').length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff Panel */}
|
||||
{showDiff && hasChanges && (
|
||||
<div className="w-72 border-l border-[var(--dg-border)] pl-5 flex-shrink-0">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<GitCompare className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
变更详情
|
||||
</h3>
|
||||
<div className="space-y-2 max-h-[500px] overflow-y-auto">
|
||||
{changes.map(change => (
|
||||
<div
|
||||
key={change.id}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs ${
|
||||
change.action === 'added' ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{change.action === 'added' ? <CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" /> : <MinusCircle className="w-3.5 h-3.5 flex-shrink-0" />}
|
||||
<span className="flex-1 min-w-0 truncate">{change.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-[var(--dg-border)] flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
|
||||
<span className="flex items-center gap-1"><CheckCircle2 className="w-3 h-3 text-green-400" /> 新增 {changes.filter(c => c.action === 'added').length}</span>
|
||||
<span className="flex items-center gap-1"><MinusCircle className="w-3 h-3 text-red-400" /> 移除 {changes.filter(c => c.action === 'removed').length}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Clone Modal */}
|
||||
{showCloneModal && (
|
||||
<CloneRoleModal
|
||||
sourceRole={showCloneModal}
|
||||
onClose={() => setShowCloneModal(null)}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -531,54 +415,3 @@ function PermissionNodeComponent({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── 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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2"><Copy className="w-5 h-5 text-blue-400" /> 克隆角色</h2>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-500/5 border border-blue-500/15 rounded-lg mb-4">
|
||||
<p className="text-sm text-[var(--dg-text-secondary)]">基于 <strong className="text-[var(--dg-text-primary)]">{sourceRole.name}</strong> 创建副本,包含 {sourceRole.permissions.length} 个权限</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">角色名称 *</label>
|
||||
<input value={name} onChange={e => 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">描述</label>
|
||||
<textarea value={description} onChange={e => setDescription(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 resize-none" rows={2} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
||||
<button onClick={handleClone} disabled={submitting || !name.trim()} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
|
||||
{submitting ? '克隆中...' : '确认克隆'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import PlatformConfigPage from './PlatformConfigPage';
|
||||
import AuditRulesPage from './AuditRulesPage';
|
||||
import AnnouncementPage from './AnnouncementPage';
|
||||
import NotificationPage from './NotificationPage';
|
||||
import LdapConfigPage from './LdapConfigPage';
|
||||
import AuditRulesPage from './AuditRulesPage';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'platform', label: '平台配置', icon: '⚙️' },
|
||||
{ id: 'rules', label: '审核规则', icon: '🛡️' },
|
||||
{ id: 'announcement', label: '公告管理', icon: '📢' },
|
||||
{ id: 'notification', label: '消息推送', icon: '🔔' },
|
||||
@@ -14,7 +16,7 @@ const tabs = [
|
||||
type TabId = typeof tabs[number]['id'];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('rules');
|
||||
const [activeTab, setActiveTab] = useState<TabId>('platform');
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -43,6 +45,7 @@ export default function SettingsPage() {
|
||||
|
||||
{/* 内容区 */}
|
||||
<div>
|
||||
{activeTab === 'platform' && <PlatformConfigPage />}
|
||||
{activeTab === 'rules' && <AuditRulesPage />}
|
||||
{activeTab === 'announcement' && <AnnouncementPage />}
|
||||
{activeTab === 'notification' && <NotificationPage />}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
UserCheck, UserX, Filter,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { users as mockUsers } from '@/lib/mock';
|
||||
import { userApi } from '@/api/user';
|
||||
|
||||
/* ───────── Types ───────── */
|
||||
@@ -91,26 +90,6 @@ function validateUserForm(data: { username: string; password: string; email: str
|
||||
return errors;
|
||||
}
|
||||
|
||||
/* ───────── Mock Activities ───────── */
|
||||
|
||||
function generateMockActivities(userId: number): UserActivity[] {
|
||||
const actions = [
|
||||
{ action: '登录系统', target: 'Web Console' },
|
||||
{ action: '执行查询', target: 'users 表' },
|
||||
{ action: '提交工单', target: 'TK-20240115-001' },
|
||||
{ action: '审批工单', target: 'TK-20240115-002' },
|
||||
{ action: '导出数据', target: 'orders 表' },
|
||||
{ action: '修改密码', target: '账户安全' },
|
||||
];
|
||||
return actions.map((a, i) => ({
|
||||
id: `act-${userId}-${i}`,
|
||||
action: a.action,
|
||||
target: a.target,
|
||||
created_at: new Date(Date.now() - i * 3600000 * 2).toISOString(),
|
||||
status: Math.random() > 0.2 ? 'success' : 'failed',
|
||||
}));
|
||||
}
|
||||
|
||||
/* ───────── CSV Export Helper ───────── */
|
||||
|
||||
function exportUsersToCSV(users: UserItem[]) {
|
||||
@@ -155,12 +134,7 @@ export default function UsersPage() {
|
||||
try {
|
||||
const res = await userApi.list();
|
||||
const data = (res as any)?.items || res;
|
||||
// Merge with mock user data to enrich with last_login
|
||||
const merged = (Array.isArray(data) ? data : []).map((u: UserItem) => {
|
||||
const mockU = mockUsers.find(m => m.id === u.id);
|
||||
return { ...u, last_login: mockU?.lastLogin || u.created_at };
|
||||
});
|
||||
setUsers(merged);
|
||||
setUsers(Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
setToast({ message: '获取用户列表失败', type: 'error' });
|
||||
} finally {
|
||||
@@ -792,7 +766,6 @@ function ResetPasswordModal({ user, onClose, setToast }: { user: UserItem; onClo
|
||||
/* ───────── User Detail Modal ───────── */
|
||||
|
||||
function UserDetailModal({ user, onClose }: { user: UserItem; onClose: () => void }) {
|
||||
const activities = useMemo(() => generateMockActivities(user.id), [user.id]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
@@ -847,19 +820,8 @@ function UserDetailModal({ user, onClose }: { user: UserItem; onClose: () => voi
|
||||
<Clock className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
最近活动
|
||||
</h3>
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{activities.map(act => (
|
||||
<div key={act.id} className="flex items-center gap-3 p-2.5 rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<div className={`w-2 h-2 rounded-full ${act.status === 'success' ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm">{act.action}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{act.target}</div>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">
|
||||
{new Date(act.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-sm text-[var(--dg-text-muted)] text-center py-6">
|
||||
暂无活动记录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+256
-1118
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user