feat: 工单权限过滤+仪表盘优化+LDAP登录+审批流程+待处理工单

后端:
- ticket_statistics/list_tickets 按用户身份过滤(admin全部, dba/auditor审批相关, developer自己的)
- 新增 /dashboard/my-tickets 接口(按角色返回待处理工单)
- auth.py 本地优先+LDAP fallback 登录认证
- ldap_config.py LDAPS 证书验证修复
- instance_detail.py developer/auditor 权限修复

前端:
- Dashboard 仪表盘: 3核心大数字指标+公告+我的待处理工单
- 删除 ECharts 趋势图(无真实数据)
- TicketDetailPage 审批流程时间线展示
- TicketsPage 工单统计按用户过滤+关联列(我提交的/待我审批)
- types 新增 TicketApproval 等类型定义
This commit is contained in:
logan committed 2026-05-25 20:47:15 +08:00
1 parent be2d6f3894
commit 43d1f3a234
10 files changed
+587 -440

No files matched your search

+102 -13
View File
@@ -4,38 +4,127 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.core.security import verify_password, create_access_token, get_current_user from app.core.security import verify_password, hash_password, create_access_token, get_current_user
from app.models.user import User from app.models.user import User
from app.schemas.auth import LoginRequest, TokenResponse, UserBrief from app.schemas.auth import LoginRequest, TokenResponse, UserBrief
router = APIRouter(prefix="/auth", tags=["认证"]) router = APIRouter(prefix="/auth", tags=["认证"])
async def _try_ldap_auth(username: str, password: str):
"""尝试 LDAP 认证,成功返回用户信息 dict,失败返回 None"""
from app.core.database import async_session
from app.models.ldap_config import LdapConfig
from app.core.crypto import decrypt_password
import json
async with async_session() as db:
result = await db.execute(select(LdapConfig).limit(1))
ldap = result.scalar_one_or_none()
if not ldap or not ldap.ldap_host:
return None
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
# Admin bind to search user
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
# Search with uid filter
base_filter = ldap.search_filter or "(objectClass=inetOrgPerson)"
if "%s" in base_filter:
user_filter = base_filter.replace("%s", username)
else:
user_filter = f"(&{base_filter}(uid={username}))"
results = conn.search_s(
ldap.base_dn, _ldap.SCOPE_SUBTREE, user_filter,
["uid", "cn", "mail", "telephoneNumber"],
)
if not results or not results[0][0]:
conn.unbind_s()
return None
user_dn, user_attrs = results[0]
# Verify password by binding as user
try:
conn.simple_bind_s(user_dn, password)
except Exception:
conn.unbind_s()
return None
# Extract info
def _attr(key):
vals = user_attrs.get(key, [])
return vals[0].decode("utf-8", errors="ignore") if vals else ""
user_mapping = json.loads(ldap.user_mapping) if ldap.user_mapping else {}
info = {
"username": _attr(user_mapping.get("username", "uid")) or username,
"display_name": _attr(user_mapping.get("display_name", "cn")) or username,
"email": _attr(user_mapping.get("email", "mail")) or None,
}
conn.unbind_s()
return info
except Exception:
return None
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)): async def login(req: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.username == req.username)) result = await db.execute(select(User).where(User.username == req.username))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user or not verify_password(req.password, user.hashed_password): auth_source = "local"
# 记录登录失败
from app.services.audit_logger import log_operation if user:
await log_operation(db, action="login_failed", username=req.username, # 本地用户存在,用本地密码验证
detail=f"登录失败: 用户名或密码错误", request=request) if not verify_password(req.password, user.hashed_password):
await db.commit() # 本地密码不对,记录失败
raise HTTPException(status_code=401, detail="用户名或密码错误") from app.services.audit_logger import log_operation
await log_operation(db, action="login_failed", username=req.username,
detail="登录失败: 用户名或密码错误", request=request)
await db.commit()
raise HTTPException(status_code=401, detail="用户名或密码错误")
else:
# 本地没有该用户,尝试 LDAP 认证
ldap_info = await _try_ldap_auth(req.username, req.password)
if not ldap_info:
from app.services.audit_logger import log_operation
await log_operation(db, action="login_failed", username=req.username,
detail="登录失败: 本地和LDAP均未通过", request=request)
await db.commit()
raise HTTPException(status_code=401, detail="用户名或密码错误")
# LDAP 认证成功,自动创建本地用户
user = User(
username=ldap_info["username"],
display_name=ldap_info["display_name"],
email=ldap_info.get("email"),
hashed_password=hash_password(req.password),
role="developer",
is_active=True,
)
db.add(user)
await db.flush()
auth_source = "ldap"
if not user.is_active: if not user.is_active:
raise HTTPException(status_code=403, detail="用户已禁用") raise HTTPException(status_code=403, detail="用户已禁用")
if user.role == "guest":
raise HTTPException(status_code=403, detail="访客账户已禁用,请联系管理员")
user.last_login = datetime.now(timezone.utc) user.last_login = datetime.now(timezone.utc)
# 记录登录成功
from app.services.audit_logger import log_operation from app.services.audit_logger import log_operation
await log_operation(db, action="login", user_id=user.id, username=user.username, await log_operation(db, action="login", user_id=user.id, username=user.username,
detail=f"登录成功", request=request) detail=f"登录成功 ({auth_source})", request=request)
await db.commit() await db.commit()
token = create_access_token(data={"sub": str(user.id), "role": user.role}) token = create_access_token(data={"sub": str(user.id), "role": user.role})
+91
View File
@@ -131,3 +131,94 @@ async def get_trend(
items.append(TrendPoint(date=label, **d)) items.append(TrendPoint(date=label, **d))
return TrendData(items=items) return TrendData(items=items)
@router.get("/my-tickets")
async def my_pending_tickets(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""返回当前用户关联的待处理工单:
- admin: 所有 pending/reviewing/executing
- dba: 自己需要审批的 + 自己提交的
- developer/auditor: 自己提交的
"""
from app.models.workflow import TicketApproval, WorkflowStep
from sqlalchemy import or_
import json as _json
user_id = current_user.id
role = current_user.role
if role == "admin":
# admin 看所有待处理
rows = (await db.execute(
select(Ticket).where(
Ticket.status.in_(["pending", "reviewing", "executing"])
).order_by(Ticket.create_time.desc()).limit(10)
)).scalars().all()
else:
# 非admin: 找"和我有关"的工单
# 1) 我提交的
my_submitted = set((await db.execute(
select(Ticket.id).where(
Ticket.status.in_(["pending", "reviewing", "executing"]),
Ticket.applicant_id == user_id,
)
)).scalars().all())
# 2) ticket_approvals 中 action=pending 且 approver_id = me
my_approvals = set((await db.execute(
select(TicketApproval.ticket_id).where(
TicketApproval.action == "pending",
TicketApproval.approver_id == user_id,
)
)).scalars().all())
# 3) 通过角色匹配: approver_role == my role
my_role_approvals = set((await db.execute(
select(TicketApproval.ticket_id).where(
TicketApproval.action == "pending",
TicketApproval.approver_role == role,
)
)).scalars().all())
all_ids = my_submitted | my_approvals | my_role_approvals
if not all_ids:
return []
rows = (await db.execute(
select(Ticket).where(Ticket.id.in_(all_ids)).order_by(Ticket.create_time.desc()).limit(10)
)).scalars().all()
# 用户名映射
all_users = (await db.execute(select(User))).scalars().all()
user_map = {u.id: u.username for u in all_users}
result = []
for t in rows:
pending_approvals = (await db.execute(
select(TicketApproval).where(
TicketApproval.ticket_id == t.id,
TicketApproval.action == "pending",
)
)).scalars().all()
pending_info = []
for pa in pending_approvals:
approver_name = user_map.get(pa.approver_id, "")
label = pa.step_name or ""
if approver_name:
pending_info.append(f"{label}: {approver_name}" if label else approver_name)
is_mine = t.applicant_id == user_id
result.append({
"id": t.id,
"title": t.title or "",
"status": t.status,
"db_type": t.db_type or "",
"applicant": user_map.get(t.applicant_id, ""),
"create_time": t.create_time.strftime("%m-%d %H:%M") if t.create_time else "",
"pending_info": ", ".join(filter(None, pending_info)),
"relation": "我提交的" if is_mine else "待我审批",
})
return result
@@ -68,7 +68,7 @@ async def _mysql_execute(instance: Instance, sql: str, database: Optional[str] =
async def list_databases( async def list_databases(
instance_id: int, instance_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")), current_user: User = Depends(require_role("admin", "dba", "developer", "auditor")),
): ):
instance = await _get_decrypted_instance(db, instance_id) instance = await _get_decrypted_instance(db, instance_id)
# P0: 权限检查 # P0: 权限检查
@@ -93,7 +93,7 @@ async def list_tables(
instance_id: int, instance_id: int,
database: str = Query(..., description="数据库名"), database: str = Query(..., description="数据库名"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")), current_user: User = Depends(require_role("admin", "dba", "developer", "auditor")),
): ):
instance = await _get_decrypted_instance(db, instance_id) instance = await _get_decrypted_instance(db, instance_id)
# P0: 权限检查 # P0: 权限检查
@@ -160,7 +160,7 @@ async def table_detail(
database: str = Query(..., description="数据库名"), database: str = Query(..., description="数据库名"),
table: str = Query(..., description="表名"), table: str = Query(..., description="表名"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")), current_user: User = Depends(require_role("admin", "dba", "developer", "auditor")),
): ):
instance = await _get_decrypted_instance(db, instance_id) instance = await _get_decrypted_instance(db, instance_id)
# P0: 权限检查 # P0: 权限检查
+7 -3
View File
@@ -120,9 +120,13 @@ async def test_ldap_connection(
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10) conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password)) conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
# Search user # Search user — always filter by uid to avoid matching wrong user
search_filter = ldap.search_filter or "(uid=%s)" base_filter = ldap.search_filter or "(objectClass=inetOrgPerson)"
user_filter = search_filter.replace("%s", data.test_user) if "%s" in base_filter:
user_filter = base_filter.replace("%s", data.test_user)
else:
# search_filter has no placeholder, append uid condition
user_filter = f"(&{base_filter}(uid={data.test_user}))"
results = conn.search_s( results = conn.search_s(
ldap.base_dn, ldap.base_dn,
_ldap.SCOPE_SUBTREE, _ldap.SCOPE_SUBTREE,
+123 -25
View File
@@ -299,24 +299,60 @@ async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]:
# Resolve current approver (审批/执行阶段的当前待审核人) # Resolve current approver (审批/执行阶段的当前待审核人)
data["current_approver_name"] = None data["current_approver_name"] = None
if ticket.status in ("reviewing", "approved", "executing"): data["pending_steps"] = []
pending_approval = await db.execute( if ticket.status in ("pending", "reviewing", "approved", "executing"):
pending_result = await db.execute(
select(TicketApproval) select(TicketApproval)
.where( .where(
TicketApproval.ticket_id == ticket.id, TicketApproval.ticket_id == ticket.id,
TicketApproval.action == "pending", TicketApproval.action == "pending",
) )
.order_by(TicketApproval.step_number) .order_by(TicketApproval.step_number)
.limit(1)
) )
pending = pending_approval.scalar_one_or_none() pending_list = pending_result.scalars().all()
if pending:
if pending.approver_id: pending_names_all = []
r = await db.execute(select(User).where(User.id == pending.approver_id)) for pa in pending_list:
approver_user = r.scalar_one_or_none() step_info = {
data["current_approver_name"] = approver_user.display_name if approver_user else None "step_number": pa.step_number,
elif pending.approver_role: "step_name": pa.step_name,
data["current_approver_name"] = pending.approver_role "step_type": pa.step_type,
"approver_names": [],
}
# Try ticket_approval.approver_id first
if pa.approver_id:
r = await db.execute(select(User).where(User.id == pa.approver_id))
u = r.scalar_one_or_none()
if u:
step_info["approver_names"].append(u.display_name or u.username)
# Fallback: resolve from workflow_steps.approver_ids
if not step_info["approver_names"] and ticket.workflow_id:
ws_result = await db.execute(
select(WorkflowStep).where(
WorkflowStep.workflow_id == ticket.workflow_id,
WorkflowStep.step_number == pa.step_number,
)
)
ws = ws_result.scalar_one_or_none()
if ws and ws.approver_ids:
try:
import json as _json
ids = _json.loads(ws.approver_ids) if isinstance(ws.approver_ids, str) else ws.approver_ids
for uid in (ids or []):
r = await db.execute(select(User).where(User.id == uid))
u = r.scalar_one_or_none()
if u:
step_info["approver_names"].append(u.display_name or u.username)
except (json.JSONDecodeError, TypeError):
pass
if step_info["approver_names"]:
pending_names_all.extend(step_info["approver_names"])
data["pending_steps"].append(step_info)
# Backward compat: single string
if pending_names_all:
data["current_approver_name"] = ", ".join(dict.fromkeys(pending_names_all))
return data return data
@@ -411,16 +447,37 @@ async def list_tickets(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""获取工单列表。 """获取工单列表(按用户身份过滤)。"""
- admin/dba 可查看所有工单
- developer 只能查看自己的工单
"""
stmt = select(Ticket) stmt = select(Ticket)
# 权限过滤: developer 只能看到自己的工单 # 权限过滤
if current_user.role == "developer": if current_user.role == "admin":
pass # admin 看全部
elif current_user.role == "developer":
stmt = stmt.where(Ticket.applicant_id == current_user.id) stmt = stmt.where(Ticket.applicant_id == current_user.id)
else:
# dba/auditor: 自己提交的 + 自己需要审批的
from app.models.workflow import TicketApproval as _TA
_my_sub = set((await db.execute(
select(Ticket.id).where(Ticket.applicant_id == current_user.id)
)).scalars().all())
_my_appr = set((await db.execute(
select(_TA.ticket_id).where(
_TA.action == "pending",
_TA.approver_id == current_user.id,
)
)).scalars().all())
_my_role = set((await db.execute(
select(_TA.ticket_id).where(
_TA.action == "pending",
_TA.approver_role == current_user.role,
)
)).scalars().all())
_all_ids = _my_sub | _my_appr | _my_role
if _all_ids:
stmt = stmt.where(Ticket.id.in_(_all_ids))
else:
stmt = stmt.where(Ticket.applicant_id == current_user.id)
# 状态过滤 # 状态过滤
if status: if status:
@@ -678,8 +735,38 @@ async def ticket_statistics(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""获取工单统计信息。""" """获取工单统计信息(按用户身份过滤)。"""
stmt = select(Ticket.status, func.count(Ticket.id)).group_by(Ticket.status) # 构建用户可见工单的基础查询
base_stmt = select(Ticket)
if current_user.role == "developer":
# developer 只看自己提交的
base_stmt = base_stmt.where(Ticket.applicant_id == current_user.id)
elif current_user.role not in ("admin",):
# dba/auditor: 看自己提交的 + 自己需要审批的
from app.models.workflow import TicketApproval
my_approvals = set((await db.execute(
select(TicketApproval.ticket_id).where(
TicketApproval.action == "pending",
TicketApproval.approver_id == current_user.id,
)
)).scalars().all())
my_role_approvals = set((await db.execute(
select(TicketApproval.ticket_id).where(
TicketApproval.action == "pending",
TicketApproval.approver_role == current_user.role,
)
)).scalars().all())
my_submitted = set((await db.execute(
select(Ticket.id).where(Ticket.applicant_id == current_user.id)
)).scalars().all())
all_ids = my_submitted | my_approvals | my_role_approvals
if all_ids:
base_stmt = base_stmt.where(Ticket.id.in_(all_ids))
else:
base_stmt = base_stmt.where(Ticket.applicant_id == current_user.id)
sub = base_stmt.subquery()
stmt = select(sub.c.status, func.count(sub.c.id)).group_by(sub.c.status)
result = await db.execute(stmt) result = await db.execute(stmt)
stats = {row[0]: row[1] for row in result.all()} stats = {row[0]: row[1] for row in result.all()}
@@ -773,20 +860,31 @@ async def get_ticket_approvals(
) )
approvals = approvals_result.scalars().all() approvals = approvals_result.scalars().all()
return [ # Enrich with approver display names
{ result = []
for a in approvals:
approver_name = None
if a.approver_id:
u = await db.execute(select(User).where(User.id == a.approver_id))
approver_name = u.scalar_one_or_none()
approver_name = approver_name.display_name if approver_name else None
result.append({
"id": a.id, "id": a.id,
"step_number": a.step_number, "step_number": a.step_number,
"step_name": a.step_name, "step_name": a.step_name,
"step_type": a.step_type,
"approver_role": a.approver_role, "approver_role": a.approver_role,
"action": a.action, "action": a.action,
"approver_id": a.approver_id, "approver_id": a.approver_id,
"approver_name": approver_name,
"comment": a.comment, "comment": a.comment,
"created_at": a.created_at.isoformat() if a.created_at else None, "created_at": a.created_at.isoformat() if a.created_at else None,
"updated_at": a.updated_at.isoformat() if a.updated_at else None, "updated_at": a.updated_at.isoformat() if a.updated_at else None,
} "completed_at": a.completed_at.isoformat() if a.completed_at else None,
for a in approvals })
]
return result
@router.post("/{ticket_id}/review") @router.post("/{ticket_id}/review")
+3
View File
@@ -22,6 +22,9 @@ export var ticketApi = {
timeline: function (id: number) { timeline: function (id: number) {
return request.get('/tickets/' + id + '/timeline'); return request.get('/tickets/' + id + '/timeline');
}, },
approvals: function (id: number) {
return request.get('/tickets/' + id + '/approvals');
},
cancel: function (id: number) { cancel: function (id: number) {
return request.post('/tickets/' + id + '/cancel'); return request.post('/tickets/' + id + '/cancel');
}, },
+118 -385
View File
@@ -1,53 +1,29 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { useEffect, useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
ClipboardList, Clock, CheckCircle, AlertTriangle, ClipboardList, Clock, CheckCircle, AlertTriangle,
Database, ChevronRight, X, AlertCircle, Info, Bell, Database, ChevronRight, X, Bell, Eye,
Activity, TrendingUp, Zap, Server, Search, Activity, TrendingUp, Zap, Server, Search,
FileText, ArrowUpRight, ArrowDownRight, Minus, FileText, ArrowUpRight, ArrowDownRight,
Filter, Eye, BarChart3,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/constants'; import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/constants';
import type { DBInstance } from '@/types';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { dashboardApi } from '@/api/dashboard'; import { dashboardApi } from '@/api/dashboard';
import type { DashboardStats } from '@/types'; import type { DashboardStats } from '@/types';
import * as echarts from 'echarts';
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Types */ /* Types */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
type PriorityKey = 'urgent' | 'high' | 'normal' | 'low'; interface QuickAction {
type TrendRange = 7 | 30 | 90; label: string;
type AnnouncementFilter = 'all' | PriorityKey; icon: any;
onClick: () => void;
interface TrendDataset { color: string;
xAxis: string[]; bg: string;
submitted: number[];
success: number[];
failed: number[];
} }
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const priorityStyle: Record<PriorityKey, { border: string; bg: string; icon: React.ElementType; iconColor: string; badge: string }> = {
urgent: { border: 'border-l-red-500', bg: 'bg-red-500/5', icon: AlertCircle, iconColor: 'text-red-400', badge: 'bg-red-500/15 text-red-400 border-red-500/30' },
high: { border: 'border-l-orange-500', bg: 'bg-orange-500/5', icon: AlertTriangle, iconColor: 'text-orange-400', badge: 'bg-orange-500/15 text-orange-400 border-orange-500/30' },
normal: { border: 'border-l-blue-500', bg: 'bg-blue-500/5', icon: Info, iconColor: 'text-blue-400', badge: 'bg-blue-500/15 text-blue-400 border-blue-500/30' },
low: { border: 'border-l-gray-500', bg: 'bg-gray-500/5', icon: Bell, iconColor: 'text-gray-400', badge: 'bg-gray-500/15 text-gray-400 border-gray-500/30' },
};
const priorityLabel: Record<PriorityKey, string> = { urgent: '紧急', high: '重要', normal: '通知', low: '一般' };
const trendDatasets: Record<TrendRange, TrendDataset> = {
7: { xAxis: [], submitted: [], success: [], failed: [] },
30: { xAxis: [], submitted: [], success: [], failed: [] },
90: { xAxis: [], submitted: [], success: [], failed: [] },
};
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Helpers */ /* Helpers */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -95,37 +71,23 @@ function StatCardSkeleton() {
); );
} }
function ChartSkeleton() {
return (
<div className="h-[300px] flex items-center justify-center">
<div className="w-full h-full animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />
</div>
);
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Main Dashboard */ /* Main Dashboard */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
export default function Dashboard() { export default function Dashboard() {
const chartRef = useRef<HTMLDivElement>(null); const theme = useTheme();
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
const { theme } = useTheme();
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set()); const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
const [readIds, setReadIds] = useState<Set<string>>(new Set());
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [annFilter, setAnnFilter] = useState<AnnouncementFilter>('all');
const [trendRange, setTrendRange] = useState<TrendRange>(7);
const [stats, setStats] = useState<DashboardStats | null>(null); const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [chartReady, setChartReady] = useState(false);
const [announcements, setAnnouncements] = useState<any[]>([]); const [announcements, setAnnouncements] = useState<any[]>([]);
const [dbInstances, setDbInstances] = useState<any[]>([]); const [dbInstances, setDbInstances] = useState<any[]>([]);
const [tickets, setTickets] = useState<any[]>([]); const [tickets, setTickets] = useState<any[]>([]);
const [queryStats, setQueryStats] = useState<any>(null); const [queryStats, setQueryStats] = useState<any>(null);
const [trendData, setTrendData] = useState<TrendDataset>({ xAxis: [], submitted: [], success: [], failed: [] });
useEffect(() => { useEffect(() => {
import('@/api/request').then(m => m.default.get('/announcements')).then(res => { import('@/api/request').then(m => m.default.get('/announcements')).then(res => {
@@ -155,20 +117,7 @@ export default function Dashboard() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
/* ---- Trend Data ---- */
useEffect(() => {
import('@/api/request').then(m => m.default.get('/dashboard/trend', { params: { days: trendRange } }))
.then((res: any) => {
const items = res?.items || res?.data?.items || [];
setTrendData({
xAxis: items.map((p: any) => p.date),
submitted: items.map((p: any) => p.submitted),
success: items.map((p: any) => p.success),
failed: items.map((p: any) => p.failed),
});
})
.catch(() => setTrendData({ xAxis: [], submitted: [], success: [], failed: [] }));
}, [trendRange]);
/* ---- Instances & Tickets ---- */ /* ---- Instances & Tickets ---- */
useEffect(() => { useEffect(() => {
@@ -183,14 +132,9 @@ export default function Dashboard() {
qps: i.qps ?? 0, connections: i.connections ?? 0, qps: i.qps ?? 0, connections: i.connections ?? 0,
}))); })));
}).catch(() => setDbInstances([])); }).catch(() => setDbInstances([]));
m.default.get('/tickets', { params: { page: 1, page_size: 50 } }).then((res: any) => { m.default.get('/dashboard/my-tickets').then((res: any) => {
const list = Array.isArray(res) ? res : (res?.items || res?.data || []); const list = Array.isArray(res) ? res : [];
setTickets(list.map((t: any) => ({ setTickets(list);
id: t.id, title: t.title || '', status: t.status || 'pending',
dbType: t.db_type || t.dbType || '',
applicant: t.created_by || t.applicant || '',
createTime: t.created_at || t.createTime || '',
})));
}).catch(() => setTickets([])); }).catch(() => setTickets([]));
}); });
}, []); }, []);
@@ -198,148 +142,39 @@ export default function Dashboard() {
/* ---- Announcements ---- */ /* ---- Announcements ---- */
const visibleAnnouncements = useMemo(() => { const visibleAnnouncements = useMemo(() => {
return announcements return announcements
.filter(a => a.enabled && a.targetRoles.includes(user.role) && !dismissedIds.has(a.id)) .filter(a => a.enabled && !dismissedIds.has(a.id))
.filter(a => annFilter === 'all' || a.priority === annFilter) .sort((a, b) => new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime());
.sort((a, b) => { }, [dismissedIds, announcements]);
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
const po: Record<PriorityKey, number> = { urgent: 0, high: 1, normal: 2, low: 3 };
if (po[a.priority as PriorityKey] !== po[b.priority as PriorityKey]) return po[a.priority as PriorityKey] - po[b.priority as PriorityKey];
return new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime();
});
}, [dismissedIds, annFilter, user.role]);
const dismiss = useCallback((id: string) => setDismissedIds(prev => new Set(prev).add(id)), []); const dismiss = useCallback((id: string) => setDismissedIds(prev => new Set(prev).add(id)), []);
const toggleRead = useCallback((id: string) => {
setReadIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const toggleExpand = useCallback((id: string) => setExpandedId(prev => prev === id ? null : id), []); const toggleExpand = useCallback((id: string) => setExpandedId(prev => prev === id ? null : id), []);
/* ---- Chart ---- */
useEffect(() => {
if (!chartRef.current) return;
if (!chartInstanceRef.current) {
chartInstanceRef.current = echarts.init(chartRef.current);
}
const chart = chartInstanceRef.current;
const isLight = theme === 'light';
const colors = isLight
? { bg: 'rgba(255,255,255,0.95)', border: '#e2e5eb', text: '#1a1d23', muted: '#5c6370', grid: '#f0f1f5', blue: '#2563eb', green: '#16a34a', red: '#dc2626' }
: { bg: 'rgba(22, 27, 34, 0.95)', border: '#30363d', text: '#f0f6fc', muted: '#8b949e', grid: '#21262d', blue: '#58a6ff', green: '#3fb950', red: '#f85149' };
const dataset = trendData;
const option: echarts.EChartsOption = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: colors.bg,
borderColor: colors.border,
textStyle: { color: colors.text },
},
legend: {
data: ['提交工单', '执行成功', '执行失败'],
textStyle: { color: colors.muted },
bottom: 0,
itemWidth: 14,
itemHeight: 8,
},
grid: { left: '2%', right: '3%', bottom: '18%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
data: dataset.xAxis,
axisLine: { lineStyle: { color: colors.border } },
axisLabel: { color: colors.muted, fontSize: 11 },
},
yAxis: {
type: 'value',
axisLine: { show: false },
splitLine: { lineStyle: { color: colors.grid } },
axisLabel: { color: colors.muted, fontSize: 11 },
},
series: [
{
name: '提交工单', type: 'line', smooth: true,
data: dataset.submitted,
itemStyle: { color: colors.blue },
lineStyle: { width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: isLight ? 'rgba(37, 99, 235, 0.15)' : 'rgba(88, 166, 255, 0.3)' },
{ offset: 1, color: 'transparent' },
]),
},
symbol: 'none',
},
{
name: '执行成功', type: 'line', smooth: true,
data: dataset.success,
itemStyle: { color: colors.green },
lineStyle: { width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: isLight ? 'rgba(22, 163, 74, 0.15)' : 'rgba(63, 185, 80, 0.3)' },
{ offset: 1, color: 'transparent' },
]),
},
symbol: 'none',
},
{
name: '执行失败', type: 'line', smooth: true,
data: dataset.failed,
itemStyle: { color: colors.red },
lineStyle: { width: 2 },
symbol: 'none',
},
],
};
chart.setOption(option, true);
setChartReady(true);
const handleResize = () => chart.resize();
window.addEventListener('resize', handleResize);
return () => { window.removeEventListener('resize', handleResize); };
}, [theme, trendRange, trendData]);
useEffect(() => {
return () => { chartInstanceRef.current?.dispose(); };
}, []);
/* ---- Computed Data ---- */ /* ---- Computed Data ---- */
const onlineInstances = dbInstances.filter(i => i.status === 'online').length; const onlineInstances = dbInstances.filter(i => i.status === 'online').length;
const warningInstances = dbInstances.filter(i => i.status === 'warning').length; const warningInstances = dbInstances.filter(i => i.status === 'warning').length;
const offlineInstances = dbInstances.filter(i => i.status === 'offline').length; const offlineInstances = dbInstances.filter(i => i.status === 'offline').length;
const pendingTickets = tickets.filter(t => t.status === 'pending' || t.status === 'reviewing').slice(0, 5); const pendingTickets = tickets;
const statsCards = useMemo(() => { const statsCards = useMemo(() => {
if (!stats) return []; if (!stats) return [];
const queryTotal = stats.query_stats?.total ?? 0;
return [ return [
{ {
title: '工单总数', value: stats.ticket_total, icon: ClipboardList, title: '工单总数', value: stats.ticket_total.toLocaleString(), icon: ClipboardList,
color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20', color: 'text-blue-400', bg: 'bg-blue-400/10',
change: '+8.2%', trend: 'up' as const, subtitle: `${stats.ticket_pending} 个待审批 · ${stats.ticket_executing} 个执行中`,
subtitle: `${stats.ticket_pending} 个待审批`,
}, },
{ {
title: '待审批', value: stats.ticket_pending, icon: Clock, title: '查询总数', value: queryTotal.toLocaleString(), icon: Search,
color: 'text-orange-400', bg: 'bg-orange-400/10', border: 'border-orange-400/20', color: 'text-green-400', bg: 'bg-green-400/10',
change: '+2', trend: 'up' as const, subtitle: `今日 ${stats.queries_today} 次 · 平均 ${stats.query_stats?.avg_time_ms ?? 0}ms`,
subtitle: `本月已处理 ${stats.ticket_month}`,
}, },
{ {
title: '本月工单', value: stats.ticket_month, icon: CheckCircle, title: '数据源总数', value: stats.instance_total, icon: Database,
color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20', color: 'text-purple-400', bg: 'bg-purple-400/10',
change: '-3.1%', trend: 'down' as const, subtitle: `${stats.instance_online} 个在线 · ${stats.user_total} 个用户`,
subtitle: `${stats.ticket_executing} 个执行中`,
},
{
title: '今日查询', value: stats.queries_today.toLocaleString(), icon: Zap,
color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20',
change: '+12%', trend: 'up' as const,
subtitle: `${stats.instance_online}/${stats.instance_total} 实例在线`,
}, },
]; ];
}, [stats]); }, [stats]);
@@ -350,25 +185,7 @@ export default function Dashboard() {
{ label: '添加实例', icon: Database, onClick: () => navigate('/instances'), color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20' }, { label: '添加实例', icon: Database, onClick: () => navigate('/instances'), color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20' },
]; ];
const TrendIcon = ({ trend }: { trend: 'up' | 'down' | 'neutral' }) => {
if (trend === 'up') return <ArrowUpRight className="w-3.5 h-3.5" />;
if (trend === 'down') return <ArrowDownRight className="w-3.5 h-3.5" />;
return <Minus className="w-3.5 h-3.5" />;
};
const trendRangeOptions: { label: string; value: TrendRange }[] = [
{ label: '近7天', value: 7 },
{ label: '近30天', value: 30 },
{ label: '近90天', value: 90 },
];
const annFilterOptions: { label: string; value: AnnouncementFilter }[] = [
{ label: '全部', value: 'all' },
{ label: '紧急', value: 'urgent' },
{ label: '重要', value: 'high' },
{ label: '通知', value: 'normal' },
{ label: '一般', value: 'low' },
];
/* ================================================================ */ /* ================================================================ */
/* RENDER */ /* RENDER */
@@ -392,53 +209,22 @@ export default function Dashboard() {
{/* ---- Announcements ---- */} {/* ---- Announcements ---- */}
{visibleAnnouncements.length > 0 && ( {visibleAnnouncements.length > 0 && (
<div className="space-y-3"> <div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="w-4 h-4 text-[var(--dg-text-secondary)]" />
<span className="text-sm font-medium text-[var(--dg-text-secondary)]">公告通知</span>
<span className="text-xs text-[var(--dg-text-muted)]">({visibleAnnouncements.length})</span>
</div>
<div className="flex items-center gap-1">
<Filter className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
{annFilterOptions.map(opt => (
<button
key={opt.value}
onClick={() => setAnnFilter(opt.value)}
className={`px-2.5 py-1 text-[11px] rounded-md transition-colors ${
annFilter === opt.value
? 'bg-blue-500/15 text-blue-400 font-medium'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{visibleAnnouncements.slice(0, 3).map(ann => { {visibleAnnouncements.slice(0, 3).map(ann => {
const style = priorityStyle[ann.priority as PriorityKey] || priorityStyle.normal;
const PIcon = style.icon;
const isExpanded = expandedId === ann.id; const isExpanded = expandedId === ann.id;
const isRead = readIds.has(ann.id);
return ( return (
<div <div
key={ann.id} key={ann.id}
className={`dg-card border-l-[3px] ${style.border} ${style.bg} overflow-hidden transition-all duration-300 ${isRead ? 'opacity-60' : ''}`} className="dg-card overflow-hidden"
> >
<div className="px-5 py-3.5 flex items-start gap-3"> <div className="px-5 py-3 flex items-start gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${style.badge.split(' ').slice(1).join(' ')}`}> <div className="w-7 h-7 rounded-md bg-blue-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<PIcon className="w-4 h-4" /> <Bell className="w-3.5 h-3.5 text-blue-400" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-0.5">
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${style.badge}`}> <span className="text-sm font-medium truncate">{ann.title}</span>
{priorityLabel[ann.priority as PriorityKey]} <span className="text-[10px] text-[var(--dg-text-muted)] flex-shrink-0">{ann.publishTime.slice(0, 10)}</span>
</span>
<span className="text-sm font-semibold truncate">{ann.title}</span>
{ann.pinned && <span className="text-[10px] text-purple-400 flex-shrink-0 font-medium">置顶</span>}
{isRead && <span className="text-[10px] text-[var(--dg-text-muted)] flex-shrink-0">已读</span>}
</div> </div>
<div className={`text-xs text-[var(--dg-text-secondary)] leading-relaxed ${isExpanded ? '' : 'line-clamp-1'}`}> <div className={`text-xs text-[var(--dg-text-secondary)] leading-relaxed ${isExpanded ? '' : 'line-clamp-1'}`}>
{ann.content} {ann.content}
@@ -446,102 +232,123 @@ export default function Dashboard() {
{ann.content.includes('\n') && ( {ann.content.includes('\n') && (
<button <button
onClick={() => toggleExpand(ann.id)} onClick={() => toggleExpand(ann.id)}
className="text-[11px] text-blue-400 hover:text-blue-300 mt-1 flex items-center gap-0.5 transition-colors" className="text-[11px] text-blue-400 hover:text-blue-300 mt-1 transition-colors"
> >
{isExpanded ? '收起' : '展开全文'} {isExpanded ? '收起' : '展开全文'}
<ChevronRight className={`w-3 h-3 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button> </button>
)} )}
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <button
<button onClick={() => dismiss(ann.id)}
onClick={() => toggleRead(ann.id)} className="w-6 h-6 rounded-md flex items-center justify-center text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors flex-shrink-0"
className="w-6 h-6 rounded-md flex items-center justify-center text-[var(--dg-text-muted)] hover:text-blue-400 hover:bg-[var(--dg-bg-hover)] transition-colors" >
title={isRead ? '标记未读' : '标记已读'} <X className="w-3.5 h-3.5" />
> </button>
<Eye className="w-3.5 h-3.5" />
</button>
<span className="text-[11px] text-[var(--dg-text-muted)]">{ann.publishTime.slice(0, 10)}</span>
<button
onClick={() => dismiss(ann.id)}
className="w-6 h-6 rounded-md flex items-center justify-center text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
title="关闭"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div> </div>
</div> </div>
); );
})} })}
{visibleAnnouncements.length > 3 && (
<div className="text-center">
<span className="text-xs text-[var(--dg-text-muted)]">
还有 {visibleAnnouncements.length - 3} 条公告,可在「公告管理」中查看
</span>
</div>
)}
</div> </div>
)} )}
{/* ---- Stats Cards ---- */} {/* ---- Stats Cards ---- */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5"> <div className="grid grid-cols-3 gap-5">
{loading ? ( {loading ? (
Array.from({ length: 4 }).map((_, i) => <StatCardSkeleton key={i} />) Array.from({ length: 3 }).map((_, i) => <StatCardSkeleton key={i} />)
) : ( ) : (
statsCards.map((s, i) => ( statsCards.map((s, i) => (
<Card key={i} className="dg-card dg-card-hover relative overflow-hidden"> <Card key={i} className="dg-card dg-card-hover relative overflow-hidden">
<div className={`absolute top-0 right-0 w-20 h-20 ${s.bg} rounded-bl-[40px] opacity-30`} /> <div className={`absolute top-0 right-0 w-20 h-20 ${s.bg} rounded-bl-[40px] opacity-30`} />
<CardContent className="p-5 relative z-10"> <CardContent className="p-5 relative z-10">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-3">
<span className="text-sm text-[var(--dg-text-secondary)] font-medium">{s.title}</span> <span className="text-sm text-[var(--dg-text-secondary)] font-medium">{s.title}</span>
<div className={`w-10 h-10 rounded-xl ${s.bg} flex items-center justify-center border ${s.border}`}> <div className={`w-9 h-9 rounded-lg ${s.bg} flex items-center justify-center`}>
<s.icon className={`w-5 h-5 ${s.color}`} /> <s.icon className={`w-4.5 h-4.5 ${s.color}`} />
</div> </div>
</div> </div>
<div className="text-3xl font-bold text-[var(--dg-text-primary)] tracking-tight">{s.value}</div> <div className="text-4xl font-bold text-[var(--dg-text-primary)] tracking-tight">{s.value}</div>
<div className="flex items-center gap-3 mt-2.5"> <div className="text-xs text-[var(--dg-text-muted)] mt-2">{s.subtitle}</div>
<span className={`flex items-center gap-1 text-xs font-medium ${s.trend === 'up' ? 'text-green-400' : s.trend === 'down' ? 'text-red-400' : 'text-[var(--dg-text-muted)]'}`}>
<TrendIcon trend={s.trend} />
{s.change}
</span>
<span className="text-xs text-[var(--dg-text-muted)]">{s.subtitle}</span>
</div>
</CardContent> </CardContent>
</Card> </Card>
)) ))
)} )}
</div> </div>
{/* ---- Charts + Instance Status ---- */} {/* ---- My Pending Tickets + Instance Status ---- */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* Trend Chart */} {/* My Pending Tickets */}
<Card className="dg-card col-span-1 lg:col-span-2"> <Card className="dg-card col-span-1 lg:col-span-2">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]"> <CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2"> <CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-blue-400" /> <Clock className="w-4 h-4 text-orange-400" />
工单趋势 我的待处理
<span className="text-xs text-[var(--dg-text-muted)] font-normal">({pendingTickets.length})</span>
</CardTitle> </CardTitle>
<div className="flex gap-1.5"> <a href="/tickets" className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 transition-colors">
{trendRangeOptions.map(opt => ( 查看全部 <ChevronRight className="w-3 h-3" />
<button </a>
key={opt.value}
onClick={() => setTrendRange(opt.value)}
className={`px-3 py-1.5 text-xs rounded-md transition-all font-medium ${
trendRange === opt.value
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]'
}`}
>
{opt.label}
</button>
))}
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="p-5"> <CardContent className="p-0">
{!chartReady ? <ChartSkeleton /> : <div ref={chartRef} style={{ height: 300 }} />} {pendingTickets.length === 0 ? (
<div className="p-8 text-center text-[var(--dg-text-muted)] text-sm">
<CheckCircle className="w-8 h-8 mx-auto mb-2 text-green-400" />
暂无待处理工单
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">工单号</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">标题</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">数据库</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">状态</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">待处理</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">关联</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">时间</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">操作</th>
</tr>
</thead>
<tbody>
{pendingTickets.map((ticket: any) => (
<tr key={ticket.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-5 py-3 font-mono text-xs text-[var(--dg-text-secondary)]">TK-{ticket.id}</td>
<td className="px-5 py-3 max-w-[200px] truncate font-medium">{ticket.title}</td>
<td className="px-5 py-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.db_type] || ''}`}>
{(ticket.db_type || '').toUpperCase()}
</span>
</td>
<td className="px-5 py-3">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' ? 'bg-blue-400 animate-pulse' : ticket.status === 'pending' ? 'bg-yellow-400' : 'bg-green-400'}`} />
{statusMap[ticket.status] || ticket.status}
</span>
</td>
<td className="px-5 py-3 text-xs text-[var(--dg-text-secondary)] max-w-[180px] truncate">{ticket.pending_info || '-'}</td>
<td className="px-5 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${ticket.relation === '我提交的' ? 'bg-blue-500/10 text-blue-400' : 'bg-orange-500/10 text-orange-400'}`}>
{ticket.relation}
</span>
</td>
<td className="px-5 py-3 text-xs text-[var(--dg-text-secondary)]">{ticket.create_time}</td>
<td className="px-5 py-3">
<button
onClick={() => navigate(`/tickets/${ticket.id}`)}
className="flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors"
>
<Eye className="w-3.5 h-3.5" />
查看
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -751,80 +558,6 @@ export default function Dashboard() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* ---- Pending Tickets ---- */}
<Card className="dg-card">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Clock className="w-4 h-4 text-orange-400" />
待处理工单
<span className="text-xs text-[var(--dg-text-muted)] font-normal">({pendingTickets.length})</span>
</CardTitle>
<a href="/tickets" className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 transition-colors">
查看全部
<ChevronRight className="w-3 h-3" />
</a>
</div>
</CardHeader>
<CardContent className="p-0">
{pendingTickets.length === 0 ? (
<div className="p-8 text-center text-[var(--dg-text-muted)] text-sm">
<CheckCircle className="w-8 h-8 mx-auto mb-2 text-green-400" />
所有工单已处理完毕
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">工单号</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">标题</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">数据库</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">申请人</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">状态</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">创建时间</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">操作</th>
</tr>
</thead>
<tbody>
{pendingTickets.map(ticket => (
<tr key={ticket.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-5 py-3.5 font-mono text-xs text-[var(--dg-text-secondary)]">{ticket.id}</td>
<td className="px-5 py-3.5 max-w-xs truncate font-medium">{ticket.title}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>
{ticket.dbType.toUpperCase()}
</span>
</td>
<td className="px-5 py-3.5">{ticket.applicant}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' ? 'bg-blue-400 animate-pulse' : ticket.status === 'pending' ? 'bg-yellow-400' : ''}`} />
{statusMap[ticket.status]}
</span>
</td>
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.createTime}</td>
<td className="px-5 py-3.5">
<div className="flex gap-2">
<button className="flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors">
<Eye className="w-3.5 h-3.5" />
查看
</button>
<button className="flex items-center gap-1 text-green-400 hover:text-green-300 text-xs font-medium transition-colors">
<CheckCircle className="w-3.5 h-3.5" />
审批
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div> </div>
); );
} }
+107 -5
View File
@@ -88,6 +88,7 @@ export default function TicketDetailPage() {
// -- Data -- // -- Data --
const [ticket, setTicket] = useState<TicketData | null>(null); const [ticket, setTicket] = useState<TicketData | null>(null);
const [timeline, setTimeline] = useState<TimelineEntry[]>([]); const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
const [approvalSteps, setApprovalSteps] = useState<any[]>([]);
const [relatedTickets, setRelatedTickets] = useState<RelatedTicket[]>([]); const [relatedTickets, setRelatedTickets] = useState<RelatedTicket[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -129,6 +130,15 @@ export default function TicketDetailPage() {
} }
}, [ticketId]); }, [ticketId]);
const fetchApprovals = useCallback(async () => {
try {
const res = await ticketApi.approvals(ticketId);
setApprovalSteps(Array.isArray(res) ? res : []);
} catch {
setApprovalSteps([]);
}
}, [ticketId]);
// Fetch related tickets from API // Fetch related tickets from API
const fetchRelated = useCallback(async () => { const fetchRelated = useCallback(async () => {
try { try {
@@ -154,6 +164,7 @@ export default function TicketDetailPage() {
setLoading(true); setLoading(true);
fetchTicket(); fetchTicket();
fetchTimeline(); fetchTimeline();
fetchApprovals();
fetchRelated(); fetchRelated();
} }
}, [ticketId, fetchTicket, fetchTimeline, fetchRelated]); }, [ticketId, fetchTicket, fetchTimeline, fetchRelated]);
@@ -196,6 +207,7 @@ export default function TicketDetailPage() {
} }
fetchTicket(); fetchTicket();
fetchTimeline(); fetchTimeline();
fetchApprovals();
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } }; const err = e as { response?: { data?: { detail?: string } } };
setError(err?.response?.data?.detail || `操作失败: ${action}`); setError(err?.response?.data?.detail || `操作失败: ${action}`);
@@ -379,6 +391,98 @@ export default function TicketDetailPage() {
); );
}; };
const renderApprovalFlow = () => {
if (approvalSteps.length === 0) return null;
const actionLabel: Record<string, string> = {
approved: '已通过',
rejected: '已拒绝',
pending: '待处理',
skipped: '已跳过',
};
const actionStyle: Record<string, { color: string; bg: string }> = {
approved: { color: 'text-green-400', bg: 'bg-green-500/15' },
rejected: { color: 'text-red-400', bg: 'bg-red-500/15' },
pending: { color: 'text-[var(--dg-text-muted)]', bg: 'bg-[var(--dg-bg-tertiary)]' },
};
const stepAccent: Record<string, string> = {
'提交': 'text-blue-400',
'审核': 'text-amber-400',
'执行': 'text-purple-400',
};
const getAccent = (name: string) => {
for (const key of Object.keys(stepAccent)) {
if (name.includes(key)) return stepAccent[key];
}
return 'text-[var(--dg-text-secondary)]';
};
return (
<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 mb-4">
<Shield className="w-4 h-4 text-blue-400" /> 审批流程
</h3>
<div className="space-y-2">
{approvalSteps.map((step, idx) => {
const actCfg = actionStyle[step.action] || actionStyle.pending;
const isCurrent = step.action === 'pending';
const isDone = step.action === 'approved' || step.action === 'rejected';
return (
<div
key={step.id || idx}
className={`flex items-start gap-3 p-3 rounded-lg ${
isCurrent
? 'bg-blue-500/5 border border-blue-500/20'
: 'bg-[var(--dg-bg-primary)]'
}`}
>
<div className={`w-6 h-6 rounded-full ${actCfg.bg} flex items-center justify-center shrink-0 mt-0.5`}>
{isDone && step.action === 'approved' ? (
<CheckCircle className={`w-3.5 h-3.5 ${actCfg.color}`} />
) : isDone ? (
<XCircle className={`w-3.5 h-3.5 ${actCfg.color}`} />
) : (
<div className={`w-1.5 h-1.5 rounded-full ${isCurrent ? 'bg-blue-400 animate-pulse' : 'bg-[var(--dg-text-muted)]'}`} />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className={`text-sm font-medium ${getAccent(step.step_name)}`}>
{step.step_name}
</span>
{step.approver_name && (
<span className="text-xs text-[var(--dg-text-muted)]">
{step.approver_name}
</span>
)}
</div>
<span className={`text-xs px-1.5 py-0.5 rounded ${actCfg.bg} ${actCfg.color} shrink-0`}>
{actionLabel[step.action] || step.action}
</span>
</div>
{step.comment && step.comment !== '提交工单' && (
<p className="text-xs text-[var(--dg-text-secondary)] mt-1">{step.comment}</p>
)}
{step.updated_at && isDone && (
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5">
{new Date(step.updated_at).toLocaleString()}
</p>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
const renderTimeline = () => { const renderTimeline = () => {
if (timeline.length === 0) return null; if (timeline.length === 0) return null;
@@ -512,9 +616,6 @@ export default function TicketDetailPage() {
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}> <span className={`px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
{statusMap[ticket.status] || ticket.status} {statusMap[ticket.status] || ticket.status}
</span> </span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${priorityInfo.color}`}>
{priorityInfo.label}优先级
</span>
</h1> </h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1"> <p className="text-sm text-[var(--dg-text-secondary)] mt-1">
TK-{ticket.id} · 由 {ticket.applicant_name || '-'} 创建于 {new Date(ticket.create_time).toLocaleString()} TK-{ticket.id} · 由 {ticket.applicant_name || '-'} 创建于 {new Date(ticket.create_time).toLocaleString()}
@@ -537,8 +638,6 @@ export default function TicketDetailPage() {
{ label: '数据库类型', value: ticket.db_type.toUpperCase() }, { label: '数据库类型', value: ticket.db_type.toUpperCase() },
{ label: '目标数据库', value: ticket.target_database, icon: Database }, { label: '目标数据库', value: ticket.target_database, icon: Database },
{ label: '申请人', value: ticket.applicant_name || '-' }, { label: '申请人', value: ticket.applicant_name || '-' },
{ label: '审批人', value: ticket.approver_name || '-' },
{ label: '优先级', value: priorityInfo.label },
{ {
label: '创建时间', label: '创建时间',
value: new Date(ticket.create_time).toLocaleString(), value: new Date(ticket.create_time).toLocaleString(),
@@ -798,6 +897,9 @@ export default function TicketDetailPage() {
); );
})()} })()}
{/* Approval Flow */}
{renderApprovalFlow()}
{/* Timeline */} {/* Timeline */}
{renderTimeline()} {renderTimeline()}
+25 -6
View File
@@ -15,6 +15,13 @@ import { ticketApi } from '@/api/ticket';
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Types */ /* Types */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
interface PendingStep {
step_number: number;
step_name: string;
step_type: string;
approver_names: string[];
}
interface TicketItem { interface TicketItem {
id: number; id: number;
ticket_no: string | null; ticket_no: string | null;
@@ -31,6 +38,7 @@ interface TicketItem {
approver_id: number | null; approver_id: number | null;
approver_name: string | null; approver_name: string | null;
current_approver_name: string | null; current_approver_name: string | null;
pending_steps: PendingStep[];
reason: string | null; reason: string | null;
audit_result: string | null; audit_result: string | null;
execute_result: string | null; execute_result: string | null;
@@ -285,7 +293,7 @@ export default function TicketsPage() {
<th className="text-left px-4 py-3 font-semibold">工单编号</th> <th className="text-left px-4 py-3 font-semibold">工单编号</th>
<th className="text-left px-4 py-3 font-semibold">实例名称</th> <th className="text-left px-4 py-3 font-semibold">实例名称</th>
<th className="text-left px-4 py-3 font-semibold">提交人</th> <th className="text-left px-4 py-3 font-semibold">提交人</th>
<th className="text-left px-4 py-3 font-semibold">当前审核人</th> <th className="text-left px-4 py-3 font-semibold">待处理</th>
<th className="text-left px-4 py-3 font-semibold">状态</th> <th className="text-left px-4 py-3 font-semibold">状态</th>
<th className="text-left px-4 py-3 font-semibold"> <th className="text-left px-4 py-3 font-semibold">
<button onClick={() => setSortAsc(!sortAsc)} className="flex items-center gap-1 hover:text-blue-400 transition-colors"> <button onClick={() => setSortAsc(!sortAsc)} className="flex items-center gap-1 hover:text-blue-400 transition-colors">
@@ -310,7 +318,7 @@ export default function TicketsPage() {
<th className="text-left px-4 py-3 font-semibold">工单编号</th> <th className="text-left px-4 py-3 font-semibold">工单编号</th>
<th className="text-left px-4 py-3 font-semibold">实例名称</th> <th className="text-left px-4 py-3 font-semibold">实例名称</th>
<th className="text-left px-4 py-3 font-semibold">提交人</th> <th className="text-left px-4 py-3 font-semibold">提交人</th>
<th className="text-left px-4 py-3 font-semibold">当前审核人</th> <th className="text-left px-4 py-3 font-semibold">待处理</th>
<th className="text-left px-4 py-3 font-semibold">状态</th> <th className="text-left px-4 py-3 font-semibold">状态</th>
<th className="text-left px-4 py-3 font-semibold"> <th className="text-left px-4 py-3 font-semibold">
<button onClick={() => setSortAsc(!sortAsc)} className="flex items-center gap-1 hover:text-blue-400 transition-colors"> <button onClick={() => setSortAsc(!sortAsc)} className="flex items-center gap-1 hover:text-blue-400 transition-colors">
@@ -352,11 +360,22 @@ export default function TicketsPage() {
</div> </div>
</td> </td>
{/* 当前审核人 */} {/* 待处理 */}
<td className="px-4 py-3.5"> <td className="px-4 py-3.5">
<span className="text-sm text-[var(--dg-text-secondary)]"> {ticket.pending_steps?.length > 0 ? (
{ticket.current_approver_name || (ticket.status === 'approved' ? '待执行' : '-')} <div className="flex flex-col gap-1">
</span> {ticket.pending_steps.map((ps) => (
<div key={ps.step_number} className="flex items-center gap-1.5 text-xs">
<span className="text-[var(--dg-text-muted)] shrink-0">{ps.step_name}:</span>
<span className="text-[var(--dg-text-primary)] font-medium">{ps.approver_names.join(', ')}</span>
</div>
))}
</div>
) : (
<span className="text-sm text-[var(--dg-text-muted)]">
{['success', 'failed', 'rejected', 'rollback'].includes(ticket.status) ? '-' : '-'}
</span>
)}
</td> </td>
{/* 状态 */} {/* 状态 */}
+8
View File
@@ -158,6 +158,13 @@ export interface PermissionNode {
children?: PermissionNode[]; children?: PermissionNode[];
} }
export interface QueryStatsData {
total: number;
avg_time_ms: number;
slow_count: number;
type_distribution: { label: string; count: number }[];
}
export interface DashboardStats { export interface DashboardStats {
ticket_total: number; ticket_total: number;
ticket_pending: number; ticket_pending: number;
@@ -167,6 +174,7 @@ export interface DashboardStats {
instance_online: number; instance_online: number;
user_total: number; user_total: number;
queries_today: number; queries_today: number;
query_stats?: QueryStatsData;
} }
export interface MenuItem { export interface MenuItem {