feat: 项目重命名 SQL-Platform + 数据源表单重写(连接模式/流程/排除DB/脱敏)
- 项目名称 Web-Lite Pro → SQL-Platform 全局替换(13处) - Instance 模型新增: access_mode, workflow, excluded_databases, mask_fields - 创建数据源表单: 分区卡片(数据源信息/连接模式/流程/高级配置) - 后端 JSON 字段序列化/反序列化 - 新增 5 个 Instance 字段测试 (133 total)
This commit is contained in:
1 parent
1877522239
commit
d5d19d6be4
16 files changed
+366
-73
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
# Web-Lite 数据审核平台 - 项目评估
|
# SQL-Platform 数据审核平台 - 项目评估
|
||||||
|
|
||||||
> 评估日期: 2025-05-22
|
> 评估日期: 2025-05-22
|
||||||
> 评估版本: v0.5.0 (Phase 1-5 全部完成)
|
> 评估版本: v0.5.0 (Phase 1-5 全部完成)
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
| 项目 | 说明 |
|
| 项目 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 项目名 | Web-Lite 数据审核平台 (sql-platform) |
|
| 项目名 | SQL-Platform 数据审核平台 (sql-platform) |
|
||||||
| 定位 | SQL 审核与工单管理平台(对标 Yearning/ArcSQL) |
|
| 定位 | SQL 审核与工单管理平台(对标 Yearning/ArcSQL) |
|
||||||
| 技术栈 | FastAPI + SQLite + React + TypeScript + Vite |
|
| 技术栈 | FastAPI + SQLite + React + TypeScript + Vite |
|
||||||
| 代码仓库 | ~/devops/sql-platform |
|
| 代码仓库 | ~/devops/sql-platform |
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# Web-Lite 数据审核平台 - 开发路线图
|
# SQL-Platform 数据审核平台 - 开发路线图
|
||||||
|
|
||||||
> 项目路径: ~/devops/sql-platform
|
> 项目路径: ~/devops/sql-platform
|
||||||
> 创建时间: 2025-05-22
|
> 创建时间: 2025-05-22
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# Web-Lite 数据审核平台 - TDD 开发计划
|
# SQL-Platform 数据审核平台 - TDD 开发计划
|
||||||
|
|
||||||
> 创建时间: 2025-05-22
|
> 创建时间: 2025-05-22
|
||||||
> 测试框架: pytest (后端) + vitest (前端)
|
> 测试框架: pytest (后端) + vitest (前端)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
@@ -16,6 +17,29 @@ from app.services.audit_log_service import AuditLogService
|
|||||||
router = APIRouter(prefix="/instances", tags=["数据源管理"])
|
router = APIRouter(prefix="/instances", tags=["数据源管理"])
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_json_fields(instance: Instance) -> dict:
|
||||||
|
"""将 Instance 的 JSON text 字段反序列化为 dict/list,返回 Response dict"""
|
||||||
|
d = {
|
||||||
|
"id": instance.id,
|
||||||
|
"name": instance.name,
|
||||||
|
"db_type": instance.db_type,
|
||||||
|
"host": instance.host,
|
||||||
|
"port": instance.port,
|
||||||
|
"username": instance.username,
|
||||||
|
"database": instance.database,
|
||||||
|
"env": instance.env,
|
||||||
|
"description": instance.description,
|
||||||
|
"status": instance.status,
|
||||||
|
"version": instance.version,
|
||||||
|
"access_mode": instance.access_mode or "readwrite",
|
||||||
|
"workflow": json.loads(instance.workflow) if instance.workflow else None,
|
||||||
|
"excluded_databases": json.loads(instance.excluded_databases) if instance.excluded_databases else None,
|
||||||
|
"mask_fields": json.loads(instance.mask_fields) if instance.mask_fields else None,
|
||||||
|
"created_at": instance.created_at,
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=List[InstanceResponse])
|
@router.get("", response_model=List[InstanceResponse])
|
||||||
async def list_instances(
|
async def list_instances(
|
||||||
db_type: Optional[str] = None,
|
db_type: Optional[str] = None,
|
||||||
@@ -29,7 +53,8 @@ async def list_instances(
|
|||||||
if env:
|
if env:
|
||||||
stmt = stmt.where(Instance.env == env)
|
stmt = stmt.where(Instance.env == env)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
return result.scalars().all()
|
instances = result.scalars().all()
|
||||||
|
return [_serialize_json_fields(i) for i in instances]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=InstanceResponse, status_code=201)
|
@router.post("", response_model=InstanceResponse, status_code=201)
|
||||||
@@ -48,6 +73,10 @@ async def create_instance(
|
|||||||
database=data.database,
|
database=data.database,
|
||||||
env=data.env,
|
env=data.env,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
|
access_mode=data.access_mode,
|
||||||
|
workflow=json.dumps([s.model_dump() for s in data.workflow], ensure_ascii=False) if data.workflow else None,
|
||||||
|
excluded_databases=json.dumps(data.excluded_databases, ensure_ascii=False) if data.excluded_databases else None,
|
||||||
|
mask_fields=json.dumps([m.model_dump() for m in data.mask_fields], ensure_ascii=False) if data.mask_fields else None,
|
||||||
)
|
)
|
||||||
db.add(instance)
|
db.add(instance)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -58,7 +87,7 @@ async def create_instance(
|
|||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(instance)
|
await db.refresh(instance)
|
||||||
return instance
|
return _serialize_json_fields(instance)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instance_id}", response_model=InstanceResponse)
|
@router.get("/{instance_id}", response_model=InstanceResponse)
|
||||||
@@ -71,7 +100,7 @@ async def get_instance(
|
|||||||
instance = result.scalar_one_or_none()
|
instance = result.scalar_one_or_none()
|
||||||
if not instance:
|
if not instance:
|
||||||
raise HTTPException(status_code=404, detail="数据源不存在")
|
raise HTTPException(status_code=404, detail="数据源不存在")
|
||||||
return instance
|
return _serialize_json_fields(instance)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{instance_id}", response_model=InstanceResponse)
|
@router.put("/{instance_id}", response_model=InstanceResponse)
|
||||||
@@ -89,11 +118,19 @@ async def update_instance(
|
|||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
if "password" in update_data:
|
if "password" in update_data:
|
||||||
update_data["password"] = encrypt_password(update_data["password"])
|
update_data["password"] = encrypt_password(update_data["password"])
|
||||||
|
# JSON fields
|
||||||
|
if "workflow" in update_data:
|
||||||
|
update_data["workflow"] = json.dumps([s if isinstance(s, dict) else s for s in update_data["workflow"]], ensure_ascii=False) if update_data["workflow"] else None
|
||||||
|
if "excluded_databases" in update_data:
|
||||||
|
update_data["excluded_databases"] = json.dumps(update_data["excluded_databases"], ensure_ascii=False) if update_data["excluded_databases"] else None
|
||||||
|
if "mask_fields" in update_data:
|
||||||
|
update_data["mask_fields"] = json.dumps([m if isinstance(m, dict) else m for m in update_data["mask_fields"]], ensure_ascii=False) if update_data["mask_fields"] else None
|
||||||
|
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(instance, key, value)
|
setattr(instance, key, value)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(instance)
|
await db.refresh(instance)
|
||||||
return instance
|
return _serialize_json_fields(instance)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{instance_id}")
|
@router.delete("/{instance_id}")
|
||||||
@@ -128,7 +165,6 @@ async def test_connection(
|
|||||||
if not instance:
|
if not instance:
|
||||||
raise HTTPException(status_code=404, detail="数据源不存在")
|
raise HTTPException(status_code=404, detail="数据源不存在")
|
||||||
|
|
||||||
# Decrypt password for connection test
|
|
||||||
instance.password = decrypt_password(instance.password)
|
instance.password = decrypt_password(instance.password)
|
||||||
return await InstanceService.test_connection(instance)
|
return await InstanceService.test_connection(instance)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Web-Lite API",
|
title="SQL-Platform API",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
description="数据审核平台 API",
|
description="数据审核平台 API",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -18,5 +18,12 @@ class Instance(Base):
|
|||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
status = Column(String(20), default="disconnected") # connected / disconnected / error
|
status = Column(String(20), default="disconnected") # connected / disconnected / error
|
||||||
version = Column(String(50), nullable=True)
|
version = Column(String(50), nullable=True)
|
||||||
|
|
||||||
|
# 新增字段
|
||||||
|
access_mode = Column(String(20), default="readwrite") # readwrite / read / write
|
||||||
|
workflow = Column(Text, nullable=True) # JSON: [{stage, role, assignee}]
|
||||||
|
excluded_databases = Column(Text, nullable=True) # JSON: ["sys", "information_schema", ...]
|
||||||
|
mask_fields = Column(Text, nullable=True) # JSON: [{table, field, rule}]
|
||||||
|
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional, Literal
|
from typing import Optional, Literal, List, Any
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStage(BaseModel):
|
||||||
|
stage: str = Field(..., description="阶段名称,如:提交阶段、审核阶段、执行阶段")
|
||||||
|
role: str = Field(..., description="角色,如:提交人、开发负责人、执行人")
|
||||||
|
assignee: Optional[str] = Field(None, description="负责人用户名")
|
||||||
|
|
||||||
|
|
||||||
|
class MaskFieldRule(BaseModel):
|
||||||
|
table: str = Field(..., description="表名")
|
||||||
|
field: str = Field(..., description="字段名")
|
||||||
|
rule: str = Field(default="mask", description="脱敏规则:mask/hide/hash")
|
||||||
|
|
||||||
|
|
||||||
class InstanceCreate(BaseModel):
|
class InstanceCreate(BaseModel):
|
||||||
@@ -13,6 +25,10 @@ class InstanceCreate(BaseModel):
|
|||||||
database: Optional[str] = None
|
database: Optional[str] = None
|
||||||
env: Literal["production", "staging", "development"] = "development"
|
env: Literal["production", "staging", "development"] = "development"
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
access_mode: Literal["readwrite", "read", "write"] = "readwrite"
|
||||||
|
workflow: Optional[List[WorkflowStage]] = None
|
||||||
|
excluded_databases: Optional[List[str]] = None
|
||||||
|
mask_fields: Optional[List[MaskFieldRule]] = None
|
||||||
|
|
||||||
|
|
||||||
class InstanceUpdate(BaseModel):
|
class InstanceUpdate(BaseModel):
|
||||||
@@ -24,6 +40,10 @@ class InstanceUpdate(BaseModel):
|
|||||||
database: Optional[str] = None
|
database: Optional[str] = None
|
||||||
env: Optional[Literal["production", "staging", "development"]] = None
|
env: Optional[Literal["production", "staging", "development"]] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
access_mode: Optional[Literal["readwrite", "read", "write"]] = None
|
||||||
|
workflow: Optional[List[WorkflowStage]] = None
|
||||||
|
excluded_databases: Optional[List[str]] = None
|
||||||
|
mask_fields: Optional[List[MaskFieldRule]] = None
|
||||||
|
|
||||||
|
|
||||||
class InstanceResponse(BaseModel):
|
class InstanceResponse(BaseModel):
|
||||||
@@ -38,6 +58,10 @@ class InstanceResponse(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
status: str
|
status: str
|
||||||
version: Optional[str] = None
|
version: Optional[str] = None
|
||||||
|
access_mode: str = "readwrite"
|
||||||
|
workflow: Optional[List[dict]] = None
|
||||||
|
excluded_databases: Optional[List[str]] = None
|
||||||
|
mask_fields: Optional[List[dict]] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""测试 Instance 新增字段:access_mode, workflow, excluded_databases, mask_fields"""
|
||||||
|
import pytest
|
||||||
|
from tests.conftest import auth_header
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_instance_with_full_fields(client, admin_token):
|
||||||
|
"""创建数据源:完整字段"""
|
||||||
|
res = await client.post("/api/v1/instances", json={
|
||||||
|
"name": "pro-cex-rds-只读库",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"host": "pro-rds-ro.51aipay.com",
|
||||||
|
"port": 3306,
|
||||||
|
"username": "admin",
|
||||||
|
"password": "secret123",
|
||||||
|
"env": "production",
|
||||||
|
"access_mode": "read",
|
||||||
|
"workflow": [
|
||||||
|
{"stage": "提交阶段", "role": "提交人", "assignee": ""},
|
||||||
|
{"stage": "开发负责人", "role": "开发负责人", "assignee": "lairdlandonloose"},
|
||||||
|
{"stage": "执行人", "role": "执行人", "assignee": "zachlogan"},
|
||||||
|
],
|
||||||
|
"excluded_databases": ["sys", "information_schema", "mysql"],
|
||||||
|
"mask_fields": [
|
||||||
|
{"table": "users", "field": "phone", "rule": "mask"},
|
||||||
|
{"table": "users", "field": "id_card", "rule": "hide"},
|
||||||
|
],
|
||||||
|
}, headers=auth_header(admin_token))
|
||||||
|
assert res.status_code == 201
|
||||||
|
data = res.json()
|
||||||
|
assert data["access_mode"] == "read"
|
||||||
|
assert data["workflow"] is not None
|
||||||
|
assert len(data["workflow"]) == 3
|
||||||
|
assert data["workflow"][1]["assignee"] == "lairdlandonloose"
|
||||||
|
assert data["excluded_databases"] == ["sys", "information_schema", "mysql"]
|
||||||
|
assert len(data["mask_fields"]) == 2
|
||||||
|
assert data["mask_fields"][0]["table"] == "users"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_instance_default_access_mode(client, admin_token):
|
||||||
|
"""默认连接模式为 readwrite"""
|
||||||
|
res = await client.post("/api/v1/instances", json={
|
||||||
|
"name": "test-default",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 3306,
|
||||||
|
"username": "root",
|
||||||
|
"password": "test123",
|
||||||
|
}, headers=auth_header(admin_token))
|
||||||
|
assert res.status_code == 201
|
||||||
|
assert res.json()["access_mode"] == "readwrite"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_instance_includes_new_fields(client, admin_token):
|
||||||
|
"""列表接口返回新字段"""
|
||||||
|
# 创建
|
||||||
|
await client.post("/api/v1/instances", json={
|
||||||
|
"name": "list-test",
|
||||||
|
"db_type": "postgres",
|
||||||
|
"host": "10.0.0.1",
|
||||||
|
"port": 5432,
|
||||||
|
"username": "postgres",
|
||||||
|
"password": "pwd",
|
||||||
|
"access_mode": "write",
|
||||||
|
"workflow": [{"stage": "审批", "role": "DBA", "assignee": "alice"}],
|
||||||
|
}, headers=auth_header(admin_token))
|
||||||
|
|
||||||
|
res = await client.get("/api/v1/instances", headers=auth_header(admin_token))
|
||||||
|
assert res.status_code == 200
|
||||||
|
items = res.json()
|
||||||
|
found = [i for i in items if i["name"] == "list-test"]
|
||||||
|
assert len(found) == 1
|
||||||
|
assert found[0]["access_mode"] == "write"
|
||||||
|
assert found[0]["workflow"] is not None
|
||||||
|
assert found[0]["workflow"][0]["assignee"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_instance_workflow(client, admin_token):
|
||||||
|
"""更新流程配置"""
|
||||||
|
create = await client.post("/api/v1/instances", json={
|
||||||
|
"name": "update-wf",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 3306,
|
||||||
|
"username": "root",
|
||||||
|
"password": "test",
|
||||||
|
}, headers=auth_header(admin_token))
|
||||||
|
iid = create.json()["id"]
|
||||||
|
|
||||||
|
res = await client.put(f"/api/v1/instances/{iid}", json={
|
||||||
|
"workflow": [
|
||||||
|
{"stage": "提交", "role": "提交人", "assignee": "bob"},
|
||||||
|
{"stage": "审核", "role": "DBA", "assignee": "alice"},
|
||||||
|
{"stage": "执行", "role": "DBA", "assignee": "charlie"},
|
||||||
|
],
|
||||||
|
"access_mode": "read",
|
||||||
|
"excluded_databases": ["test_db"],
|
||||||
|
}, headers=auth_header(admin_token))
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert len(data["workflow"]) == 3
|
||||||
|
assert data["access_mode"] == "read"
|
||||||
|
assert data["excluded_databases"] == ["test_db"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_instance_null_json_fields(client, admin_token):
|
||||||
|
"""不传新字段时,返回 null"""
|
||||||
|
res = await client.post("/api/v1/instances", json={
|
||||||
|
"name": "minimal",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 3306,
|
||||||
|
"username": "root",
|
||||||
|
"password": "test",
|
||||||
|
}, headers=auth_header(admin_token))
|
||||||
|
assert res.status_code == 201
|
||||||
|
data = res.json()
|
||||||
|
assert data["workflow"] is None
|
||||||
|
assert data["excluded_databases"] is None
|
||||||
|
assert data["mask_fields"] is None
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Web-Lite Pro</title>
|
<title>SQL-Platform</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function Layout() {
|
|||||||
<Shield className="w-5 h-5 text-white" />
|
<Shield className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-bold dg-gradient-text whitespace-nowrap">
|
<span className="text-lg font-bold dg-gradient-text whitespace-nowrap">
|
||||||
Web-Lite
|
SQL-Platform
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +140,7 @@ export default function Layout() {
|
|||||||
{/* 顶部栏 */}
|
{/* 顶部栏 */}
|
||||||
<header className="h-16 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-6 flex-shrink-0">
|
<header className="h-16 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-6 flex-shrink-0">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<span className="text-[var(--dg-text-muted)]">Web-Lite</span>
|
<span className="text-[var(--dg-text-muted)]">SQL-Platform</span>
|
||||||
<span className="text-[var(--dg-text-muted)]">/</span>
|
<span className="text-[var(--dg-text-muted)]">/</span>
|
||||||
<span className="text-[var(--dg-text-primary)] font-medium">{getPageTitle()}</span>
|
<span className="text-[var(--dg-text-primary)] font-medium">{getPageTitle()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ export const announcements: Announcement[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ann-sql-1', title: '【规范】SQL 变更工单提交流程',
|
id: 'ann-sql-1', title: '【规范】SQL 变更工单提交流程',
|
||||||
content: '1. 登录 Web-Lite 平台,进入「数据变更」页面\n2. 点击「新建工单」,填写标题和变更原因\n3. 选择目标数据库类型和实例\n4. 在 SQL 编辑器中输入变更语句\n5. 系统自动审核并评分,确认无误后提交\n6. 等待 DBA 审批通过后执行\n\n注意:生产环境变更必须提供回滚 SQL!',
|
content: '1. 登录 SQL-Platform 平台,进入「数据变更」页面\n2. 点击「新建工单」,填写标题和变更原因\n3. 选择目标数据库类型和实例\n4. 在 SQL 编辑器中输入变更语句\n5. 系统自动审核并评分,确认无误后提交\n6. 等待 DBA 审批通过后执行\n\n注意:生产环境变更必须提供回滚 SQL!',
|
||||||
priority: 'high', pinned: true, enabled: true, author: 'admin',
|
priority: 'high', pinned: true, enabled: true, author: 'admin',
|
||||||
publishTime: '2024-01-10 08:00:00', expireTime: '2025-12-31 00:00:00',
|
publishTime: '2024-01-10 08:00:00', expireTime: '2025-12-31 00:00:00',
|
||||||
readCount: 512, targetRoles: ['admin', 'dba', 'developer'],
|
readCount: 512, targetRoles: ['admin', 'dba', 'developer'],
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const initialAnnouncements: Announcement[] = [
|
|||||||
readCount: 67, targetRoles: ['developer'],
|
readCount: 67, targetRoles: ['developer'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ann-5', title: '【版本更新】Web-Lite v2.5.0 更新日志',
|
id: 'ann-5', title: '【版本更新】SQL-Platform v2.5.0 更新日志',
|
||||||
content: '本次更新内容:\n\n新增功能:\n- 支持多数据源统一管理\n- 查询审计新增敏感字段自动脱敏\n- 工单批量审批功能\n\n优化改进:\n- SQL 编辑器性能优化\n- 审批流程可视化升级\n- 通知渠道新增 Telegram 支持\n\nBug 修复:\n- 修复大结果集导出内存溢出问题\n- 修复 Redis 集群连接超时问题',
|
content: '本次更新内容:\n\n新增功能:\n- 支持多数据源统一管理\n- 查询审计新增敏感字段自动脱敏\n- 工单批量审批功能\n\n优化改进:\n- SQL 编辑器性能优化\n- 审批流程可视化升级\n- 通知渠道新增 Telegram 支持\n\nBug 修复:\n- 修复大结果集导出内存溢出问题\n- 修复 Redis 集群连接超时问题',
|
||||||
priority: 'low', pinned: false, enabled: false, author: 'admin',
|
priority: 'low', pinned: false, enabled: false, author: 'admin',
|
||||||
publishTime: '2024-01-10 16:00:00', expireTime: '2024-02-10 00:00:00',
|
publishTime: '2024-01-10 16:00:00', expireTime: '2024-02-10 00:00:00',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Plus, RefreshCw, Search, Database, Server, Zap, Leaf,
|
Plus, RefreshCw, Search, Database, Server, Zap, Leaf,
|
||||||
Activity, HardDrive, ChevronRight, Trash2, Edit2,
|
Activity, HardDrive, ChevronRight, Trash2, Edit2,
|
||||||
XCircle,
|
XCircle, Eye, EyeOff, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
@@ -293,6 +293,12 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- CreateInstanceForm ---------- */
|
/* ---------- CreateInstanceForm ---------- */
|
||||||
|
interface WorkflowStep {
|
||||||
|
stage: string;
|
||||||
|
role: string;
|
||||||
|
assignee: string;
|
||||||
|
}
|
||||||
|
|
||||||
function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType: string; onCancel: () => void; onCreated: () => void }) {
|
function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType: string; onCancel: () => void; onCreated: () => void }) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [dbType, setDbType] = useState(defaultType);
|
const [dbType, setDbType] = useState(defaultType);
|
||||||
@@ -300,12 +306,32 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
|||||||
const [port, setPort] = useState('3306');
|
const [port, setPort] = useState('3306');
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPwd, setShowPwd] = useState(false);
|
||||||
const [database, setDatabase] = useState('');
|
const [database, setDatabase] = useState('');
|
||||||
const [env, setEnv] = useState('development');
|
const [env, setEnv] = useState('development');
|
||||||
const [description, setDescription] = useState('');
|
const [accessMode, setAccessMode] = useState<'readwrite' | 'read' | 'write'>('readwrite');
|
||||||
|
const [excludedDbs, setExcludedDbs] = useState('');
|
||||||
|
const [maskFields, setMaskFields] = useState('');
|
||||||
|
const [workflow, setWorkflow] = useState<WorkflowStep[]>([
|
||||||
|
{ stage: '提交阶段', role: '提交人', assignee: '' },
|
||||||
|
{ stage: '开发负责人', role: '开发负责人', assignee: '' },
|
||||||
|
{ stage: '执行人', role: '执行人', assignee: '' },
|
||||||
|
]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const updateWorkflow = (idx: number, field: keyof WorkflowStep, value: string) => {
|
||||||
|
setWorkflow(prev => prev.map((w, i) => i === idx ? { ...w, [field]: value } : w));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addWorkflowStep = () => {
|
||||||
|
setWorkflow(prev => [...prev, { stage: '', role: '', assignee: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeWorkflowStep = (idx: number) => {
|
||||||
|
setWorkflow(prev => prev.filter((_, i) => i !== idx));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!name || !host || !port || !username || !password) { setError('请填写所有必填项'); return; }
|
if (!name || !host || !port || !username || !password) { setError('请填写所有必填项'); return; }
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -314,7 +340,13 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
|||||||
await instanceApi.create({
|
await instanceApi.create({
|
||||||
name, db_type: dbType, host, port: Number(port),
|
name, db_type: dbType, host, port: Number(port),
|
||||||
username, password, database: database || undefined,
|
username, password, database: database || undefined,
|
||||||
env, description: description || undefined,
|
env, access_mode: accessMode,
|
||||||
|
workflow: workflow.filter(w => w.stage || w.assignee).length > 0 ? workflow.filter(w => w.stage || w.assignee) : undefined,
|
||||||
|
excluded_databases: excludedDbs ? excludedDbs.split(',').map(s => s.trim()).filter(Boolean) : undefined,
|
||||||
|
mask_fields: maskFields ? maskFields.split('\n').filter(Boolean).map(line => {
|
||||||
|
const [table = '', field = '', rule = 'mask'] = line.split(':');
|
||||||
|
return { table: table.trim(), field: field.trim(), rule: rule.trim() || 'mask' };
|
||||||
|
}) : undefined,
|
||||||
});
|
});
|
||||||
onCreated();
|
onCreated();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -324,74 +356,144 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const inputCls = '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';
|
||||||
|
const labelCls = 'block text-sm font-medium mb-1.5';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5 max-w-3xl">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold">添加实例</h1>
|
<h1 className="text-xl font-semibold">添加数据源</h1>
|
||||||
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-6 h-6" /></button>
|
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-5 h-5" /></button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">实例名称 <span className="text-red-400">*</span></label>
|
|
||||||
<input value={name} onChange={e => setName(e.target.value)} 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="例如:生产 MySQL 主库" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 数据源信息 */}
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardContent className="p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">数据源信息</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">数据库类型 <span className="text-red-400">*</span></label>
|
<label className={labelCls}>环境 <span className="text-red-400">*</span></label>
|
||||||
<select value={dbType} onChange={e => setDbType(e.target.value)} 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">
|
<select value={env} onChange={e => setEnv(e.target.value)} className={inputCls}>
|
||||||
|
<option value="production">生产</option>
|
||||||
|
<option value="staging">预发</option>
|
||||||
|
<option value="development">开发</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>名称 <span className="text-red-400">*</span></label>
|
||||||
|
<input value={name} onChange={e => setName(e.target.value)} className={inputCls} placeholder="pro-cex-rds-只读库" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>地址 <span className="text-red-400">*</span></label>
|
||||||
|
<input value={host} onChange={e => setHost(e.target.value)} className={inputCls} placeholder="pro-rds-ro.51aipay.com" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>端口 <span className="text-red-400">*</span></label>
|
||||||
|
<input value={port} onChange={e => setPort(e.target.value)} className={inputCls} placeholder="3306" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>类型</label>
|
||||||
|
<select value={dbType} onChange={e => setDbType(e.target.value)} className={inputCls}>
|
||||||
<option value="mysql">MySQL</option><option value="postgres">PostgreSQL</option><option value="redis">Redis</option><option value="mongo">MongoDB</option>
|
<option value="mysql">MySQL</option><option value="postgres">PostgreSQL</option><option value="redis">Redis</option><option value="mongo">MongoDB</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">环境 <span className="text-red-400">*</span></label>
|
|
||||||
<select value={env} onChange={e => setEnv(e.target.value)} 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 value="development">开发</option><option value="staging">预发</option><option value="production">生产</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">主机地址 <span className="text-red-400">*</span></label>
|
|
||||||
<input value={host} onChange={e => setHost(e.target.value)} 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="192.168.1.100" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">端口 <span className="text-red-400">*</span></label>
|
|
||||||
<input value={port} onChange={e => setPort(e.target.value)} 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="3306" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">默认数据库</label>
|
|
||||||
<input value={database} onChange={e => setDatabase(e.target.value)} 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>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">用户名 <span className="text-red-400">*</span></label>
|
<label className={labelCls}>用户名 <span className="text-red-400">*</span></label>
|
||||||
<input value={username} onChange={e => setUsername(e.target.value)} 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="root" />
|
<input value={username} onChange={e => setUsername(e.target.value)} className={inputCls} placeholder="admin" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">密码 <span className="text-red-400">*</span></label>
|
<label className={labelCls}>密码 <span className="text-red-400">*</span></label>
|
||||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} 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 className="relative">
|
||||||
</div>
|
<input type={showPwd ? 'text' : 'password'} value={password} onChange={e => setPassword(e.target.value)} className={inputCls + ' pr-10'} placeholder="数据库密码" />
|
||||||
</div>
|
<button type="button" onClick={() => setShowPwd(!showPwd)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
|
||||||
|
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">描述</label>
|
|
||||||
<textarea value={description} onChange={e => setDescription(e.target.value)} className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm min-h-[80px] resize-y focus:border-blue-400 focus:outline-none" placeholder="可选描述" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="text-sm text-red-400">{error}</div>}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
|
||||||
<button onClick={onCancel} className="px-5 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)]">取消</button>
|
|
||||||
<button onClick={handleSubmit} disabled={submitting} 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 disabled:opacity-50">
|
|
||||||
{submitting ? '创建中...' : '添加实例'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>默认数据库</label>
|
||||||
|
<input value={database} onChange={e => setDatabase(e.target.value)} className={inputCls} placeholder="可选,留空则连接默认库" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 连接模式 */}
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardContent className="p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">连接模式</h3>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{[
|
||||||
|
{ value: 'readwrite' as const, label: '读写' },
|
||||||
|
{ value: 'write' as const, label: '写' },
|
||||||
|
{ value: 'read' as const, label: '读' },
|
||||||
|
].map(m => (
|
||||||
|
<button
|
||||||
|
key={m.value}
|
||||||
|
onClick={() => setAccessMode(m.value)}
|
||||||
|
className={`px-5 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
accessMode === m.value
|
||||||
|
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
|
||||||
|
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 流程配置 */}
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardContent className="p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">流程</h3>
|
||||||
|
<button onClick={addWorkflowStep} className="text-xs text-blue-400 hover:text-blue-300">+ 添加阶段</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{workflow.map((step, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)] w-6 text-right">{idx + 1}</span>
|
||||||
|
<input value={step.stage} onChange={e => updateWorkflow(idx, 'stage', e.target.value)} className={inputCls + ' flex-1'} placeholder="提交阶段" />
|
||||||
|
<input value={step.role} onChange={e => updateWorkflow(idx, 'role', e.target.value)} className={inputCls + ' w-32'} placeholder="角色" />
|
||||||
|
<input value={step.assignee} onChange={e => updateWorkflow(idx, 'assignee', e.target.value)} className={inputCls + ' flex-1'} placeholder="负责人" />
|
||||||
|
{workflow.length > 1 && (
|
||||||
|
<button onClick={() => removeWorkflowStep(idx)} className="text-[var(--dg-text-muted)] hover:text-red-400"><X className="w-4 h-4" /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 排除的数据库 & 脱敏字段 */}
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardContent className="p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">高级配置</h3>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>排除的数据库</label>
|
||||||
|
<input value={excludedDbs} onChange={e => setExcludedDbs(e.target.value)} className={inputCls} placeholder="以逗号分隔,如:sys, information_schema, mysql" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>脱敏字段</label>
|
||||||
|
<textarea value={maskFields} onChange={e => setMaskFields(e.target.value)} className={inputCls + ' min-h-[80px] resize-y'} placeholder={'每行一个,格式:表名:字段名:规则\n例如:\nusers:phone:mask\nusers:id_card:mask\norders:amount:hide'} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error && <div className="text-sm text-red-400">{error}</div>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pb-6">
|
||||||
|
<button onClick={onCancel} className="px-5 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)]">取消</button>
|
||||||
|
<button onClick={handleSubmit} disabled={submitting} 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 disabled:opacity-50">
|
||||||
|
{submitting ? '创建中...' : '添加数据源'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ export default function LoginPage() {
|
|||||||
<Shield className="w-7 h-7 text-white" />
|
<Shield className="w-7 h-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-5xl font-bold dg-gradient-text">
|
<h1 className="text-5xl font-bold dg-gradient-text">
|
||||||
Web-Lite Pro
|
SQL-Platform
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg text-[var(--dg-text-secondary)] leading-relaxed">
|
<p className="text-lg text-[var(--dg-text-secondary)] leading-relaxed">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@echo off
|
@echo off
|
||||||
chcp 65001 >nul
|
chcp 65001 >nul
|
||||||
echo === Web-Lite 数据审核平台启动脚本 ===
|
echo === SQL-Platform 数据审核平台启动脚本 ===
|
||||||
|
|
||||||
echo [1/4] 安装后端依赖...
|
echo [1/4] 安装后端依赖...
|
||||||
cd backend
|
cd backend
|
||||||
|
|||||||
Reference in new issue
Block a user