feat: P0前端页面完善 — InstanceDetail/RedisDetail/TicketCreate/TicketDetail
- InstanceDetailPage: 概览/表管理/SQL查询/用户权限/慢查询/进程列表 6个Tab - RedisDetailPage: 键管理/监控信息/命令行/性能分析 4个Tab + 新增键弹框 - TicketCreatePage: 工单创建向导(选择实例/填写SQL/审核规则检查) - TicketDetailPage: 工单详情+审批/拒绝/执行/回滚操作 - App.tsx 路由注册: instances/:id, redis/:id, tickets/create, tickets/:id - API方法名对齐: listDatabases/listTables/listUsers - 前端构建零错误, 后端202测试通过
This commit is contained in:
1 parent
5cbd0e3441
commit
dfdac3ad37
7 files changed
+1415
-8
No files matched your search
@@ -2,6 +2,7 @@ from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -16,6 +17,12 @@ from app.services.audit_engine import SQLAuditEngine
|
||||
from app.services.audit_log_service import AuditLogService
|
||||
import json
|
||||
|
||||
|
||||
class AuditRequest(BaseModel):
|
||||
sql_content: str
|
||||
instance_id: int
|
||||
database: str = ""
|
||||
|
||||
router = APIRouter(prefix="/tickets", tags=["工单管理"])
|
||||
|
||||
|
||||
@@ -164,6 +171,33 @@ async def ticket_statistics(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/audit")
|
||||
async def audit_sql(
|
||||
data: AuditRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# Verify instance exists
|
||||
inst_result = await db.execute(select(Instance).where(Instance.id == data.instance_id))
|
||||
instance = inst_result.scalar_one_or_none()
|
||||
if not instance:
|
||||
raise HTTPException(status_code=400, detail="数据源不存在")
|
||||
|
||||
env = "production" if instance.env == "production" else "development"
|
||||
engine = SQLAuditEngine(env=env)
|
||||
report = engine.audit(data.sql_content)
|
||||
|
||||
return {
|
||||
"score": report.score,
|
||||
"passed": report.passed,
|
||||
"has_warnings": report.has_warnings,
|
||||
"findings": [
|
||||
{"rule": f.rule, "level": f.level.value, "message": f.message}
|
||||
for f in report.findings
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{ticket_id}")
|
||||
async def get_ticket(
|
||||
ticket_id: int,
|
||||
@@ -216,6 +250,169 @@ async def review_ticket(
|
||||
return await _enrich_ticket(ticket, db)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/timeline")
|
||||
async def get_ticket_timeline(
|
||||
ticket_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
||||
ticket = result.scalar_one_or_none()
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="工单不存在")
|
||||
|
||||
if current_user.role == "developer" and ticket.applicant_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权查看该工单")
|
||||
|
||||
# Fetch audit logs for this ticket
|
||||
from app.models.audit_log import AuditLog
|
||||
log_stmt = (
|
||||
select(AuditLog)
|
||||
.where(AuditLog.target_type == "ticket", AuditLog.target_id == ticket_id)
|
||||
.order_by(AuditLog.created_at.asc())
|
||||
)
|
||||
log_result = await db.execute(log_stmt)
|
||||
logs = log_result.scalars().all()
|
||||
|
||||
timeline = []
|
||||
|
||||
# Creation event
|
||||
applicant_name = "未知"
|
||||
if ticket.applicant_id:
|
||||
r = await db.execute(select(User).where(User.id == ticket.applicant_id))
|
||||
applicant = r.scalar_one_or_none()
|
||||
if applicant:
|
||||
applicant_name = applicant.display_name
|
||||
|
||||
timeline.append({
|
||||
"action": "CREATE_TICKET",
|
||||
"label": "创建工单",
|
||||
"user": applicant_name,
|
||||
"time": ticket.create_time.isoformat() if ticket.create_time else None,
|
||||
"detail": "提交 SQL 变更工单",
|
||||
})
|
||||
|
||||
# From audit logs
|
||||
for log in logs:
|
||||
if log.action == "CREATE_TICKET":
|
||||
continue # already handled
|
||||
r = await db.execute(select(User).where(User.id == log.user_id))
|
||||
u = r.scalar_one_or_none()
|
||||
uname = u.display_name if u else "未知"
|
||||
|
||||
label_map = {
|
||||
"REVIEW_TICKET": "审核工单",
|
||||
"EXECUTE_TICKET": "执行工单",
|
||||
"CANCEL_TICKET": "取消工单",
|
||||
"ROLLBACK_TICKET": "回滚工单",
|
||||
}
|
||||
timeline.append({
|
||||
"action": log.action,
|
||||
"label": label_map.get(log.action, log.action),
|
||||
"user": uname,
|
||||
"time": log.created_at.isoformat() if log.created_at else None,
|
||||
"detail": log.detail,
|
||||
})
|
||||
|
||||
# Status-based synthetic events if no log entries cover them
|
||||
if ticket.review_time:
|
||||
has_review = any(t["action"] == "REVIEW_TICKET" for t in timeline)
|
||||
if not has_review:
|
||||
approver_name = "未知"
|
||||
if ticket.approver_id:
|
||||
r = await db.execute(select(User).where(User.id == ticket.approver_id))
|
||||
a = r.scalar_one_or_none()
|
||||
if a:
|
||||
approver_name = a.display_name
|
||||
timeline.append({
|
||||
"action": "REVIEW_TICKET",
|
||||
"label": "审核工单",
|
||||
"user": approver_name,
|
||||
"time": ticket.review_time.isoformat() if ticket.review_time else None,
|
||||
"detail": "工单状态变更为 " + str(ticket.status),
|
||||
})
|
||||
|
||||
if ticket.execute_time:
|
||||
has_exec = any(t["action"] == "EXECUTE_TICKET" for t in timeline)
|
||||
if not has_exec:
|
||||
timeline.append({
|
||||
"action": "EXECUTE_TICKET",
|
||||
"label": "执行工单",
|
||||
"user": "系统",
|
||||
"time": ticket.execute_time.isoformat() if ticket.execute_time else None,
|
||||
"detail": ticket.execute_result or "执行完成",
|
||||
})
|
||||
|
||||
return {"ticket_id": ticket_id, "timeline": timeline}
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/cancel")
|
||||
async def cancel_ticket(
|
||||
ticket_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
||||
ticket = result.scalar_one_or_none()
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="工单不存在")
|
||||
|
||||
# Only applicant or admin can cancel
|
||||
if ticket.applicant_id != current_user.id and current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="只有申请人或管理员可以取消工单")
|
||||
|
||||
if ticket.status not in ("pending", "reviewing"):
|
||||
raise HTTPException(status_code=400, detail="只能取消待审批或审批中的工单")
|
||||
|
||||
ticket.status = "rejected"
|
||||
ticket.audit_result = "工单被取消"
|
||||
|
||||
await AuditLogService.log(
|
||||
db, user_id=current_user.id, action="CANCEL_TICKET",
|
||||
target_type="ticket", target_id=ticket.id,
|
||||
detail="取消工单",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
return await _enrich_ticket(ticket, db)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/rollback")
|
||||
async def rollback_ticket(
|
||||
ticket_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin", "dba")),
|
||||
):
|
||||
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
||||
ticket = result.scalar_one_or_none()
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="工单不存在")
|
||||
|
||||
if ticket.status not in ("success", "failed"):
|
||||
raise HTTPException(status_code=400, detail="只能回滚已执行(成功或失败)的工单")
|
||||
|
||||
# Simplified rollback — return info message
|
||||
rollback_sql = ticket.rollback_sql or "未提供回滚 SQL"
|
||||
|
||||
await AuditLogService.log(
|
||||
db, user_id=current_user.id, action="ROLLBACK_TICKET",
|
||||
target_type="ticket", target_id=ticket.id,
|
||||
detail="请求回滚工单",
|
||||
)
|
||||
|
||||
ticket.status = "rollback"
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
return {
|
||||
"message": "回滚请求已记录,请手动执行以下回滚 SQL",
|
||||
"ticket_id": ticket_id,
|
||||
"rollback_sql": rollback_sql,
|
||||
"status": ticket.status,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/execute")
|
||||
async def execute_ticket(
|
||||
ticket_id: int,
|
||||
|
||||
@@ -16,6 +16,10 @@ import AutoTaskPage from '@/pages/AutoTaskPage';
|
||||
import SettingsPage from '@/pages/SettingsPage';
|
||||
import AuditLogsPage from '@/pages/AuditLogsPage';
|
||||
import DataDictionaryPage from '@/pages/DataDictionaryPage';
|
||||
import InstanceDetailPage from '@/pages/InstanceDetailPage';
|
||||
import RedisDetailPage from '@/pages/RedisDetailPage';
|
||||
import TicketCreatePage from '@/pages/TicketCreatePage';
|
||||
import TicketDetailPage from '@/pages/TicketDetailPage';
|
||||
|
||||
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
|
||||
function RouteGuard() {
|
||||
@@ -52,8 +56,12 @@ function AppRoutes() {
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="instances" element={<InstancePage />} />
|
||||
<Route path="instances/:id" element={<InstanceDetailPage />} />
|
||||
<Route path="redis/:id" element={<RedisDetailPage />} />
|
||||
<Route path="data-dictionary" element={<DataDictionaryPage />} />
|
||||
<Route path="tickets" element={<TicketsPage />} />
|
||||
<Route path="tickets/create" element={<TicketCreatePage />} />
|
||||
<Route path="tickets/:id" element={<TicketDetailPage />} />
|
||||
<Route path="queries" element={<QueriesPage />} />
|
||||
<Route path="roles" element={<RolesPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
import request from './request';
|
||||
|
||||
export const ticketApi = {
|
||||
list: (params?: any) => request.get<any, any>('/tickets', { params }),
|
||||
get: (id: number) => request.get<any, any>(`/tickets/${id}`),
|
||||
create: (data: any) => request.post<any, any>('/tickets', data),
|
||||
review: (id: number, data: { action: 'approve' | 'reject'; comment: string }) =>
|
||||
request.post<any, any>(`/tickets/${id}/review`, data),
|
||||
execute: (id: number) => request.post<any, any>(`/tickets/${id}/execute`),
|
||||
statistics: () => request.get('/tickets/statistics'),
|
||||
export var ticketApi = {
|
||||
list: function (params?: any) {
|
||||
return request.get('/tickets', { params: params });
|
||||
},
|
||||
get: function (id: number) {
|
||||
return request.get('/tickets/' + id);
|
||||
},
|
||||
create: function (data: any) {
|
||||
return request.post('/tickets', data);
|
||||
},
|
||||
review: function (id: number, data: { action: 'approve' | 'reject'; comment: string }) {
|
||||
return request.post('/tickets/' + id + '/review', data);
|
||||
},
|
||||
execute: function (id: number) {
|
||||
return request.post('/tickets/' + id + '/execute');
|
||||
},
|
||||
statistics: function () {
|
||||
return request.get('/tickets/statistics');
|
||||
},
|
||||
timeline: function (id: number) {
|
||||
return request.get('/tickets/' + id + '/timeline');
|
||||
},
|
||||
cancel: function (id: number) {
|
||||
return request.post('/tickets/' + id + '/cancel');
|
||||
},
|
||||
rollback: function (id: number) {
|
||||
return request.post('/tickets/' + id + '/rollback');
|
||||
},
|
||||
audit: function (data: { sql_content: string; instance_id: number; database: string }) {
|
||||
return request.post('/tickets/audit', data);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Database, Table2, Users, Clock, Cpu, Play, RefreshCw } from 'lucide-react';
|
||||
import { instanceDetailApi } from '@/api/instanceDetail';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import { queryApi } from '@/api/query';
|
||||
|
||||
interface InstanceInfo {
|
||||
id: number; name: string; db_type: string; host: string; port: number;
|
||||
status: string; version: string | null; env: string;
|
||||
}
|
||||
|
||||
type TabKey = 'overview' | 'tables' | 'query' | 'users' | 'slow' | 'process';
|
||||
|
||||
export default function InstanceDetailPage() {
|
||||
var params = useParams<{ id: string }>();
|
||||
var navigate = useNavigate();
|
||||
var instanceId = Number(params.id);
|
||||
|
||||
var [instance, setInstance] = useState<InstanceInfo | null>(null);
|
||||
var [activeTab, setActiveTab] = useState<TabKey>('overview');
|
||||
var [databases, setDatabases] = useState<string[]>([]);
|
||||
var [selectedDb, setSelectedDb] = useState('');
|
||||
var [tables, setTables] = useState<any[]>([]);
|
||||
var [users, setUsers] = useState<any[]>([]);
|
||||
var [slowQueries, setSlowQueries] = useState<any[]>([]);
|
||||
var [processes, setProcesses] = useState<any[]>([]);
|
||||
var [sqlText, setSqlText] = useState('');
|
||||
var [queryResult, setQueryResult] = useState<any>(null);
|
||||
var [loading, setLoading] = useState(false);
|
||||
|
||||
var tabs: { key: TabKey; label: string; icon: React.ElementType }[] = [
|
||||
{ key: 'overview', label: '概览', icon: Database },
|
||||
{ key: 'tables', label: '表管理', icon: Table2 },
|
||||
{ key: 'query', label: 'SQL查询', icon: Play },
|
||||
{ key: 'users', label: '用户权限', icon: Users },
|
||||
{ key: 'slow', label: '慢查询', icon: Clock },
|
||||
{ key: 'process', label: '进程列表', icon: Cpu },
|
||||
];
|
||||
|
||||
useEffect(function () {
|
||||
instanceApi.get(instanceId).then(function (res: any) {
|
||||
setInstance(res);
|
||||
}).catch(function () {});
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(function () {
|
||||
if (activeTab === 'overview' || activeTab === 'tables') {
|
||||
instanceDetailApi.listDatabases(instanceId).then(function (res: any) {
|
||||
var dbs = res.databases || res || [];
|
||||
setDatabases(Array.isArray(dbs) ? dbs : []);
|
||||
if (dbs.length > 0 && !selectedDb) setSelectedDb(dbs[0]);
|
||||
}).catch(function () { setDatabases([]); });
|
||||
}
|
||||
if (activeTab === 'users') {
|
||||
instanceDetailApi.listUsers(instanceId).then(function (res: any) {
|
||||
setUsers(res.users || res || []);
|
||||
}).catch(function () { setUsers([]); });
|
||||
}
|
||||
if (activeTab === 'slow') {
|
||||
instanceDetailApi.getSlowQueries(instanceId, selectedDb).then(function (res: any) {
|
||||
setSlowQueries(res.queries || res || []);
|
||||
}).catch(function () { setSlowQueries([]); });
|
||||
}
|
||||
if (activeTab === 'process') {
|
||||
instanceDetailApi.getProcesslist(instanceId).then(function (res: any) {
|
||||
setProcesses(res.processes || res || []);
|
||||
}).catch(function () { setProcesses([]); });
|
||||
}
|
||||
}, [activeTab, selectedDb, instanceId]);
|
||||
|
||||
useEffect(function () {
|
||||
if (activeTab === 'tables' && selectedDb) {
|
||||
instanceDetailApi.listTables(instanceId, selectedDb).then(function (res: any) {
|
||||
setTables(res.tables || res || []);
|
||||
}).catch(function () { setTables([]); });
|
||||
}
|
||||
}, [activeTab, selectedDb, instanceId]);
|
||||
|
||||
var handleQuery = function () {
|
||||
if (!sqlText.trim()) return;
|
||||
setLoading(true);
|
||||
queryApi.execute({ instance_id: instanceId, database: selectedDb, sql: sqlText }).then(function (res: any) {
|
||||
setQueryResult(res);
|
||||
}).catch(function (e: any) {
|
||||
setQueryResult({ error: e?.response?.data?.detail || '查询失败' });
|
||||
}).finally(function () { setLoading(false); });
|
||||
};
|
||||
|
||||
var handleKill = function (pid: number) {
|
||||
if (!confirm('确定 Kill 进程 ' + pid + '?')) return;
|
||||
instanceDetailApi.killProcess(instanceId, pid).then(function () {
|
||||
instanceDetailApi.getProcesslist(instanceId).then(function (res: any) {
|
||||
setProcesses(res.processes || res || []);
|
||||
});
|
||||
}).catch(function () {});
|
||||
};
|
||||
|
||||
if (!instance) return <div className="p-10 text-center text-[var(--dg-text-muted)]">加载中...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={function () { navigate('/instances'); }} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{instance.name}</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">{instance.host}:{instance.port} · {instance.version || '-'} · {instance.env}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'px-3 py-1 rounded-full text-xs font-medium ' + (instance.status === 'connected' || instance.status === 'online' ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400')}>
|
||||
{instance.status === 'connected' || instance.status === 'online' ? '在线' : '离线'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab 导航 */}
|
||||
<div className="flex gap-0 border-b border-[var(--dg-border)]">
|
||||
{tabs.map(function (tab) {
|
||||
var Icon = tab.icon;
|
||||
return (
|
||||
<button key={tab.key} onClick={function () { setActiveTab(tab.key); }}
|
||||
className={'flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px '
|
||||
+ (activeTab === tab.key ? 'border-blue-500 text-blue-400' : 'border-transparent text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]')}>
|
||||
<Icon className="w-4 h-4" />{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 概览 */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="dg-card p-4"><div className="text-2xl font-bold text-blue-400">{databases.length}</div><div className="text-xs text-[var(--dg-text-muted)]">数据库</div></div>
|
||||
<div className="dg-card p-4"><div className="text-2xl font-bold text-green-400">{instance.status === 'connected' ? '在线' : '离线'}</div><div className="text-xs text-[var(--dg-text-muted)]">状态</div></div>
|
||||
<div className="dg-card p-4"><div className="text-2xl font-bold text-purple-400">{instance.version || '-'}</div><div className="text-xs text-[var(--dg-text-muted)]">版本</div></div>
|
||||
</div>
|
||||
<div className="dg-card p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">数据库列表</h3>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{databases.map(function (db) {
|
||||
return <div key={db} className="px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm cursor-pointer hover:bg-blue-500/10" onClick={function () { setSelectedDb(db); setActiveTab('tables'); }}>{db}</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表管理 */}
|
||||
{activeTab === 'tables' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<select value={selectedDb} onChange={function (e) { setSelectedDb(e.target.value); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
|
||||
<option value="">选择数据库</option>
|
||||
{databases.map(function (db) { return <option key={db} value={db}>{db}</option>; })}
|
||||
</select>
|
||||
<button onClick={function () { if (selectedDb) setActiveTab('tables'); }} className="px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]"><RefreshCw className="w-4 h-4" /></button>
|
||||
</div>
|
||||
{tables.length === 0 ? <div className="text-center py-8 text-[var(--dg-text-muted)]">选择数据库查看表列表</div> : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
<th className="px-4 py-2">表名</th><th className="px-4 py-2">引擎</th><th className="px-4 py-2">行数</th><th className="px-4 py-2">大小</th><th className="px-4 py-2">注释</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{tables.map(function (t: any, i: number) {
|
||||
return <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<td className="px-4 py-2 font-medium">{t.Name || t.name}</td>
|
||||
<td className="px-4 py-2">{t.Engine || t.engine || '-'}</td>
|
||||
<td className="px-4 py-2">{t.Rows || t.rows || '-'}</td>
|
||||
<td className="px-4 py-2">{t.Data_length ? (t.Data_length / 1024 / 1024).toFixed(2) + ' MB' : '-'}</td>
|
||||
<td className="px-4 py-2 text-[var(--dg-text-muted)]">{t.Comment || t.comment || '-'}</td>
|
||||
</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SQL查询 */}
|
||||
{activeTab === 'query' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<select value={selectedDb} onChange={function (e) { setSelectedDb(e.target.value); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
|
||||
<option value="">选择数据库</option>
|
||||
{databases.map(function (db) { return <option key={db} value={db}>{db}</option>; })}
|
||||
</select>
|
||||
</div>
|
||||
<textarea value={sqlText} onChange={function (e) { setSqlText(e.target.value); }} className="w-full h-32 px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono resize-y" placeholder="输入 SQL 查询语句..." />
|
||||
<button onClick={handleQuery} disabled={loading} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm disabled:opacity-50">
|
||||
{loading ? '执行中...' : '执行查询'}
|
||||
</button>
|
||||
{queryResult && queryResult.error && <div className="p-3 bg-red-500/10 text-red-400 rounded-lg text-sm">{queryResult.error}</div>}
|
||||
{queryResult && !queryResult.error && queryResult.columns && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
{queryResult.columns.map(function (c: string) { return <th key={c} className="px-4 py-2">{c}</th>; })}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{(queryResult.rows || []).map(function (row: any, i: number) {
|
||||
return <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
||||
{queryResult.columns.map(function (c: string) { return <td key={c} className="px-4 py-2">{row[c] != null ? String(row[c]) : 'NULL'}</td>; })}
|
||||
</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-2">共 {queryResult.total || 0} 行 · {queryResult.elapsed_ms || 0}ms</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户权限 */}
|
||||
{activeTab === 'users' && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
<th className="px-4 py-2">User</th><th className="px-4 py-2">Host</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{users.map(function (u: any, i: number) {
|
||||
return <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<td className="px-4 py-2 font-medium">{u.User || u.user}</td>
|
||||
<td className="px-4 py-2">{u.Host || u.host}</td>
|
||||
</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{users.length === 0 && <div className="text-center py-8 text-[var(--dg-text-muted)]">暂无数据</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 慢查询 */}
|
||||
{activeTab === 'slow' && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
<th className="px-4 py-2">SQL</th><th className="px-4 py-2">执行时间</th><th className="px-4 py-2">扫描行数</th><th className="px-4 py-2">时间</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{slowQueries.map(function (q: any, i: number) {
|
||||
return <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<td className="px-4 py-2 max-w-md truncate font-mono text-xs">{q.sql_text || q.SQL || '-'}</td>
|
||||
<td className="px-4 py-2">{q.query_time || q.Time || '-'}</td>
|
||||
<td className="px-4 py-2">{q.rows_examined || q.ScanRows || '-'}</td>
|
||||
<td className="px-4 py-2">{q.start_time || q.Timestamp || '-'}</td>
|
||||
</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{slowQueries.length === 0 && <div className="text-center py-8 text-[var(--dg-text-muted)]">暂无慢查询记录</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 进程列表 */}
|
||||
{activeTab === 'process' && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
<th className="px-4 py-2">Id</th><th className="px-4 py-2">User</th><th className="px-4 py-2">Host</th><th className="px-4 py-2">DB</th><th className="px-4 py-2">Time</th><th className="px-4 py-2">State</th><th className="px-4 py-2">操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{processes.map(function (p: any) {
|
||||
return <tr key={p.Id || p.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<td className="px-4 py-2">{p.Id || p.id}</td>
|
||||
<td className="px-4 py-2">{p.User || p.user}</td>
|
||||
<td className="px-4 py-2">{p.Host || p.host}</td>
|
||||
<td className="px-4 py-2">{p.db || p.DB || '-'}</td>
|
||||
<td className="px-4 py-2">{p.Time || p.time}</td>
|
||||
<td className="px-4 py-2 text-[var(--dg-text-muted)] max-w-xs truncate">{p.State || p.state || '-'}</td>
|
||||
<td className="px-4 py-2">
|
||||
<button onClick={function () { handleKill(p.Id || p.id); }} className="px-2 py-1 text-xs bg-red-500/10 text-red-400 rounded hover:bg-red-500/20">Kill</button>
|
||||
</td>
|
||||
</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{processes.length === 0 && <div className="text-center py-8 text-[var(--dg-text-muted)]">暂无进程</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Key, Activity, Terminal, Gauge, Plus, RefreshCw, Search } from 'lucide-react';
|
||||
import { redisApi } from '@/api/redis';
|
||||
|
||||
type TabKey = 'keys' | 'monitor' | 'cli' | 'perf';
|
||||
|
||||
export default function RedisDetailPage() {
|
||||
var params = useParams<{ id: string }>();
|
||||
var navigate = useNavigate();
|
||||
var instanceId = Number(params.id);
|
||||
|
||||
var [info, setInfo] = useState<Record<string, any>>({});
|
||||
var [activeTab, setActiveTab] = useState<TabKey>('keys');
|
||||
var [selectedDb, setSelectedDb] = useState(0);
|
||||
var [keyPattern, setKeyPattern] = useState('*');
|
||||
var [keyType, setKeyType] = useState('');
|
||||
var [keys, setKeys] = useState<any[]>([]);
|
||||
var [selectedKey, setSelectedKey] = useState<any>(null);
|
||||
var [showAddKey, setShowAddKey] = useState(false);
|
||||
var [newKey, setNewKey] = useState({ key: '', value: '', type: 'string', ttl: -1 });
|
||||
var [cmdInput, setCmdInput] = useState('');
|
||||
var [cmdOutput, setCmdOutput] = useState<string[]>([]);
|
||||
var [slowlog, setSlowlog] = useState<any[]>([]);
|
||||
var [clients, setClients] = useState<any[]>([]);
|
||||
var [loading, setLoading] = useState(false);
|
||||
|
||||
var tabs: { key: TabKey; label: string; icon: React.ElementType }[] = [
|
||||
{ key: 'keys', label: '键管理', icon: Key },
|
||||
{ key: 'monitor', label: '监控信息', icon: Activity },
|
||||
{ key: 'cli', label: '命令行', icon: Terminal },
|
||||
{ key: 'perf', label: '性能分析', icon: Gauge },
|
||||
];
|
||||
|
||||
useEffect(function () {
|
||||
redisApi.getInfo(instanceId).then(function (res: any) {
|
||||
setInfo(res.info || res || {});
|
||||
}).catch(function () { setInfo({}); });
|
||||
}, [instanceId]);
|
||||
|
||||
var fetchKeys = function () {
|
||||
setLoading(true);
|
||||
redisApi.getKeys(instanceId, { db: selectedDb, pattern: keyPattern, type: keyType || undefined, count: 50 }).then(function (res: any) {
|
||||
setKeys(res.keys || res || []);
|
||||
}).catch(function () { setKeys([]); }).finally(function () { setLoading(false); });
|
||||
};
|
||||
|
||||
var handleAddKey = function () {
|
||||
redisApi.createKey(instanceId, { db: selectedDb, key: newKey.key, value: newKey.value, type: newKey.type, ttl: newKey.ttl > 0 ? newKey.ttl : undefined }).then(function () {
|
||||
setShowAddKey(false);
|
||||
setNewKey({ key: '', value: '', type: 'string', ttl: -1 });
|
||||
fetchKeys();
|
||||
}).catch(function () {});
|
||||
};
|
||||
|
||||
var handleDeleteKey = function (keyName: string) {
|
||||
if (!confirm('确定删除键 ' + keyName + '?')) return;
|
||||
redisApi.deleteKey(instanceId, keyName, selectedDb).then(function () { fetchKeys(); }).catch(function () {});
|
||||
};
|
||||
|
||||
var handleCommand = function () {
|
||||
if (!cmdInput.trim()) return;
|
||||
var cmd = cmdInput;
|
||||
setCmdInput('');
|
||||
setCmdOutput(function (prev) { return prev.concat(['> ' + cmd]); });
|
||||
redisApi.executeCommand(instanceId, { db: selectedDb, command: cmd }).then(function (res: any) {
|
||||
setCmdOutput(function (prev) { return prev.concat([String(res.result || res.data || JSON.stringify(res))]); });
|
||||
}).catch(function (e: any) {
|
||||
setCmdOutput(function (prev) { return prev.concat(['ERROR: ' + (e?.response?.data?.detail || '执行失败')]); });
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(function () {
|
||||
if (activeTab === 'perf') {
|
||||
redisApi.getSlowlog(instanceId, 20).then(function (res: any) { setSlowlog(res.slowlog || res || []); }).catch(function () { setSlowlog([]); });
|
||||
redisApi.getClients(instanceId).then(function (res: any) { setClients(res.clients || res || []); }).catch(function () { setClients([]); });
|
||||
}
|
||||
}, [activeTab, instanceId]);
|
||||
|
||||
var infoSections = Object.entries(info).filter(function (e) { return typeof e[1] === 'object' && e[1] !== null; });
|
||||
var infoScalars = Object.entries(info).filter(function (e) { return typeof e[1] !== 'object'; });
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={function () { navigate('/instances'); }} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Redis 实例</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">#{instanceId}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信息卡片 */}
|
||||
{infoScalars.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{infoScalars.slice(0, 4).map(function (e) {
|
||||
return <div key={e[0]} className="dg-card p-3"><div className="text-lg font-bold">{String(e[1])}</div><div className="text-xs text-[var(--dg-text-muted)]">{e[0]}</div></div>;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab */}
|
||||
<div className="flex gap-0 border-b border-[var(--dg-border)]">
|
||||
{tabs.map(function (tab) {
|
||||
var Icon = tab.icon;
|
||||
return <button key={tab.key} onClick={function () { setActiveTab(tab.key); }}
|
||||
className={'flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px '
|
||||
+ (activeTab === tab.key ? 'border-blue-500 text-blue-400' : 'border-transparent text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]')}>
|
||||
<Icon className="w-4 h-4" />{tab.label}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 键管理 */}
|
||||
{activeTab === 'keys' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<select value={selectedDb} onChange={function (e) { setSelectedDb(Number(e.target.value)); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm w-24">
|
||||
{Array.from({ length: 16 }, function (_, i) { return i; }).map(function (i) { return <option key={i} value={i}>DB {i}</option>; })}
|
||||
</select>
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input value={keyPattern} onChange={function (e) { setKeyPattern(e.target.value); }} className="w-full pl-10 pr-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="pattern, 如 user:*" />
|
||||
</div>
|
||||
<select value={keyType} onChange={function (e) { setKeyType(e.target.value); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
|
||||
<option value="">全部类型</option>
|
||||
<option value="string">String</option><option value="hash">Hash</option><option value="list">List</option><option value="set">Set</option><option value="zset">ZSet</option>
|
||||
</select>
|
||||
<button onClick={fetchKeys} disabled={loading} className="px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] disabled:opacity-50"><RefreshCw className="w-4 h-4" /></button>
|
||||
<button onClick={function () { setShowAddKey(true); }} className="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm flex items-center gap-1"><Plus className="w-4 h-4" />新增</button>
|
||||
</div>
|
||||
|
||||
{/* 键列表 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
<th className="px-4 py-2">键名</th><th className="px-4 py-2">类型</th><th className="px-4 py-2">TTL</th><th className="px-4 py-2">操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{keys.map(function (k: any, i: number) {
|
||||
return <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<td className="px-4 py-2 font-mono text-xs">{k.key || k.name || k}</td>
|
||||
<td className="px-4 py-2">{k.type || '-'}</td>
|
||||
<td className="px-4 py-2">{k.ttl != null ? k.ttl : '-'}</td>
|
||||
<td className="px-4 py-2 flex gap-2">
|
||||
<button onClick={function () {
|
||||
redisApi.getKeyDetail(instanceId, k.key || k.name || k, selectedDb).then(function (res: any) { setSelectedKey(res); }).catch(function () {});
|
||||
}} className="text-xs text-blue-400 hover:text-blue-300">查看</button>
|
||||
<button onClick={function () { handleDeleteKey(k.key || k.name || k); }} className="text-xs text-red-400 hover:text-red-300">删除</button>
|
||||
</td>
|
||||
</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{keys.length === 0 && <div className="text-center py-8 text-[var(--dg-text-muted)]">点击刷新加载键列表</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 监控信息 */}
|
||||
{activeTab === 'monitor' && (
|
||||
<div className="space-y-4">
|
||||
{infoSections.map(function (section) {
|
||||
var entries = Object.entries(section[1] as Record<string, any>);
|
||||
return (
|
||||
<div key={section[0]} className="dg-card p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">{section[0]}</h3>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{entries.map(function (e) {
|
||||
return <div key={e[0]} className="flex justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] rounded text-xs"><span className="text-[var(--dg-text-muted)]">{e[0]}</span><span className="font-mono">{String(e[1])}</span></div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 命令行 */}
|
||||
{activeTab === 'cli' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<select value={selectedDb} onChange={function (e) { setSelectedDb(Number(e.target.value)); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm w-24">
|
||||
{Array.from({ length: 16 }, function (_, i) { return i; }).map(function (i) { return <option key={i} value={i}>DB {i}</option>; })}
|
||||
</select>
|
||||
<input value={cmdInput} onChange={function (e) { setCmdInput(e.target.value); }}
|
||||
onKeyDown={function (e) { if (e.key === 'Enter') handleCommand(); }}
|
||||
className="flex-1 px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono" placeholder="输入 Redis 命令..." />
|
||||
<button onClick={handleCommand} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">执行</button>
|
||||
</div>
|
||||
<div className="dg-card p-4 min-h-[300px] max-h-[400px] overflow-y-auto font-mono text-xs space-y-1">
|
||||
{cmdOutput.length === 0 && <div className="text-[var(--dg-text-muted)]">等待输入命令...</div>}
|
||||
{cmdOutput.map(function (line, i) {
|
||||
return <div key={i} className={line.startsWith('>') ? 'text-green-400' : line.startsWith('ERROR') ? 'text-red-400' : 'text-[var(--dg-text-secondary)]'}>{line}</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 性能分析 */}
|
||||
{activeTab === 'perf' && (
|
||||
<div className="space-y-4">
|
||||
<div className="dg-card p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">慢日志</h3>
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
<th className="px-4 py-2">命令</th><th className="px-4 py-2">耗时(μs)</th><th className="px-4 py-2">时间</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{slowlog.map(function (s: any, i: number) {
|
||||
return <tr key={i} className="border-b border-[var(--dg-border)]">
|
||||
<td className="px-4 py-2 font-mono text-xs">{Array.isArray(s.command) ? s.command.join(' ') : s.command || '-'}</td>
|
||||
<td className="px-4 py-2">{s.duration || s.execution_time || '-'}</td>
|
||||
<td className="px-4 py-2">{s.timestamp || '-'}</td>
|
||||
</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{slowlog.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无慢日志</div>}
|
||||
</div>
|
||||
<div className="dg-card p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">客户端列表</h3>
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||||
<th className="px-4 py-2">ID</th><th className="px-4 py-2">地址</th><th className="px-4 py-2">DB</th><th className="px-4 py-2">空闲(s)</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{clients.map(function (c: any, i: number) {
|
||||
return <tr key={i} className="border-b border-[var(--dg-border)]">
|
||||
<td className="px-4 py-2">{c.id || c.fd || '-'}</td>
|
||||
<td className="px-4 py-2">{c.addr || c.host || '-'}</td>
|
||||
<td className="px-4 py-2">{c.db || '-'}</td>
|
||||
<td className="px-4 py-2">{c.idle || '-'}</td>
|
||||
</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 键详情弹窗 */}
|
||||
{selectedKey && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={function () { setSelectedKey(null); }}>
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] overflow-y-auto p-6" onClick={function (e) { e.stopPropagation(); }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold">键详情</h3>
|
||||
<button onClick={function () { setSelectedKey(null); }} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">✕</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2 text-sm"><span className="text-[var(--dg-text-muted)]">键名:</span><span className="font-mono">{selectedKey.key || selectedKey.name}</span></div>
|
||||
<div className="flex gap-2 text-sm"><span className="text-[var(--dg-text-muted)]">类型:</span><span>{selectedKey.type}</span></div>
|
||||
<div className="flex gap-2 text-sm"><span className="text-[var(--dg-text-muted)]">TTL:</span><span>{selectedKey.ttl != null ? selectedKey.ttl + 's' : '-'}</span></div>
|
||||
<div className="text-sm"><span className="text-[var(--dg-text-muted)]">值:</span>
|
||||
<pre className="mt-1 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs font-mono max-h-60 overflow-y-auto">
|
||||
{typeof selectedKey.value === 'object' ? JSON.stringify(selectedKey.value, null, 2) : String(selectedKey.value || '-')}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新增键弹窗 */}
|
||||
{showAddKey && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={function () { setShowAddKey(false); }}>
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-md p-6" onClick={function (e) { e.stopPropagation(); }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold">新增键</h3>
|
||||
<button onClick={function () { setShowAddKey(false); }} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">✕</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div><label className="text-sm text-[var(--dg-text-muted)]">类型</label><select value={newKey.type} onChange={function (e) { setNewKey(Object.assign({}, newKey, { type: e.target.value })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
|
||||
<option value="string">String</option><option value="hash">Hash</option><option value="list">List</option><option value="set">Set</option><option value="zset">ZSet</option>
|
||||
</select></div>
|
||||
<div><label className="text-sm text-[var(--dg-text-muted)]">键名</label><input value={newKey.key} onChange={function (e) { setNewKey(Object.assign({}, newKey, { key: e.target.value })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="key" /></div>
|
||||
<div><label className="text-sm text-[var(--dg-text-muted)]">值</label><textarea value={newKey.value} onChange={function (e) { setNewKey(Object.assign({}, newKey, { value: e.target.value })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono min-h-[80px]" placeholder="value" /></div>
|
||||
<div><label className="text-sm text-[var(--dg-text-muted)]">TTL (秒, -1 永不过期)</label><input type="number" value={newKey.ttl} onChange={function (e) { setNewKey(Object.assign({}, newKey, { ttl: Number(e.target.value) })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" /></div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<button onClick={function () { setShowAddKey(false); }} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm">取消</button>
|
||||
<button onClick={handleAddKey} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight, Check, AlertTriangle, Shield } from 'lucide-react';
|
||||
import { ticketApi } from '@/api/ticket';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
|
||||
interface InstanceItem {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
db_type: string;
|
||||
}
|
||||
|
||||
interface AuditFinding {
|
||||
rule: string;
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface AuditResult {
|
||||
score: number;
|
||||
passed: boolean;
|
||||
has_warnings: boolean;
|
||||
findings: AuditFinding[];
|
||||
}
|
||||
|
||||
var levelColor: Record<string, string> = {
|
||||
error: 'text-red-400 bg-red-500/15',
|
||||
warning: 'text-yellow-400 bg-yellow-500/15',
|
||||
info: 'text-blue-400 bg-blue-500/15',
|
||||
};
|
||||
|
||||
var levelLabel: Record<string, string> = {
|
||||
error: '错误',
|
||||
warning: '警告',
|
||||
info: '提示',
|
||||
};
|
||||
|
||||
export default function TicketCreatePage() {
|
||||
var navigate = useNavigate();
|
||||
var [step, setStep] = useState(1);
|
||||
var [title, setTitle] = useState('');
|
||||
var [priority, setPriority] = useState('medium');
|
||||
var [instanceId, setInstanceId] = useState('');
|
||||
var [database, setDatabase] = useState('');
|
||||
var [sqlContent, setSqlContent] = useState('');
|
||||
var [reason, setReason] = useState('');
|
||||
var [instances, setInstances] = useState<InstanceItem[]>([]);
|
||||
var [auditResult, setAuditResult] = useState<AuditResult | null>(null);
|
||||
var [auditing, setAuditing] = useState(false);
|
||||
var [submitting, setSubmitting] = useState(false);
|
||||
var [error, setError] = useState('');
|
||||
|
||||
useEffect(function () {
|
||||
instanceApi.list().then(function (res) {
|
||||
var data = (res as any)?.items || res;
|
||||
setInstances(Array.isArray(data) ? data : []);
|
||||
}).catch(function () {
|
||||
setInstances([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
var handleAudit = async function () {
|
||||
if (!sqlContent || !instanceId) {
|
||||
setError('请填写SQL和选择实例');
|
||||
return;
|
||||
}
|
||||
setAuditing(true);
|
||||
setError('');
|
||||
try {
|
||||
var result = await ticketApi.audit({
|
||||
sql_content: sqlContent,
|
||||
instance_id: Number(instanceId),
|
||||
database: database,
|
||||
}) as unknown as AuditResult;
|
||||
setAuditResult(result);
|
||||
setStep(2);
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || '审核失败');
|
||||
} finally {
|
||||
setAuditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
var handleSubmit = async function () {
|
||||
if (!title || !sqlContent || !instanceId || !database || !reason) {
|
||||
setError('请填写所有必填项');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
var inst = instances.find(function (i) { return i.id === Number(instanceId); });
|
||||
await ticketApi.create({
|
||||
title: title,
|
||||
db_type: inst?.db_type || 'mysql',
|
||||
instance_id: Number(instanceId),
|
||||
target_database: database,
|
||||
sql_content: sqlContent,
|
||||
reason: reason,
|
||||
priority: priority,
|
||||
});
|
||||
navigate('/tickets');
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
var riskLevel = auditResult
|
||||
? auditResult.score >= 90 ? 'low'
|
||||
: auditResult.score >= 60 ? 'medium' : 'high'
|
||||
: '';
|
||||
|
||||
var riskLabel: Record<string, string> = { low: '低风险', medium: '中风险', high: '高风险' };
|
||||
var riskColor: Record<string, string> = {
|
||||
low: 'text-green-400 bg-green-500/15 border-green-500/30',
|
||||
medium: 'text-yellow-400 bg-yellow-500/15 border-yellow-500/30',
|
||||
high: 'text-red-400 bg-red-500/15 border-red-500/30',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={function () { navigate('/tickets'); }} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all">
|
||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-semibold">新建数据变更工单</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps indicator */}
|
||||
<div className="flex items-center gap-2 p-4 bg-[var(--dg-bg-secondary)] rounded-lg">
|
||||
{[
|
||||
{ num: 1, label: '填写信息' },
|
||||
{ num: 2, label: 'SQL审核' },
|
||||
{ num: 3, label: '确认提交' },
|
||||
].map(function (s, idx) {
|
||||
var isActive = step === s.num;
|
||||
var isDone = step > s.num;
|
||||
return (
|
||||
<div key={s.num} className="flex items-center flex-1">
|
||||
<div className={'flex items-center gap-2 flex-1 px-4 py-2.5 rounded-lg transition-colors ' + (isActive ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : isDone ? 'bg-green-500/10 text-green-400' : 'text-[var(--dg-text-muted)]')}>
|
||||
{isDone ? <Check className="w-4 h-4" /> : <span className="w-5 h-5 rounded-full border text-xs flex items-center justify-center">{s.num}</span>}
|
||||
<span className="text-sm font-medium">{s.label}</span>
|
||||
</div>
|
||||
{idx < 2 && <ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)] mx-1 shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-400 bg-red-500/10 px-4 py-2 rounded-lg">{error}</div>}
|
||||
|
||||
{/* Step 1: Fill info */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4 bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">工单标题 <span className="text-red-400">*</span></label>
|
||||
<input value={title} onChange={function (e) { setTitle(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="请输入工单标题" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">优先级</label>
|
||||
<select value={priority} onChange={function (e) { setPriority(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
|
||||
<option value="low">低</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="high">高</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">选择实例 <span className="text-red-400">*</span></label>
|
||||
<select value={instanceId} onChange={function (e) { setInstanceId(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
|
||||
<option value="">请选择实例</option>
|
||||
{instances.map(function (inst) {
|
||||
return <option key={inst.id} value={String(inst.id)}>{inst.name} ({inst.host}:{inst.port})</option>;
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">数据库 <span className="text-red-400">*</span></label>
|
||||
<input value={database} onChange={function (e) { setDatabase(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="目标数据库名" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">SQL语句 <span className="text-red-400">*</span></label>
|
||||
<textarea value={sqlContent} onChange={function (e) { setSqlContent(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono min-h-[180px] resize-y" placeholder="请输入SQL语句" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">变更原因 <span className="text-red-400">*</span></label>
|
||||
<textarea value={reason} onChange={function (e) { setReason(e.target.value); }} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm min-h-[80px] resize-y" placeholder="请描述变更原因" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button onClick={handleAudit} disabled={auditing} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm disabled:opacity-50">
|
||||
{auditing ? '审核中...' : '开始审核'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Audit result */}
|
||||
{step === 2 && auditResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-blue-400" />
|
||||
SQL审核结果
|
||||
</h3>
|
||||
<div className={'flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border ' + riskColor[riskLevel]}>
|
||||
<span className="text-2xl font-bold">{auditResult.score}</span>
|
||||
<span>分 · {riskLabel[riskLevel]}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{auditResult.findings.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--dg-text-secondary)]">
|
||||
<Check className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
||||
<p className="text-lg font-medium">审核通过,未发现风险</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{auditResult.findings.map(function (f, idx) {
|
||||
return (
|
||||
<div key={idx} className={'flex items-start gap-3 p-3 rounded-lg ' + (levelColor[f.level] || '')}>
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{levelLabel[f.level] || f.level}</span>
|
||||
<span className="opacity-60">·</span>
|
||||
<span className="font-mono text-xs">{f.rule}</span>
|
||||
</div>
|
||||
<p className="text-sm opacity-80 mt-0.5">{f.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button onClick={function () { setStep(1); }} className="px-4 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
|
||||
上一步
|
||||
</button>
|
||||
<button onClick={function () { setStep(3); }} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">
|
||||
下一步:确认提交
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Confirm and submit */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-4">
|
||||
<h3 className="text-lg font-semibold">确认工单信息</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-[var(--dg-text-muted)]">标题</span>
|
||||
<p className="font-medium mt-1">{title}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--dg-text-muted)]">优先级</span>
|
||||
<p className="font-medium mt-1">{priority === 'high' ? '高' : priority === 'medium' ? '中' : '低'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--dg-text-muted)]">实例</span>
|
||||
<p className="font-medium mt-1">{instances.find(function (i) { return i.id === Number(instanceId); })?.name || instanceId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--dg-text-muted)]">数据库</span>
|
||||
<p className="font-medium mt-1">{database}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">SQL语句</span>
|
||||
<pre className="mt-1 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap">{sqlContent}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">变更原因</span>
|
||||
<p className="mt-1 text-sm">{reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button onClick={function () { setStep(2); }} className="px-4 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">
|
||||
上一步
|
||||
</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm disabled:opacity-50">
|
||||
{submitting ? '提交中...' : '确认提交'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ChevronRight, FileCode, Clock, CheckCircle, XCircle,
|
||||
Play, RotateCcw, X, Shield,
|
||||
} from 'lucide-react';
|
||||
import { ticketApi } from '@/api/ticket';
|
||||
import { statusMap, statusColorMap } from '@/lib/mock';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface TicketData {
|
||||
id: number;
|
||||
title: string;
|
||||
db_type: string;
|
||||
instance_id: number;
|
||||
target_database: string;
|
||||
sql_content: string;
|
||||
rollback_sql: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
applicant_id: number;
|
||||
applicant_name: string | null;
|
||||
approver_id: number | null;
|
||||
approver_name: string | null;
|
||||
reason: string | null;
|
||||
audit_result: string | null;
|
||||
execute_result: string | null;
|
||||
create_time: string;
|
||||
review_time: string | null;
|
||||
execute_time: string | null;
|
||||
}
|
||||
|
||||
interface TimelineEntry {
|
||||
action: string;
|
||||
label: string;
|
||||
user: string;
|
||||
time: string | null;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
var actionIcon: Record<string, React.ReactNode> = {
|
||||
CREATE_TICKET: <CheckCircle className="w-4 h-4 text-blue-400" />,
|
||||
REVIEW_TICKET: <Shield className="w-4 h-4 text-green-400" />,
|
||||
EXECUTE_TICKET: <Play className="w-4 h-4 text-purple-400" />,
|
||||
CANCEL_TICKET: <XCircle className="w-4 h-4 text-red-400" />,
|
||||
ROLLBACK_TICKET: <RotateCcw className="w-4 h-4 text-orange-400" />,
|
||||
};
|
||||
|
||||
export default function TicketDetailPage() {
|
||||
var params = useParams();
|
||||
var navigate = useNavigate();
|
||||
var auth = useAuth();
|
||||
var ticketId = Number(params.id);
|
||||
|
||||
var [ticket, setTicket] = useState<TicketData | null>(null);
|
||||
var [timeline, setTimeline] = useState<TimelineEntry[]>([]);
|
||||
var [loading, setLoading] = useState(true);
|
||||
var [actionLoading, setActionLoading] = useState(false);
|
||||
var [comment, setComment] = useState('');
|
||||
var [showRejectInput, setShowRejectInput] = useState(false);
|
||||
|
||||
var fetchTicket = async function () {
|
||||
try {
|
||||
var data = await ticketApi.get(ticketId) as unknown as TicketData;
|
||||
setTicket(data);
|
||||
} catch {
|
||||
setTicket(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
var fetchTimeline = async function () {
|
||||
try {
|
||||
var data = await ticketApi.timeline(ticketId) as any;
|
||||
setTimeline(data.timeline || []);
|
||||
} catch {
|
||||
setTimeline([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(function () {
|
||||
fetchTicket();
|
||||
fetchTimeline();
|
||||
}, [ticketId]);
|
||||
|
||||
var handleAction = async function (action: string) {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
if (action === 'approve') {
|
||||
await ticketApi.review(ticketId, { action: 'approve', comment: '审批通过' });
|
||||
} else if (action === 'reject') {
|
||||
await ticketApi.review(ticketId, { action: 'reject', comment: comment || '拒绝' });
|
||||
setShowRejectInput(false);
|
||||
setComment('');
|
||||
} else if (action === 'execute') {
|
||||
await ticketApi.execute(ticketId);
|
||||
} else if (action === 'rollback') {
|
||||
await ticketApi.rollback(ticketId);
|
||||
} else if (action === 'cancel') {
|
||||
await ticketApi.cancel(ticketId);
|
||||
}
|
||||
fetchTicket();
|
||||
fetchTimeline();
|
||||
} catch {
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center h-64 text-[var(--dg-text-muted)]">加载中...</div>;
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)]">
|
||||
<p>工单不存在</p>
|
||||
<button onClick={function () { navigate('/tickets'); }} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm">返回列表</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
var isAdminOrDba = auth.user.role === 'admin' || auth.user.role === 'dba';
|
||||
var isApplicant = ticket.applicant_id === auth.user.id;
|
||||
var priorityLabel = ticket.priority === 'high' ? '高' : ticket.priority === 'medium' ? '中' : '低';
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={function () { navigate('/tickets'); }} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all">
|
||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold flex items-center gap-3">
|
||||
{ticket.title}
|
||||
<span className={'px-2.5 py-1 rounded-full text-xs font-medium ' + (statusColorMap[ticket.status] || '')}>
|
||||
{statusMap[ticket.status] || ticket.status}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
|
||||
TK-{ticket.id} · 由 {ticket.applicant_name || '-'} 创建于 {new Date(ticket.create_time).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* Approve/Reject for admin/dba when pending */}
|
||||
{isAdminOrDba && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||||
<>
|
||||
<button onClick={function () { handleAction('approve'); }} disabled={actionLoading} className="flex items-center gap-2 px-4 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">
|
||||
<CheckCircle className="w-4 h-4" /> 通过
|
||||
</button>
|
||||
{!showRejectInput ? (
|
||||
<button onClick={function () { setShowRejectInput(true); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-red-500/15 border border-red-500/30 rounded-lg text-sm font-medium text-red-400 hover:bg-red-500/25 transition-all">
|
||||
<XCircle className="w-4 h-4" /> 拒绝
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<input value={comment} onChange={function (e) { setComment(e.target.value); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm w-48" placeholder="拒绝原因..." />
|
||||
<button onClick={function () { handleAction('reject'); }} className="px-3 py-2 bg-red-500/15 border border-red-500/30 rounded-lg text-sm text-red-400">确认拒绝</button>
|
||||
<button onClick={function () { setShowRejectInput(false); }} className="px-3 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg text-sm">取消</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Execute for admin/dba when approved */}
|
||||
{isAdminOrDba && ticket.status === 'approved' && (
|
||||
<button onClick={function () { handleAction('execute'); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-purple-500/15 border border-purple-500/30 rounded-lg text-sm font-medium text-purple-400 hover:bg-purple-500/25 transition-all">
|
||||
<Play className="w-4 h-4" /> 执行
|
||||
</button>
|
||||
)}
|
||||
{/* Rollback for admin when completed/success */}
|
||||
{isAdminOrDba && (ticket.status === 'success' || ticket.status === 'failed') && (
|
||||
<button onClick={function () { handleAction('rollback'); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-orange-500/15 border border-orange-500/30 rounded-lg text-sm font-medium text-orange-400 hover:bg-orange-500/25 transition-all">
|
||||
<RotateCcw className="w-4 h-4" /> 回滚
|
||||
</button>
|
||||
)}
|
||||
{/* Cancel for applicant when pending */}
|
||||
{isApplicant && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||||
<button onClick={function () { handleAction('cancel'); }} disabled={actionLoading} className="flex items-center gap-2 px-4 py-2.5 bg-gray-500/15 border border-gray-500/30 rounded-lg text-sm font-medium text-gray-400 hover:bg-gray-500/25 transition-all">
|
||||
<X className="w-4 h-4" /> 取消工单
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info cards */}
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5 space-y-3">
|
||||
<h3 className="text-[15px] font-semibold border-b border-[var(--dg-border)] pb-3">工单信息</h3>
|
||||
{[
|
||||
{ label: '工单号', value: 'TK-' + ticket.id },
|
||||
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
|
||||
{ label: '目标数据库', value: ticket.target_database },
|
||||
{ label: '申请人', value: ticket.applicant_name || '-' },
|
||||
{ label: '审批人', value: ticket.approver_name || '-' },
|
||||
{ label: '优先级', value: priorityLabel },
|
||||
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
|
||||
{ label: '变更原因', value: ticket.reason || '-' },
|
||||
].map(function (item, idx) {
|
||||
return (
|
||||
<div key={idx} className="flex justify-between py-2 border-b border-[var(--dg-border-light)] last:border-0">
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">{item.label}</span>
|
||||
<span className="text-sm font-medium">{item.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
||||
<FileCode className="w-4 h-4 text-blue-400" /> SQL 内容
|
||||
</h3>
|
||||
<pre className="mt-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap">{ticket.sql_content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audit result */}
|
||||
{ticket.audit_result && (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
||||
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
|
||||
</h3>
|
||||
<div className="mt-3 text-sm">{ticket.audit_result}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rollback SQL */}
|
||||
{ticket.rollback_sql && (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
||||
<RotateCcw className="w-4 h-4 text-purple-400" /> 回滚 SQL
|
||||
</h3>
|
||||
<pre className="mt-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap">{ticket.rollback_sql}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execute result */}
|
||||
{ticket.execute_result && (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
||||
<Play className="w-4 h-4 text-purple-400" /> 执行结果
|
||||
</h3>
|
||||
<pre className="mt-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap">{ticket.execute_result}</pre>
|
||||
{ticket.execute_time && <div className="text-xs text-[var(--dg-text-muted)] mt-2">执行时间: {new Date(ticket.execute_time).toLocaleString()}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{timeline.length > 0 && (
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3">
|
||||
<Clock className="w-4 h-4 text-blue-400" /> 操作时间线
|
||||
</h3>
|
||||
<div className="mt-4 relative pl-8">
|
||||
<div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-[var(--dg-border)]" />
|
||||
{timeline.map(function (entry, idx) {
|
||||
return (
|
||||
<div key={idx} className="relative mb-6 last:mb-0">
|
||||
<div className="absolute -left-8 top-0.5 w-5 h-5 rounded-full bg-[var(--dg-bg-secondary)] border-2 border-[var(--dg-border)] flex items-center justify-center z-10">
|
||||
{actionIcon[entry.action] || <div className="w-2 h-2 rounded-full bg-[var(--dg-text-muted)]" />}
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{entry.label}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">by {entry.user}</span>
|
||||
</div>
|
||||
{entry.time && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
||||
{new Date(entry.time).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
{entry.detail && (
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] mt-1 p-2 bg-[var(--dg-bg-primary)] rounded-lg">
|
||||
{entry.detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user