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:
logan committed 2026-05-22 23:10:14 +08:00
1 parent 5cbd0e3441
commit dfdac3ad37
7 files changed
+1415 -8

No files matched your search

+197
View File
@@ -2,6 +2,7 @@ from typing import Optional, List, Dict, Any
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, func, or_ from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession 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 from app.services.audit_log_service import AuditLogService
import json import json
class AuditRequest(BaseModel):
sql_content: str
instance_id: int
database: str = ""
router = APIRouter(prefix="/tickets", tags=["工单管理"]) 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}") @router.get("/{ticket_id}")
async def get_ticket( async def get_ticket(
ticket_id: int, ticket_id: int,
@@ -216,6 +250,169 @@ async def review_ticket(
return await _enrich_ticket(ticket, db) 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") @router.post("/{ticket_id}/execute")
async def execute_ticket( async def execute_ticket(
ticket_id: int, ticket_id: int,
+8
View File
@@ -16,6 +16,10 @@ import AutoTaskPage from '@/pages/AutoTaskPage';
import SettingsPage from '@/pages/SettingsPage'; import SettingsPage from '@/pages/SettingsPage';
import AuditLogsPage from '@/pages/AuditLogsPage'; import AuditLogsPage from '@/pages/AuditLogsPage';
import DataDictionaryPage from '@/pages/DataDictionaryPage'; 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() { function RouteGuard() {
@@ -52,8 +56,12 @@ function AppRoutes() {
<Route index element={<Navigate to="/dashboard" replace />} /> <Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} /> <Route path="dashboard" element={<Dashboard />} />
<Route path="instances" element={<InstancePage />} /> <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="data-dictionary" element={<DataDictionaryPage />} />
<Route path="tickets" element={<TicketsPage />} /> <Route path="tickets" element={<TicketsPage />} />
<Route path="tickets/create" element={<TicketCreatePage />} />
<Route path="tickets/:id" element={<TicketDetailPage />} />
<Route path="queries" element={<QueriesPage />} /> <Route path="queries" element={<QueriesPage />} />
<Route path="roles" element={<RolesPage />} /> <Route path="roles" element={<RolesPage />} />
<Route path="users" element={<UsersPage />} /> <Route path="users" element={<UsersPage />} />
+31 -8
View File
@@ -1,11 +1,34 @@
import request from './request'; import request from './request';
export const ticketApi = { export var ticketApi = {
list: (params?: any) => request.get<any, any>('/tickets', { params }), list: function (params?: any) {
get: (id: number) => request.get<any, any>(`/tickets/${id}`), return request.get('/tickets', { params: params });
create: (data: any) => request.post<any, any>('/tickets', data), },
review: (id: number, data: { action: 'approve' | 'reject'; comment: string }) => get: function (id: number) {
request.post<any, any>(`/tickets/${id}/review`, data), return request.get('/tickets/' + id);
execute: (id: number) => request.post<any, any>(`/tickets/${id}/execute`), },
statistics: () => request.get('/tickets/statistics'), 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);
},
}; };
+289
View File
@@ -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>
);
}
+294
View File
@@ -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>
);
}
+307
View File
@@ -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>
);
}
+289
View File
@@ -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>
);
}