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:
logan committed 2026-05-22 22:08:05 +08:00
1 parent 1877522239
commit d5d19d6be4
16 files changed
+366 -73

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -1,4 +1,4 @@
# Web-Lite 数据审核平台 - 开发路线图 # SQL-Platform 数据审核平台 - 开发路线图
> 项目路径: ~/devops/sql-platform > 项目路径: ~/devops/sql-platform
> 创建时间: 2025-05-22 > 创建时间: 2025-05-22
+1 -1
View File
@@ -1,4 +1,4 @@
# Web-Lite 数据审核平台 - TDD 开发计划 # SQL-Platform 数据审核平台 - TDD 开发计划
> 创建时间: 2025-05-22 > 创建时间: 2025-05-22
> 测试框架: pytest (后端) + vitest (前端) > 测试框架: pytest (后端) + vitest (前端)
+41 -5
View File
@@ -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
View File
@@ -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,
+7
View File
@@ -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())
+25 -1
View File
@@ -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:
+124
View File
@@ -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
View File
@@ -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>
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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'],
+1 -1
View File
@@ -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',
+156 -54
View File
@@ -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,73 +356,143 @@ 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>
<div className="space-y-4"> {/* 数据源信息 */}
<div> <Card className="dg-card">
<label className="block text-sm font-medium mb-2">实例名称 <span className="text-red-400">*</span></label> <CardContent className="p-5 space-y-4">
<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 主库" /> <h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">数据源信息</h3>
</div> <div className="grid grid-cols-2 gap-4">
<div>
<label className={labelCls}>环境 <span className="text-red-400">*</span></label>
<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>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelCls}>用户名 <span className="text-red-400">*</span></label>
<input value={username} onChange={e => setUsername(e.target.value)} className={inputCls} placeholder="admin" />
</div>
<div>
<label className={labelCls}>密码 <span className="text-red-400">*</span></label>
<div className="relative">
<input type={showPwd ? 'text' : 'password'} value={password} onChange={e => setPassword(e.target.value)} className={inputCls + ' pr-10'} placeholder="数据库密码" />
<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" />}
</button>
</div>
</div>
</div>
<div>
<label className={labelCls}>默认数据库</label>
<input value={database} onChange={e => setDatabase(e.target.value)} className={inputCls} placeholder="可选,留空则连接默认库" />
</div>
</CardContent>
</Card>
<div className="grid grid-cols-2 gap-4"> {/* 连接模式 */}
<div> <Card className="dg-card">
<label className="block text-sm font-medium mb-2">数据库类型 <span className="text-red-400">*</span></label> <CardContent className="p-5 space-y-4">
<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"> <h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">连接模式</h3>
<option value="mysql">MySQL</option><option value="postgres">PostgreSQL</option><option value="redis">Redis</option><option value="mongo">MongoDB</option> <div className="flex gap-3">
</select> {[
{ 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> </div>
<div> </CardContent>
<label className="block text-sm font-medium mb-2">环境 <span className="text-red-400">*</span></label> </Card>
<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 className="grid grid-cols-3 gap-4"> {/* 流程配置 */}
<div> <Card className="dg-card">
<label className="block text-sm font-medium mb-2">主机地址 <span className="text-red-400">*</span></label> <CardContent className="p-5 space-y-4">
<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 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>
<div> <div className="space-y-3">
<label className="block text-sm font-medium mb-2">端口 <span className="text-red-400">*</span></label> {workflow.map((step, idx) => (
<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 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> </div>
<div> </CardContent>
<label className="block text-sm font-medium mb-2">默认数据库</label> </Card>
<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"> {/* 排除的数据库 & 脱敏字段 */}
<Card className="dg-card">
<CardContent className="p-5 space-y-4">
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">高级配置</h3>
<div> <div>
<label className="block text-sm font-medium mb-2">用户名 <span className="text-red-400">*</span></label> <label className={labelCls}>排除的数据库</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={excludedDbs} onChange={e => setExcludedDbs(e.target.value)} className={inputCls} placeholder="以逗号分隔,如:sys, information_schema, mysql" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-2">密码 <span className="text-red-400">*</span></label> <label className={labelCls}>脱敏字段</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="数据库密码" /> <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> </div>
</div> </CardContent>
</Card>
<div> {error && <div className="text-sm text-red-400">{error}</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 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>
<div className="flex justify-end gap-3 pt-4"> <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">
<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> {submitting ? '创建中...' : '添加数据源'}
<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"> </button>
{submitting ? '创建中...' : '添加实例'}
</button>
</div>
</div> </div>
</div> </div>
); );
+1 -1
View File
@@ -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 -1
View File
@@ -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
+1 -1
View File
@@ -138,7 +138,7 @@ start_frontend() {
show_status() { show_status() {
echo "" echo ""
echo "=== Web-Lite 数据审核平台 状态 ===" echo "=== SQL-Platform 数据审核平台 状态 ==="
echo "" echo ""
if is_running "$BACKEND_PID_FILE" "后端"; then if is_running "$BACKEND_PID_FILE" "后端"; then