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:
1 parent
be2d6f3894
commit
43d1f3a234
10 files changed
+587
-440
No files matched your search
@@ -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})
|
||||||
|
|||||||
@@ -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: 权限检查
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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()}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
{/* 状态 */}
|
{/* 状态 */}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user