diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index e6e17ff..977511c 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -4,38 +4,127 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession 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.schemas.auth import LoginRequest, TokenResponse, UserBrief 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) async def login(req: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)): result = await db.execute(select(User).where(User.username == req.username)) user = result.scalar_one_or_none() - if not user or not verify_password(req.password, user.hashed_password): - # 记录登录失败 - from app.services.audit_logger import log_operation - await log_operation(db, action="login_failed", username=req.username, - detail=f"登录失败: 用户名或密码错误", request=request) - await db.commit() - raise HTTPException(status_code=401, detail="用户名或密码错误") + auth_source = "local" + + if user: + # 本地用户存在,用本地密码验证 + if not verify_password(req.password, user.hashed_password): + # 本地密码不对,记录失败 + 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: raise HTTPException(status_code=403, detail="用户已禁用") - if user.role == "guest": - raise HTTPException(status_code=403, detail="访客账户已禁用,请联系管理员") - user.last_login = datetime.now(timezone.utc) - # 记录登录成功 from app.services.audit_logger import log_operation 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() token = create_access_token(data={"sub": str(user.id), "role": user.role}) diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py index e7f5768..023f37a 100644 --- a/backend/app/api/v1/endpoints/dashboard.py +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -131,3 +131,94 @@ async def get_trend( items.append(TrendPoint(date=label, **d)) 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 diff --git a/backend/app/api/v1/endpoints/instance_detail.py b/backend/app/api/v1/endpoints/instance_detail.py index 44c911d..3700f34 100644 --- a/backend/app/api/v1/endpoints/instance_detail.py +++ b/backend/app/api/v1/endpoints/instance_detail.py @@ -68,7 +68,7 @@ async def _mysql_execute(instance: Instance, sql: str, database: Optional[str] = async def list_databases( instance_id: int, 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) # P0: 权限检查 @@ -93,7 +93,7 @@ async def list_tables( instance_id: int, database: str = Query(..., description="数据库名"), 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) # P0: 权限检查 @@ -160,7 +160,7 @@ async def table_detail( database: str = Query(..., description="数据库名"), table: str = Query(..., description="表名"), 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) # P0: 权限检查 diff --git a/backend/app/api/v1/endpoints/ldap_config.py b/backend/app/api/v1/endpoints/ldap_config.py index 44fd511..1d0204b 100644 --- a/backend/app/api/v1/endpoints/ldap_config.py +++ b/backend/app/api/v1/endpoints/ldap_config.py @@ -120,9 +120,13 @@ async def test_ldap_connection( conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10) conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password)) - # Search user - search_filter = ldap.search_filter or "(uid=%s)" - user_filter = search_filter.replace("%s", data.test_user) + # Search user — always filter by uid to avoid matching wrong user + base_filter = ldap.search_filter or "(objectClass=inetOrgPerson)" + 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( ldap.base_dn, _ldap.SCOPE_SUBTREE, diff --git a/backend/app/api/v1/endpoints/ticket.py b/backend/app/api/v1/endpoints/ticket.py index b10ddc7..6bb3d67 100644 --- a/backend/app/api/v1/endpoints/ticket.py +++ b/backend/app/api/v1/endpoints/ticket.py @@ -299,24 +299,60 @@ async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]: # Resolve current approver (审批/执行阶段的当前待审核人) data["current_approver_name"] = None - if ticket.status in ("reviewing", "approved", "executing"): - pending_approval = await db.execute( + data["pending_steps"] = [] + if ticket.status in ("pending", "reviewing", "approved", "executing"): + pending_result = await db.execute( select(TicketApproval) .where( TicketApproval.ticket_id == ticket.id, TicketApproval.action == "pending", ) .order_by(TicketApproval.step_number) - .limit(1) ) - pending = pending_approval.scalar_one_or_none() - if pending: - if pending.approver_id: - r = await db.execute(select(User).where(User.id == pending.approver_id)) - approver_user = r.scalar_one_or_none() - data["current_approver_name"] = approver_user.display_name if approver_user else None - elif pending.approver_role: - data["current_approver_name"] = pending.approver_role + pending_list = pending_result.scalars().all() + + pending_names_all = [] + for pa in pending_list: + step_info = { + "step_number": pa.step_number, + "step_name": pa.step_name, + "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 @@ -411,16 +447,37 @@ async def list_tickets( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """获取工单列表。 - - - admin/dba 可查看所有工单 - - developer 只能查看自己的工单 - """ + """获取工单列表(按用户身份过滤)。""" 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) + 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: @@ -678,8 +735,38 @@ async def ticket_statistics( db: AsyncSession = Depends(get_db), 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) stats = {row[0]: row[1] for row in result.all()} @@ -773,20 +860,31 @@ async def get_ticket_approvals( ) 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, "step_number": a.step_number, "step_name": a.step_name, + "step_type": a.step_type, "approver_role": a.approver_role, "action": a.action, "approver_id": a.approver_id, + "approver_name": approver_name, "comment": a.comment, "created_at": a.created_at.isoformat() if a.created_at else None, "updated_at": a.updated_at.isoformat() if a.updated_at else None, - } - for a in approvals - ] + "completed_at": a.completed_at.isoformat() if a.completed_at else None, + }) + + return result @router.post("/{ticket_id}/review") diff --git a/frontend/src/api/ticket.ts b/frontend/src/api/ticket.ts index 7bb98f9..84776cc 100644 --- a/frontend/src/api/ticket.ts +++ b/frontend/src/api/ticket.ts @@ -22,6 +22,9 @@ export var ticketApi = { timeline: function (id: number) { return request.get('/tickets/' + id + '/timeline'); }, + approvals: function (id: number) { + return request.get('/tickets/' + id + '/approvals'); + }, cancel: function (id: number) { return request.post('/tickets/' + id + '/cancel'); }, diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index d281b56..e09f72b 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 { ClipboardList, Clock, CheckCircle, AlertTriangle, - Database, ChevronRight, X, AlertCircle, Info, Bell, + Database, ChevronRight, X, Bell, Eye, Activity, TrendingUp, Zap, Server, Search, - FileText, ArrowUpRight, ArrowDownRight, Minus, - Filter, Eye, BarChart3, + FileText, ArrowUpRight, ArrowDownRight, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/constants'; -import type { DBInstance } from '@/types'; import { useTheme } from '@/hooks/useTheme'; import { useAuth } from '@/hooks/useAuth'; import { dashboardApi } from '@/api/dashboard'; import type { DashboardStats } from '@/types'; -import * as echarts from 'echarts'; /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ -type PriorityKey = 'urgent' | 'high' | 'normal' | 'low'; -type TrendRange = 7 | 30 | 90; -type AnnouncementFilter = 'all' | PriorityKey; - -interface TrendDataset { - xAxis: string[]; - submitted: number[]; - success: number[]; - failed: number[]; +interface QuickAction { + label: string; + icon: any; + onClick: () => void; + color: string; + bg: string; } -/* ------------------------------------------------------------------ */ -/* Constants */ -/* ------------------------------------------------------------------ */ -const priorityStyle: Record = { - 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 = { urgent: '紧急', high: '重要', normal: '通知', low: '一般' }; - -const trendDatasets: Record = { - 7: { xAxis: [], submitted: [], success: [], failed: [] }, - 30: { xAxis: [], submitted: [], success: [], failed: [] }, - 90: { xAxis: [], submitted: [], success: [], failed: [] }, -}; - /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ @@ -95,37 +71,23 @@ function StatCardSkeleton() { ); } -function ChartSkeleton() { - return ( -
-
-
- ); -} - /* ------------------------------------------------------------------ */ /* Main Dashboard */ /* ------------------------------------------------------------------ */ export default function Dashboard() { - const chartRef = useRef(null); - const chartInstanceRef = useRef(null); - const { theme } = useTheme(); + const theme = useTheme(); const { user } = useAuth(); const navigate = useNavigate(); const [dismissedIds, setDismissedIds] = useState>(new Set()); - const [readIds, setReadIds] = useState>(new Set()); const [expandedId, setExpandedId] = useState(null); - const [annFilter, setAnnFilter] = useState('all'); - const [trendRange, setTrendRange] = useState(7); + const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); - const [chartReady, setChartReady] = useState(false); const [announcements, setAnnouncements] = useState([]); const [dbInstances, setDbInstances] = useState([]); const [tickets, setTickets] = useState([]); const [queryStats, setQueryStats] = useState(null); - const [trendData, setTrendData] = useState({ xAxis: [], submitted: [], success: [], failed: [] }); useEffect(() => { import('@/api/request').then(m => m.default.get('/announcements')).then(res => { @@ -155,20 +117,7 @@ export default function Dashboard() { .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 ---- */ useEffect(() => { @@ -183,14 +132,9 @@ export default function Dashboard() { qps: i.qps ?? 0, connections: i.connections ?? 0, }))); }).catch(() => setDbInstances([])); - m.default.get('/tickets', { params: { page: 1, page_size: 50 } }).then((res: any) => { - const list = Array.isArray(res) ? res : (res?.items || res?.data || []); - setTickets(list.map((t: any) => ({ - 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 || '', - }))); + m.default.get('/dashboard/my-tickets').then((res: any) => { + const list = Array.isArray(res) ? res : []; + setTickets(list); }).catch(() => setTickets([])); }); }, []); @@ -198,148 +142,39 @@ export default function Dashboard() { /* ---- Announcements ---- */ const visibleAnnouncements = useMemo(() => { return announcements - .filter(a => a.enabled && a.targetRoles.includes(user.role) && !dismissedIds.has(a.id)) - .filter(a => annFilter === 'all' || a.priority === annFilter) - .sort((a, b) => { - if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; - const po: Record = { 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]); + .filter(a => a.enabled && !dismissedIds.has(a.id)) + .sort((a, b) => new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime()); + }, [dismissedIds, announcements]); 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), []); - /* ---- 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 ---- */ const onlineInstances = dbInstances.filter(i => i.status === 'online').length; const warningInstances = dbInstances.filter(i => i.status === 'warning').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(() => { if (!stats) return []; + const queryTotal = stats.query_stats?.total ?? 0; return [ { - title: '工单总数', value: stats.ticket_total, icon: ClipboardList, - color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20', - change: '+8.2%', trend: 'up' as const, - subtitle: `${stats.ticket_pending} 个待审批`, + title: '工单总数', value: stats.ticket_total.toLocaleString(), icon: ClipboardList, + color: 'text-blue-400', bg: 'bg-blue-400/10', + subtitle: `${stats.ticket_pending} 个待审批 · ${stats.ticket_executing} 个执行中`, }, { - title: '待审批', value: stats.ticket_pending, icon: Clock, - color: 'text-orange-400', bg: 'bg-orange-400/10', border: 'border-orange-400/20', - change: '+2', trend: 'up' as const, - subtitle: `本月已处理 ${stats.ticket_month}`, + title: '查询总数', value: queryTotal.toLocaleString(), icon: Search, + color: 'text-green-400', bg: 'bg-green-400/10', + subtitle: `今日 ${stats.queries_today} 次 · 平均 ${stats.query_stats?.avg_time_ms ?? 0}ms`, }, { - title: '本月工单', value: stats.ticket_month, icon: CheckCircle, - color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20', - change: '-3.1%', trend: 'down' as const, - 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} 实例在线`, + title: '数据源总数', value: stats.instance_total, icon: Database, + color: 'text-purple-400', bg: 'bg-purple-400/10', + subtitle: `${stats.instance_online} 个在线 · ${stats.user_total} 个用户`, }, ]; }, [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' }, ]; - const TrendIcon = ({ trend }: { trend: 'up' | 'down' | 'neutral' }) => { - if (trend === 'up') return ; - if (trend === 'down') return ; - return ; - }; - 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 */ @@ -392,53 +209,22 @@ export default function Dashboard() { {/* ---- Announcements ---- */} {visibleAnnouncements.length > 0 && ( -
-
-
- - 公告通知 - ({visibleAnnouncements.length}) -
-
- - {annFilterOptions.map(opt => ( - - ))} -
-
- +
{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 isRead = readIds.has(ann.id); return (
-
-
- +
+
+
-
- - {priorityLabel[ann.priority as PriorityKey]} - - {ann.title} - {ann.pinned && 置顶} - {isRead && 已读} +
+ {ann.title} + {ann.publishTime.slice(0, 10)}
{ann.content} @@ -446,102 +232,123 @@ export default function Dashboard() { {ann.content.includes('\n') && ( )}
-
- - {ann.publishTime.slice(0, 10)} - -
+
); })} - {visibleAnnouncements.length > 3 && ( -
- - 还有 {visibleAnnouncements.length - 3} 条公告,可在「公告管理」中查看 - -
- )}
)} {/* ---- Stats Cards ---- */} -
+
{loading ? ( - Array.from({ length: 4 }).map((_, i) => ) + Array.from({ length: 3 }).map((_, i) => ) ) : ( statsCards.map((s, i) => (
-
+
{s.title} -
- +
+
-
{s.value}
-
- - - {s.change} - - {s.subtitle} -
+
{s.value}
+
{s.subtitle}
)) )}
- {/* ---- Charts + Instance Status ---- */} + {/* ---- My Pending Tickets + Instance Status ---- */}
- {/* Trend Chart */} + {/* My Pending Tickets */}
- - 工单趋势 + + 我的待处理 + ({pendingTickets.length}) -
- {trendRangeOptions.map(opt => ( - - ))} -
+ + 查看全部 +
- - {!chartReady ? :
} + + {pendingTickets.length === 0 ? ( +
+ + 暂无待处理工单 +
+ ) : ( +
+ + + + + + + + + + + + + + + {pendingTickets.map((ticket: any) => ( + + + + + + + + + + + ))} + +
工单号标题数据库状态待处理关联时间操作
TK-{ticket.id}{ticket.title} + + {(ticket.db_type || '').toUpperCase()} + + + + + {statusMap[ticket.status] || ticket.status} + + {ticket.pending_info || '-'} + + {ticket.relation} + + {ticket.create_time} + +
+
+ )}
@@ -751,80 +558,6 @@ export default function Dashboard() {
- - {/* ---- Pending Tickets ---- */} - - -
- - - 待处理工单 - ({pendingTickets.length}) - - - 查看全部 - - -
-
- - {pendingTickets.length === 0 ? ( -
- - 所有工单已处理完毕 -
- ) : ( -
- - - - - - - - - - - - - - {pendingTickets.map(ticket => ( - - - - - - - - - - ))} - -
工单号标题数据库申请人状态创建时间操作
{ticket.id}{ticket.title} - - {ticket.dbType.toUpperCase()} - - {ticket.applicant} - - - {statusMap[ticket.status]} - - {ticket.createTime} -
- - -
-
-
- )} -
-
); } diff --git a/frontend/src/pages/TicketDetailPage.tsx b/frontend/src/pages/TicketDetailPage.tsx index bac51fe..8ca81b7 100644 --- a/frontend/src/pages/TicketDetailPage.tsx +++ b/frontend/src/pages/TicketDetailPage.tsx @@ -88,6 +88,7 @@ export default function TicketDetailPage() { // -- Data -- const [ticket, setTicket] = useState(null); const [timeline, setTimeline] = useState([]); + const [approvalSteps, setApprovalSteps] = useState([]); const [relatedTickets, setRelatedTickets] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -129,6 +130,15 @@ export default function TicketDetailPage() { } }, [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 const fetchRelated = useCallback(async () => { try { @@ -154,6 +164,7 @@ export default function TicketDetailPage() { setLoading(true); fetchTicket(); fetchTimeline(); + fetchApprovals(); fetchRelated(); } }, [ticketId, fetchTicket, fetchTimeline, fetchRelated]); @@ -196,6 +207,7 @@ export default function TicketDetailPage() { } fetchTicket(); fetchTimeline(); + fetchApprovals(); } catch (e: unknown) { const err = e as { response?: { data?: { detail?: string } } }; 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 = { + approved: '已通过', + rejected: '已拒绝', + pending: '待处理', + skipped: '已跳过', + }; + + const actionStyle: Record = { + 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 = { + '提交': '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 ( +
+

+ 审批流程 +

+
+ {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 ( +
+
+ {isDone && step.action === 'approved' ? ( + + ) : isDone ? ( + + ) : ( +
+ )} +
+ +
+
+
+ + {step.step_name} + + {step.approver_name && ( + + {step.approver_name} + + )} +
+ + {actionLabel[step.action] || step.action} + +
+ {step.comment && step.comment !== '提交工单' && ( +

{step.comment}

+ )} + {step.updated_at && isDone && ( +

+ {new Date(step.updated_at).toLocaleString()} +

+ )} +
+
+ ); + })} +
+
+ ); + }; + const renderTimeline = () => { if (timeline.length === 0) return null; @@ -512,9 +616,6 @@ export default function TicketDetailPage() { {statusMap[ticket.status] || ticket.status} - - {priorityInfo.label}优先级 -

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.target_database, icon: Database }, { label: '申请人', value: ticket.applicant_name || '-' }, - { label: '审批人', value: ticket.approver_name || '-' }, - { label: '优先级', value: priorityInfo.label }, { label: '创建时间', value: new Date(ticket.create_time).toLocaleString(), @@ -798,6 +897,9 @@ export default function TicketDetailPage() { ); })()} + {/* Approval Flow */} + {renderApprovalFlow()} + {/* Timeline */} {renderTimeline()} diff --git a/frontend/src/pages/TicketsPage.tsx b/frontend/src/pages/TicketsPage.tsx index 08824b5..4f206c7 100644 --- a/frontend/src/pages/TicketsPage.tsx +++ b/frontend/src/pages/TicketsPage.tsx @@ -15,6 +15,13 @@ import { ticketApi } from '@/api/ticket'; /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ +interface PendingStep { + step_number: number; + step_name: string; + step_type: string; + approver_names: string[]; +} + interface TicketItem { id: number; ticket_no: string | null; @@ -31,6 +38,7 @@ interface TicketItem { approver_id: number | null; approver_name: string | null; current_approver_name: string | null; + pending_steps: PendingStep[]; reason: string | null; audit_result: string | null; execute_result: string | null; @@ -285,7 +293,7 @@ export default function TicketsPage() { 工单编号 实例名称 提交人 - 当前审核人 + 待处理 状态

- {/* 当前审核人 */} + {/* 待处理 */} - - {ticket.current_approver_name || (ticket.status === 'approved' ? '待执行' : '-')} - + {ticket.pending_steps?.length > 0 ? ( +
+ {ticket.pending_steps.map((ps) => ( +
+ {ps.step_name}: + {ps.approver_names.join(', ')} +
+ ))} +
+ ) : ( + + {['success', 'failed', 'rejected', 'rollback'].includes(ticket.status) ? '-' : '-'} + + )} {/* 状态 */} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9ba3f45..ea93c98 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -158,6 +158,13 @@ export interface PermissionNode { children?: PermissionNode[]; } +export interface QueryStatsData { + total: number; + avg_time_ms: number; + slow_count: number; + type_distribution: { label: string; count: number }[]; +} + export interface DashboardStats { ticket_total: number; ticket_pending: number; @@ -167,6 +174,7 @@ export interface DashboardStats { instance_online: number; user_total: number; queries_today: number; + query_stats?: QueryStatsData; } export interface MenuItem {