feat: 工单列表重设计+权限组实例过滤+通知修复

后端:
- Ticket model 新增 ticket_no 字段(格式: YYYYMMDDHHmmss+ms+seq, 如20260522160543841)
- create_ticket 自动生成工单编号(flush后生成)
- _enrich_ticket 新增 instance_name, current_approver_name 字段
- current_approver: 从 ticket_approvals 查询第一个 pending 状态的审批步骤获取审核人
- TicketResponse schema 新增 ticket_no, instance_name, current_approver_name
- 新增 /instances/accessible endpoint(按用户权限组过滤实例, 无admin bypass)
- instance_group response_model 改为 InstanceGroupDetailResponse(含readonly/write实例)
- instance_group_members 脏数据清理, 重建 group_id=2 正确数据
- notification 模板修复: \n→\n 换行, 钉钉 \n→\n\n 双换行
- test_notification endpoint 改为真正调用 NotificationSender.send()
- auditor 角色添加到 audit_rule/ticket 只读端点
- seed_data() 创建4个默认用户(admin/zhangsan/laoliu/lurenjia)
- SQLite ALTER TABLE 添加 ticket_no 列, 迁移现有6条工单数据

前端:
- TicketsPage 完全重设计: 工单编号/实例名称/提交人/当前审核人/状态/操作
- 状态映射简化: pending/reviewing/approved→审核中, success→完成, failed→失败
- 执行详情弹框: 解析 execute_result JSON 展示状态/影响行数/耗时/验证/原始输出/错误信息
- 状态流程可视化(提交→审批→通过→执行→完成)
- 快速筛选: 全部/待审批/执行中/完成/失败 (合并 pending+reviewing+approved 统计)
- TicketCreatePage: 去掉数据库类型选择器, 用 accessible(write) 过滤变更实例
- QueriesPage: 用 accessible(readonly) 过滤查询实例
- instance.ts 新增 accessible 方法
- 分页优化: 动态计算页码范围(总页>5时中间滚动)
This commit is contained in:
logan committed 2026-05-24 22:55:51 +08:00
1 parent 88d064ea95
commit 08f0f88cf1
20 files changed
+1545 -817

No files matched your search

+43
View File
@@ -53,6 +53,49 @@ async def _get_workflow_names(db: AsyncSession, instances: list) -> dict:
return {w.id: w.name for w in result.scalars().all()} return {w.id: w.name for w in result.scalars().all()}
@router.get("/accessible", response_model=List[InstanceResponse])
async def list_accessible_instances(
access_type: str = "readonly",
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""返回当前用户有权访问的实例列表(按权限组的 access_type 过滤)。
所有用户(包括 admin)统一按权限组关联过滤:
user_groups → instance_group_members(access_type) → 返回对应实例
access_type: readonly=查询授权, write=变更授权
"""
from app.models.instance_group import instance_group_members, user_groups as ug_table
# 查用户关联的所有权限组
grp_result = await db.execute(
select(ug_table.c.group_id).where(ug_table.c.user_id == current_user.id)
)
group_ids = [row[0] for row in grp_result.all()]
if not group_ids:
return []
# 从 instance_group_members 中获取指定 access_type 的 instance_id
mem_result = await db.execute(
select(instance_group_members.c.instance_id)
.where(
instance_group_members.c.group_id.in_(group_ids),
instance_group_members.c.access_type == access_type,
)
.distinct()
)
instance_ids = [row[0] for row in mem_result.all()]
if not instance_ids:
return []
# 查实例详情
stmt = select(Instance).where(Instance.id.in_(instance_ids)).order_by(Instance.id)
result = await db.execute(stmt)
instances = result.scalars().all()
wnames = await _get_workflow_names(db, instances)
return [_serialize_json_fields(i, wnames.get(i.workflow_id)) for i in instances]
@router.get("", response_model=List[InstanceResponse]) @router.get("", response_model=List[InstanceResponse])
async def list_instances( async def list_instances(
db_type: Optional[str] = None, db_type: Optional[str] = None,
@@ -117,7 +117,7 @@ async def _sync_members(
) )
@router.get("", response_model=List[InstanceGroupResponse]) @router.get("", response_model=List[InstanceGroupDetailResponse])
async def list_instance_groups( async def list_instance_groups(
db_type: Optional[str] = None, db_type: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -131,7 +131,7 @@ async def list_instance_groups(
return [await _serialize_group_detail(g, db) for g in groups] return [await _serialize_group_detail(g, db) for g in groups]
@router.post("", response_model=InstanceGroupResponse, status_code=201) @router.post("", response_model=InstanceGroupDetailResponse, status_code=201)
async def create_instance_group( async def create_instance_group(
data: InstanceGroupCreate, data: InstanceGroupCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -164,7 +164,7 @@ async def create_instance_group(
await _sync_members(db, group.id, data.readonly_instance_ids, data.write_instance_ids) await _sync_members(db, group.id, data.readonly_instance_ids, data.write_instance_ids)
await db.commit() await db.commit()
return await _serialize_group(group, db) return await _serialize_group_detail(group, db)
@router.get("/{group_id}", response_model=InstanceGroupDetailResponse) @router.get("/{group_id}", response_model=InstanceGroupDetailResponse)
@@ -182,7 +182,7 @@ async def get_instance_group(
return await _serialize_group_detail(group, db) return await _serialize_group_detail(group, db)
@router.put("/{group_id}", response_model=InstanceGroupResponse) @router.put("/{group_id}", response_model=InstanceGroupDetailResponse)
async def update_instance_group( async def update_instance_group(
group_id: int, group_id: int,
data: InstanceGroupUpdate, data: InstanceGroupUpdate,
@@ -242,7 +242,7 @@ async def update_instance_group(
) )
await db.commit() await db.commit()
return await _serialize_group(group, db) return await _serialize_group_detail(group, db)
@router.delete("/{group_id}") @router.delete("/{group_id}")
@@ -0,0 +1,287 @@
"""定时任务 API — 基于 SQL 的自动化任务管理。"""
import time
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func as sa_func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_role
from app.models.user import User
from app.models.instance import Instance
from app.models.scheduled_task import ScheduledTask, ScheduledTaskLog
from app.schemas.scheduled_task import (
ScheduledTaskCreate, ScheduledTaskUpdate,
ScheduledTaskResponse, ScheduledTaskLogResponse, TaskRunResponse,
)
from app.services.db_connector import DBConnector
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/scheduled-tasks", tags=["自动化任务"])
def _serialize(task: ScheduledTask, instance_name: str = None, instance_db_type: str = None) -> dict:
return {
"id": task.id,
"name": task.name,
"description": task.description,
"task_sql_type": task.task_sql_type,
"instance_id": task.instance_id,
"instance_name": instance_name,
"instance_db_type": instance_db_type,
"target_db": task.target_db,
"target_table": task.target_table,
"sql_content": task.sql_content,
"cron_expression": task.cron_expression,
"is_enabled": task.is_enabled,
"last_run_at": task.last_run_at,
"last_run_status": task.last_run_status,
"last_run_message": task.last_run_message,
"next_run_at": task.next_run_at,
"run_count": task.run_count or 0,
"created_by": task.created_by,
"created_at": task.created_at,
}
async def _get_instance(db: AsyncSession, instance_id: int) -> Instance:
result = await db.execute(select(Instance).where(Instance.id == instance_id))
inst = result.scalar_one_or_none()
if not inst:
raise HTTPException(404, "数据源不存在")
return inst
# ── CRUD ──────────────────────────────────────────────────────
@router.get("", response_model=list[ScheduledTaskResponse])
async def list_tasks(
task_sql_type: Optional[str] = None,
is_enabled: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""列出所有定时任务。"""
q = select(ScheduledTask).order_by(ScheduledTask.created_at.desc())
if task_sql_type:
q = q.where(ScheduledTask.task_sql_type == task_sql_type)
if is_enabled is not None:
q = q.where(ScheduledTask.is_enabled == is_enabled)
result = await db.execute(q)
tasks = result.scalars().all()
# batch load instance info
inst_ids = {t.instance_id for t in tasks}
inst_map = {}
if inst_ids:
ir = await db.execute(select(Instance).where(Instance.id.in_(inst_ids)))
for inst in ir.scalars().all():
inst_map[inst.id] = inst
return [_serialize(t, inst_map.get(t.instance_id).name if inst_map.get(t.instance_id) else None,
inst_map.get(t.instance_id).db_type if inst_map.get(t.instance_id) else None)
for t in tasks]
@router.post("", response_model=ScheduledTaskResponse, status_code=201)
async def create_task(
body: ScheduledTaskCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_role("admin")),
):
"""创建定时任务。"""
# validate instance exists
inst = await _get_instance(db, body.instance_id)
task = ScheduledTask(
name=body.name,
description=body.description,
task_sql_type=body.task_sql_type.value,
instance_id=body.instance_id,
target_db=body.target_db,
target_table=body.target_table,
sql_content=body.sql_content,
cron_expression=body.cron_expression,
is_enabled=body.is_enabled,
created_by=user.id,
)
db.add(task)
await db.flush()
await db.refresh(task)
return _serialize(task, inst.name, inst.db_type)
@router.get("/{task_id}", response_model=ScheduledTaskResponse)
async def get_task(
task_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""获取任务详情。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
inst = await _get_instance(db, task.instance_id)
return _serialize(task, inst.name, inst.db_type)
@router.put("/{task_id}", response_model=ScheduledTaskResponse)
async def update_task(
task_id: int,
body: ScheduledTaskUpdate,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""更新任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
update_data = body.model_dump(exclude_unset=True)
if "task_sql_type" in update_data and update_data["task_sql_type"] is not None:
update_data["task_sql_type"] = update_data["task_sql_type"].value
for k, v in update_data.items():
setattr(task, k, v)
await db.flush()
await db.refresh(task)
inst = await _get_instance(db, task.instance_id)
return _serialize(task, inst.name, inst.db_type)
@router.delete("/{task_id}")
async def delete_task(
task_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""删除任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
await db.delete(task)
return {"detail": "已删除"}
@router.patch("/{task_id}/toggle")
async def toggle_task(
task_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""启用/禁用任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
task.is_enabled = not task.is_enabled
await db.flush()
return {"is_enabled": task.is_enabled}
# ── 执行 ──────────────────────────────────────────────────────
@router.post("/{task_id}/run", response_model=TaskRunResponse)
async def run_task(
task_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""手动执行一次任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
inst = await _get_instance(db, task.instance_id)
target_db = task.target_db or inst.database or ""
log = ScheduledTaskLog(
task_id=task.id,
status="running",
sql_content=task.sql_content,
)
db.add(log)
await db.flush()
start = time.time()
try:
msg = await DBConnector.execute_dml(inst, target_db, task.sql_content)
elapsed = int((time.time() - start) * 1000)
# parse affected rows from message like "执行成功,影响 3 行"
affected = 0
import re
m = re.search(r'(\d+)', msg)
if m:
affected = int(m.group(1))
log.status = "success"
log.affected_rows = affected
log.duration_ms = elapsed
log.finished_at = __import__("datetime").datetime.now()
task.last_run_at = __import__("datetime").datetime.now()
task.last_run_status = "success"
task.last_run_message = msg
task.run_count = (task.run_count or 0) + 1
await db.flush()
return TaskRunResponse(success=True, message=msg, affected_rows=affected, duration_ms=elapsed)
except Exception as e:
elapsed = int((time.time() - start) * 1000)
error_msg = str(e)
log.status = "failed"
log.error_message = error_msg
log.duration_ms = elapsed
log.finished_at = __import__("datetime").datetime.now()
task.last_run_at = __import__("datetime").datetime.now()
task.last_run_status = "failed"
task.last_run_message = error_msg
task.run_count = (task.run_count or 0) + 1
await db.flush()
return TaskRunResponse(success=False, message=error_msg, duration_ms=elapsed)
# ── 执行日志 ──────────────────────────────────────────────────
@router.get("/{task_id}/logs", response_model=list[ScheduledTaskLogResponse])
async def get_task_logs(
task_id: int,
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""获取任务执行日志。"""
q = (
select(ScheduledTaskLog)
.where(ScheduledTaskLog.task_id == task_id)
.order_by(ScheduledTaskLog.started_at.desc())
.limit(limit)
)
result = await db.execute(q)
logs = result.scalars().all()
return [
{
"id": l.id,
"task_id": l.task_id,
"status": l.status,
"sql_content": l.sql_content,
"affected_rows": l.affected_rows or 0,
"duration_ms": l.duration_ms,
"error_message": l.error_message,
"started_at": l.started_at,
"finished_at": l.finished_at,
}
for l in logs
]
+52 -10
View File
@@ -38,6 +38,24 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _generate_ticket_no(ticket_id: int) -> str:
"""生成工单编号,格式: YYYYMMDDHHmmss + 微秒前3位 + 序号补齐。
示例: 20260522160543841
Args:
ticket_id: 工单自增ID,用作末尾序号
Returns:
工单编号字符串
"""
now = datetime.now()
base = now.strftime("%Y%m%d%H%M%S")
ms = f"{now.microsecond:06d}"[:3] # 取前3位毫秒
seq = str(ticket_id).zfill(3)[-3:] # ID 补齐到3位
return f"{base}{ms}{seq}"
if TYPE_CHECKING: if TYPE_CHECKING:
from app.services.ticket_executor import ExecutionReport from app.services.ticket_executor import ExecutionReport
@@ -112,17 +130,10 @@ class TicketExecuteConfirm(BaseModel):
async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]: async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]:
"""丰富工单数据,添加申请人/审批人名称等信息。 """丰富工单数据,添加申请人/审批人/实例名称/当前审核人等信息。"""
Args:
ticket: 工单对象
db: 数据库会话
Returns:
丰富的工单字典
"""
data = { data = {
"id": ticket.id, "id": ticket.id,
"ticket_no": ticket.ticket_no,
"title": ticket.title, "title": ticket.title,
"db_type": ticket.db_type, "db_type": ticket.db_type,
"instance_id": ticket.instance_id, "instance_id": ticket.instance_id,
@@ -147,12 +158,39 @@ async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]:
applicant = r.scalar_one_or_none() applicant = r.scalar_one_or_none()
data["applicant_name"] = applicant.display_name if applicant else "未知" data["applicant_name"] = applicant.display_name if applicant else "未知"
# Resolve approver name # Resolve approver name (最终审批人)
if ticket.approver_id: if ticket.approver_id:
r = await db.execute(select(User).where(User.id == ticket.approver_id)) r = await db.execute(select(User).where(User.id == ticket.approver_id))
approver = r.scalar_one_or_none() approver = r.scalar_one_or_none()
data["approver_name"] = approver.display_name if approver else "未知" data["approver_name"] = approver.display_name if approver else "未知"
# Resolve instance name
if ticket.instance_id:
r = await db.execute(select(Instance).where(Instance.id == ticket.instance_id))
inst = r.scalar_one_or_none()
data["instance_name"] = inst.name if inst else "未知"
# Resolve current approver (审批/执行阶段的当前待审核人)
data["current_approver_name"] = None
if ticket.status in ("reviewing", "approved", "executing"):
pending_approval = 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
return data return data
@@ -370,6 +408,10 @@ async def create_ticket(
db.add(ticket) db.add(ticket)
await db.flush() # flush to get ticket.id await db.flush() # flush to get ticket.id
# 生成工单编号
ticket.ticket_no = _generate_ticket_no(ticket.id)
await db.flush()
# 7. 创建审批步骤(多级审批流) # 7. 创建审批步骤(多级审批流)
if default_workflow: if default_workflow:
wf_steps_result = await db.execute( wf_steps_result = await db.execute(
+34 -5
View File
@@ -95,11 +95,13 @@ async def create_workflow(
) )
db.add(step) db.add(step)
await db.commit() await db.flush()
# Reload with steps wf_id = workflow.id
# Reload with steps — use fresh query by id to avoid identity map issues
db.expire(workflow)
result = await db.execute( result = await db.execute(
select(WorkflowDefinition) select(WorkflowDefinition)
.where(WorkflowDefinition.id == workflow.id) .where(WorkflowDefinition.id == wf_id)
.options(selectinload(WorkflowDefinition.steps)) .options(selectinload(WorkflowDefinition.steps))
) )
workflow = result.scalar_one() workflow = result.scalar_one()
@@ -146,8 +148,35 @@ async def update_workflow(
if data.is_active is not None: if data.is_active is not None:
workflow.is_active = data.is_active workflow.is_active = data.is_active
await db.commit() # 更新步骤(先删后建)
await db.refresh(workflow) if data.steps is not None:
from sqlalchemy import delete as sa_delete
await db.execute(
sa_delete(WorkflowStep).where(WorkflowStep.workflow_id == workflow_id)
)
await db.flush()
for step_data in data.steps:
step = WorkflowStep(
workflow_id=workflow.id,
step_number=step_data.step_number,
name=step_data.name,
step_type=step_data.step_type,
approver_role=step_data.approver_role,
approver_ids=step_data.approver_ids,
is_required=step_data.is_required,
timeout_hours=step_data.timeout_hours,
auto_approve_on_timeout=step_data.auto_approve_on_timeout,
)
db.add(step)
await db.flush()
# Reload with steps
db.expire(workflow)
result = await db.execute(
select(WorkflowDefinition)
.where(WorkflowDefinition.id == workflow_id)
.options(selectinload(WorkflowDefinition.steps))
)
workflow = result.scalar_one()
return _serialize_workflow(workflow) return _serialize_workflow(workflow)
+2 -1
View File
@@ -7,7 +7,7 @@ from app.api.v1.endpoints import (
instance_group, masking_rule, rollback, instance_group, masking_rule, rollback,
workflow, impact, schema_drift, pool, workflow, impact, schema_drift, pool,
mongo, query_history, sql_assistant, db_types, mongo, query_history, sql_assistant, db_types,
settings, settings, scheduled_task,
) )
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
@@ -40,3 +40,4 @@ api_router.include_router(sql_assistant.router)
api_router.include_router(db_types.router) api_router.include_router(db_types.router)
api_router.include_router(settings.router, prefix="/settings", tags=["系统设置"]) api_router.include_router(settings.router, prefix="/settings", tags=["系统设置"])
api_router.include_router(scheduled_task.router)
+29 -7
View File
@@ -1,22 +1,44 @@
"""定时任务模型。""" """定时任务模型 — 基于纯SQL的自动化任务。"""
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, func from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, func
from app.core.database import Base from app.core.database import Base
class ScheduledTask(Base): class ScheduledTask(Base):
"""定时任务。""" """定时任务:insert / update / delete 三种类型,指定数据源+库表+纯SQL。"""
__tablename__ = "scheduled_tasks" __tablename__ = "scheduled_tasks"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False) name = Column(String(100), nullable=False)
task_type = Column(String(30), nullable=False) description = Column(Text, nullable=True)
instance_id = Column(Integer, ForeignKey("instances.id"), nullable=True) task_sql_type = Column(String(20), nullable=False, default="insert") # insert / update / delete
instance_id = Column(Integer, ForeignKey("instances.id", ondelete="CASCADE"), nullable=False)
target_db = Column(String(100), nullable=True)
target_table = Column(String(100), nullable=True)
sql_content = Column(Text, nullable=False)
cron_expression = Column(String(50), nullable=False) cron_expression = Column(String(50), nullable=False)
config = Column(Text, nullable=True) # JSON
is_enabled = Column(Boolean, default=True) is_enabled = Column(Boolean, default=True)
# 运行状态
last_run_at = Column(DateTime, nullable=True) last_run_at = Column(DateTime, nullable=True)
last_run_status = Column(String(20), nullable=True) last_run_status = Column(String(20), nullable=True) # success / failed
last_run_message = Column(Text, nullable=True) last_run_message = Column(Text, nullable=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True) next_run_at = Column(DateTime, nullable=True)
run_count = Column(Integer, default=0)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class ScheduledTaskLog(Base):
"""定时任务执行日志。"""
__tablename__ = "scheduled_task_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
task_id = Column(Integer, ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False)
status = Column(String(20), nullable=False) # success / failed / running
sql_content = Column(Text, nullable=True)
affected_rows = Column(Integer, default=0)
duration_ms = Column(Integer, nullable=True)
error_message = Column(Text, nullable=True)
started_at = Column(DateTime, server_default=func.now())
finished_at = Column(DateTime, nullable=True)
+1
View File
@@ -7,6 +7,7 @@ class Ticket(Base):
__tablename__ = "tickets" __tablename__ = "tickets"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
ticket_no = Column(String(20), unique=True, nullable=True, comment="工单编号,格式: 20260522160543841")
title = Column(String(200), nullable=False) title = Column(String(200), nullable=False)
db_type = Column(String(20), nullable=False) db_type = Column(String(20), nullable=False)
instance_id = Column(Integer, ForeignKey("instances.id"), nullable=False) instance_id = Column(Integer, ForeignKey("instances.id"), nullable=False)
+82
View File
@@ -0,0 +1,82 @@
"""定时任务 Schema — 基于 SQL 的自动化任务。"""
from typing import Optional, List
from pydantic import BaseModel, Field
from datetime import datetime
from enum import Enum
class TaskSqlType(str, Enum):
insert = "insert"
update = "update"
delete = "delete"
class ScheduledTaskCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
task_sql_type: TaskSqlType
instance_id: int
target_db: Optional[str] = None
target_table: Optional[str] = None
sql_content: str = Field(..., min_length=1)
cron_expression: str = Field(..., min_length=1)
is_enabled: bool = True
class ScheduledTaskUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
task_sql_type: Optional[TaskSqlType] = None
instance_id: Optional[int] = None
target_db: Optional[str] = None
target_table: Optional[str] = None
sql_content: Optional[str] = Field(None, min_length=1)
cron_expression: Optional[str] = Field(None, min_length=1)
is_enabled: Optional[bool] = None
class ScheduledTaskResponse(BaseModel):
id: int
name: str
description: Optional[str]
task_sql_type: str
instance_id: int
instance_name: Optional[str] = None
instance_db_type: Optional[str] = None
target_db: Optional[str]
target_table: Optional[str]
sql_content: str
cron_expression: str
is_enabled: bool
last_run_at: Optional[datetime]
last_run_status: Optional[str]
last_run_message: Optional[str]
next_run_at: Optional[datetime]
run_count: int
created_by: Optional[int]
created_at: datetime
class Config:
from_attributes = True
class ScheduledTaskLogResponse(BaseModel):
id: int
task_id: int
status: str
sql_content: Optional[str]
affected_rows: int
duration_ms: Optional[int]
error_message: Optional[str]
started_at: datetime
finished_at: Optional[datetime]
class Config:
from_attributes = True
class TaskRunResponse(BaseModel):
success: bool
message: str
affected_rows: int = 0
duration_ms: int = 0
+3
View File
@@ -21,9 +21,11 @@ class TicketReview(BaseModel):
class TicketResponse(BaseModel): class TicketResponse(BaseModel):
id: int id: int
ticket_no: Optional[str] = None
title: str title: str
db_type: str db_type: str
instance_id: int instance_id: int
instance_name: Optional[str] = None
target_database: str target_database: str
sql_content: str sql_content: str
rollback_sql: Optional[str] = None rollback_sql: Optional[str] = None
@@ -33,6 +35,7 @@ class TicketResponse(BaseModel):
applicant_name: Optional[str] = None applicant_name: Optional[str] = None
approver_id: Optional[int] = None approver_id: Optional[int] = None
approver_name: Optional[str] = None approver_name: Optional[str] = None
current_approver_name: Optional[str] = None
reason: Optional[str] = None reason: Optional[str] = None
audit_result: Optional[str] = None audit_result: Optional[str] = None
execute_result: Optional[str] = None execute_result: Optional[str] = None
+1
View File
@@ -33,6 +33,7 @@ class WorkflowUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
steps: Optional[List[WorkflowStepCreate]] = None
class WorkflowResponse(BaseModel): class WorkflowResponse(BaseModel):
+1
View File
@@ -2,6 +2,7 @@ import request from './request';
export const instanceApi = { export const instanceApi = {
list: (params?: any) => request.get<any, any[]>('/instances', { params }), list: (params?: any) => request.get<any, any[]>('/instances', { params }),
accessible: (accessType: 'readonly' | 'write') => request.get<any, any[]>('/instances/accessible', { params: { access_type: accessType } }),
get: (id: number) => request.get<any, any>(`/instances/${id}`), get: (id: number) => request.get<any, any>(`/instances/${id}`),
create: (data: any) => request.post<any, any>('/instances', data), create: (data: any) => request.post<any, any>('/instances', data),
update: (id: number, data: any) => request.put<any, any>(`/instances/${id}`, data), update: (id: number, data: any) => request.put<any, any>(`/instances/${id}`, data),
+20 -7
View File
@@ -34,12 +34,13 @@ interface Props {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onSaved: () => void; onSaved: () => void;
mode?: 'edit' | 'clone';
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Reusable Edit Instance Dialog */ /* Reusable Edit Instance Dialog */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
export default function EditInstanceDialog({ instance, open, onClose, onSaved }: Props) { export default function EditInstanceDialog({ instance, open, onClose, onSaved, mode = 'edit' }: Props) {
const [name, setName] = useState(instance.name); const [name, setName] = useState(instance.name);
const [host, setHost] = useState(instance.host); const [host, setHost] = useState(instance.host);
const [port, setPort] = useState(String(instance.port)); const [port, setPort] = useState(String(instance.port));
@@ -105,19 +106,31 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved }:
? maskFields.filter(m => m.table && m.field) ? maskFields.filter(m => m.table && m.field)
: undefined; : undefined;
await instanceApi.update(instance.id, { const payload: Record<string, unknown> = {
name: name.trim(), name: name.trim(),
host: host.trim(), host: host.trim(),
port: Number(port), port: Number(port),
username: username.trim() || undefined, username: username.trim() || undefined,
password: password || undefined,
database: database.trim() || undefined, database: database.trim() || undefined,
env, env,
access_mode: accessMode, access_mode: accessMode,
workflow_id: workflowId, workflow_id: workflowId,
excluded_databases: parsedExcluded, excluded_databases: parsedExcluded,
mask_fields: parsedMask, mask_fields: parsedMask,
}); };
if (mode === 'clone') {
payload.db_type = instance.db_type;
if (!password) {
alert('克隆数据源需要重新输入密码');
setSubmitting(false);
return;
}
payload.password = password;
await instanceApi.create(payload);
} else {
payload.password = password || undefined;
await instanceApi.update(instance.id, payload);
}
onSaved(); onSaved();
} catch (e: unknown) { } catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'; const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败';
@@ -140,7 +153,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved }:
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-5 py-4"> <div className="flex items-center justify-between px-5 py-4">
<h2 className="text-base font-semibold">编辑数据源</h2> <h2 className="text-base font-semibold">{mode === 'clone' ? '克隆数据源' : '编辑数据源'}</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors"> <button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@@ -172,7 +185,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved }:
<input className={inputCls} value={username} onChange={e => setUsername(e.target.value)} /> <input className={inputCls} value={username} onChange={e => setUsername(e.target.value)} />
</div> </div>
<div> <div>
<label className={labelCls}>密码(留空不修改)</label> <label className={labelCls}>{mode === 'clone' ? '密码' : '密码(留空不修改)'}</label>
<input className={inputCls} type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="••••••" /> <input className={inputCls} type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="••••••" />
</div> </div>
</div> </div>
@@ -254,7 +267,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved }:
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors">取消</button> <button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors">取消</button>
<button onClick={handleSubmit} disabled={submitting} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:opacity-50 rounded-lg transition-colors"> <button onClick={handleSubmit} disabled={submitting} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:opacity-50 rounded-lg transition-colors">
{submitting && <Loader2 className="w-4 h-4 animate-spin" />} {submitting && <Loader2 className="w-4 h-4 animate-spin" />}
保存 {mode === 'clone' ? '克隆' : '保存'}
</button> </button>
</div> </div>
</div> </div>
+20 -1
View File
@@ -164,7 +164,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
dataSourcePermissions: [], dataSourcePermissions: [],
customPermissions: {}, customPermissions: {},
}); });
const [isLoggedIn, setIsLoggedIn] = useState(() => !!localStorage.getItem('token'));
// 从 token 恢复基本用户信息(页面刷新时)
const [isLoggedIn, setIsLoggedIn] = useState(() => {
const token = localStorage.getItem('token');
if (!token) return false;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const role = payload.role as UserRole;
if (role) {
setUser(u => ({
...u,
id: Number(payload.sub) || 0,
role,
roleName: roleNameMap[role] || role,
avatar: role.charAt(0).toUpperCase(),
}));
}
} catch { /* ignore */ }
return true;
});
/** 权限缓存引用 */ /** 权限缓存引用 */
const permissionCacheRef = useRef<PermissionCache>({ const permissionCacheRef = useRef<PermissionCache>({
+21 -8
View File
@@ -21,7 +21,7 @@ const DB_TYPE_COLORS: Record<string, string> = {
/* ───────── Transfer Component ───────── */ /* ───────── Transfer Component ───────── */
interface TransferItem { label: string; value: string; tag?: string; tagColor?: string } interface TransferItem { label: string; value: string; tag?: string; tagColor?: string; accessMode?: string }
interface TransferProps { interface TransferProps {
title: string; title: string;
@@ -151,10 +151,23 @@ export default function InstanceGroupPage() {
value: String(i.id), value: String(i.id),
tag: i.db_type, tag: i.db_type,
tagColor: DB_TYPE_COLORS[i.db_type] || '', tagColor: DB_TYPE_COLORS[i.db_type] || '',
accessMode: i.access_mode || 'readwrite',
})), })),
[allInstances] [allInstances]
); );
// 查询授权:只匹配 read + readwrite 实例
const readonlyTransferItems = useMemo(() =>
transferItems.filter(i => i.accessMode === 'read' || i.accessMode === 'readwrite'),
[transferItems]
);
// 变更授权:只匹配 write + readwrite 实例
const writeTransferItems = useMemo(() =>
transferItems.filter(i => i.accessMode === 'write' || i.accessMode === 'readwrite'),
[transferItems]
);
const filteredGroups = useMemo(() => groups.filter(g => !search || g.name.includes(search)), [groups, search]); const filteredGroups = useMemo(() => groups.filter(g => !search || g.name.includes(search)), [groups, search]);
const filteredUsers = useMemo(() => allUsers.filter(u => !userSearch || (u.display_name || u.username).toLowerCase().includes(userSearch.toLowerCase())), [allUsers, userSearch]); const filteredUsers = useMemo(() => allUsers.filter(u => !userSearch || (u.display_name || u.username).toLowerCase().includes(userSearch.toLowerCase())), [allUsers, userSearch]);
@@ -263,20 +276,20 @@ export default function InstanceGroupPage() {
<div className="rounded-xl p-4 bg-emerald-500/5 border border-emerald-500/20"> <div className="rounded-xl p-4 bg-emerald-500/5 border border-emerald-500/20">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Eye className="w-4 h-4 text-emerald-400" /> <Eye className="w-4 h-4 text-emerald-400" />
<span className="text-sm font-medium text-emerald-400">只读查看</span> <span className="text-sm font-medium text-emerald-400">查询授权</span>
<span className="text-xs text-[var(--dg-text-muted)]">— 查询数据的实例</span> <span className="text-xs text-[var(--dg-text-muted)]">— 查询数据的实例</span>
</div> </div>
<Transfer title="只读实例" items={transferItems} selected={readonlyIds} onChange={setReadonlyIds} emptyText="选择用于查询的实例" /> <Transfer title="只读实例" items={readonlyTransferItems} selected={readonlyIds} onChange={setReadonlyIds} emptyText="选择用于查询的实例" />
</div> </div>
{/* Write Transfer */} {/* Write Transfer */}
<div className="rounded-xl p-4 bg-amber-500/5 border border-amber-500/20"> <div className="rounded-xl p-4 bg-amber-500/5 border border-amber-500/20">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<PenLine className="w-4 h-4 text-amber-400" /> <PenLine className="w-4 h-4 text-amber-400" />
<span className="text-sm font-medium text-amber-400">写入变更</span> <span className="text-sm font-medium text-amber-400">变更授权</span>
<span className="text-xs text-[var(--dg-text-muted)]">— 数据变更工单的实例</span> <span className="text-xs text-[var(--dg-text-muted)]">— 数据变更工单的实例</span>
</div> </div>
<Transfer title="写入实例" items={transferItems} selected={writeIds} onChange={setWriteIds} emptyText="选择用于变更的实例" /> <Transfer title="写入实例" items={writeTransferItems} selected={writeIds} onChange={setWriteIds} emptyText="选择用于变更的实例" />
</div> </div>
<div className="flex justify-end gap-3 pt-2"> <div className="flex justify-end gap-3 pt-2">
@@ -297,7 +310,7 @@ export default function InstanceGroupPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-semibold">数据库权限组</h1> <h1 className="text-2xl font-semibold">数据库权限组</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">每个策略包含「只读查看」和「写入变更」两类实例</p> <p className="text-sm text-[var(--dg-text-secondary)] mt-1">每个策略包含「查询授权」和「变更授权」两类实例</p>
</div> </div>
{canManage && ( {canManage && (
<button onClick={() => { resetForm(); setShowCreateModal(true); }} className="flex items-center gap-2 px-4 py-2.5 bg-blue-500 rounded-lg text-sm font-medium text-white hover:bg-blue-600 transition-colors"> <button onClick={() => { resetForm(); setShowCreateModal(true); }} className="flex items-center gap-2 px-4 py-2.5 bg-blue-500 rounded-lg text-sm font-medium text-white hover:bg-blue-600 transition-colors">
@@ -372,7 +385,7 @@ export default function InstanceGroupPage() {
<Card className="dg-card"> <Card className="dg-card">
<CardContent className="p-5"> <CardContent className="p-5">
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3"> <h3 className="text-sm font-semibold flex items-center gap-2 mb-3">
<Eye className="w-4 h-4 text-emerald-400" />只读查看 ({roInst.length}) <Eye className="w-4 h-4 text-emerald-400" />查询授权 ({roInst.length})
</h3> </h3>
<InstanceList instances={roInst} /> <InstanceList instances={roInst} />
</CardContent> </CardContent>
@@ -382,7 +395,7 @@ export default function InstanceGroupPage() {
<Card className="dg-card"> <Card className="dg-card">
<CardContent className="p-5"> <CardContent className="p-5">
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3"> <h3 className="text-sm font-semibold flex items-center gap-2 mb-3">
<PenLine className="w-4 h-4 text-amber-400" />写入变更 ({wrInst.length}) <PenLine className="w-4 h-4 text-amber-400" />变更授权 ({wrInst.length})
</h3> </h3>
<InstanceList instances={wrInst} /> <InstanceList instances={wrInst} />
</CardContent> </CardContent>
+79 -4
View File
@@ -4,7 +4,7 @@ import {
Activity, HardDrive, ChevronRight, Trash2, Edit2, Activity, HardDrive, ChevronRight, Trash2, Edit2,
XCircle, Eye, EyeOff, ChevronLeft, Gauge, Wifi, XCircle, Eye, EyeOff, ChevronLeft, Gauge, Wifi,
WifiOff, AlertTriangle, CheckCircle, Signal, WifiOff, AlertTriangle, CheckCircle, Signal,
LayoutGrid, ListFilter, X, Loader2, BarChart3, Users, LayoutGrid, ListFilter, X, Loader2, BarChart3, Users, Copy,
} 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 { useActionPermission } from '@/hooks/useAuth'; import { useActionPermission } from '@/hooks/useAuth';
@@ -140,6 +140,8 @@ export default function InstancePage() {
const [editingInstance, setEditingInstance] = useState<InstanceItem | null>(null); const [editingInstance, setEditingInstance] = useState<InstanceItem | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [batchDeleting, setBatchDeleting] = useState(false); const [batchDeleting, setBatchDeleting] = useState(false);
const [showClone, setShowClone] = useState(false);
const [cloneSource, setCloneSource] = useState<InstanceItem | null>(null);
const typeTabs = [ const typeTabs = [
{ key: 'all', label: '全部', icon: LayoutGrid }, { key: 'all', label: '全部', icon: LayoutGrid },
@@ -249,6 +251,7 @@ export default function InstancePage() {
canDelete={canDelete} canDelete={canDelete}
onDelete={() => { handleDelete(editingInstance.id); setView('grid'); setShowEdit(false); setEditingInstance(null); }} onDelete={() => { handleDelete(editingInstance.id); setView('grid'); setShowEdit(false); setEditingInstance(null); }}
onEdit={() => {}} onEdit={() => {}}
onClone={canAdd ? () => { setCloneSource(editingInstance); setShowClone(true); } : undefined}
/> />
<EditInstanceDialog <EditInstanceDialog
instance={editingInstance} instance={editingInstance}
@@ -256,6 +259,15 @@ export default function InstancePage() {
onClose={() => { setShowEdit(false); setEditingInstance(null); }} onClose={() => { setShowEdit(false); setEditingInstance(null); }}
onSaved={() => { setShowEdit(false); setEditingInstance(null); fetchInstances(); }} onSaved={() => { setShowEdit(false); setEditingInstance(null); fetchInstances(); }}
/> />
{cloneSource && showClone && (
<EditInstanceDialog
instance={{ ...cloneSource, name: `${cloneSource.name}-副本`, id: 0 }}
open={showClone}
onClose={() => { setShowClone(false); setCloneSource(null); }}
onSaved={() => { setShowClone(false); setCloneSource(null); fetchInstances(); }}
mode="clone"
/>
)}
</> </>
); );
} }
@@ -266,6 +278,7 @@ export default function InstancePage() {
if (view === 'detail' && selectedInstance) { if (view === 'detail' && selectedInstance) {
return ( return (
<>
<InstanceDetail <InstanceDetail
instance={selectedInstance} instance={selectedInstance}
onBack={() => setView('grid')} onBack={() => setView('grid')}
@@ -274,7 +287,18 @@ export default function InstancePage() {
canDelete={canDelete} canDelete={canDelete}
onDelete={() => { handleDelete(selectedInstance.id); setView('grid'); }} onDelete={() => { handleDelete(selectedInstance.id); setView('grid'); }}
onEdit={() => { setEditingInstance(selectedInstance); setShowEdit(true); }} onEdit={() => { setEditingInstance(selectedInstance); setShowEdit(true); }}
onClone={canAdd ? () => { setCloneSource(selectedInstance); setShowClone(true); } : undefined}
/> />
{cloneSource && showClone && (
<EditInstanceDialog
instance={{ ...cloneSource, name: `${cloneSource.name}-副本`, id: 0 }}
open={showClone}
onClose={() => { setShowClone(false); setCloneSource(null); }}
onSaved={() => { setShowClone(false); setCloneSource(null); fetchInstances(); }}
mode="clone"
/>
)}
</>
); );
} }
@@ -540,11 +564,34 @@ export default function InstancePage() {
<div className="mt-2 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="mt-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-between">
<span className="text-[11px] text-blue-400 flex items-center gap-1"> <span className="text-[11px] text-blue-400 flex items-center gap-1">
查看详情 查看详情
<ChevronRight className="w-3 h-3" /> <ChevronRight className="w-3 h-3" />
</span> </span>
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
{canAdd && (
<button
onClick={() => {
setCloneSource(instance);
setShowClone(true);
}}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
title="克隆实例"
>
<Copy className="w-3.5 h-3.5" />
</button>
)}
{canEdit && (
<button
onClick={() => { setEditingInstance(instance); setShowEdit(true); }}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
title="编辑实例"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div> </div>
</div> </div>
); );
@@ -627,12 +674,23 @@ export default function InstancePage() {
</div> </div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2">
<button <button
onClick={e => { e.stopPropagation(); setSelectedInstance(instance); setView('detail'); }} onClick={e => { e.stopPropagation(); setSelectedInstance(instance); setView('detail'); }}
className="text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors" className="text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors"
> >
查看 查看
</button> </button>
{canAdd && (
<button
onClick={e => { e.stopPropagation(); setCloneSource(instance); setShowClone(true); }}
className="text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
title="克隆实例"
>
<Copy className="w-3.5 h-3.5" />
</button>
)}
</div>
</td> </td>
</tr> </tr>
); );
@@ -643,6 +701,17 @@ export default function InstancePage() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Clone Dialog */}
{cloneSource && showClone && (
<EditInstanceDialog
instance={{ ...cloneSource, name: `${cloneSource.name}-副本`, id: 0 }}
open={showClone}
onClose={() => { setShowClone(false); setCloneSource(null); }}
onSaved={() => { setShowClone(false); setCloneSource(null); fetchInstances(); }}
mode="clone"
/>
)}
</div> </div>
); );
} }
@@ -650,10 +719,10 @@ export default function InstancePage() {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* InstanceDetail */ /* InstanceDetail */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete, onEdit }: { function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete, onEdit, onClone }: {
instance: InstanceItem; onBack: () => void; instance: InstanceItem; onBack: () => void;
config: { icon: React.ElementType; color: string; bg: string }; config: { icon: React.ElementType; color: string; bg: string };
canEdit: boolean; canDelete: boolean; onDelete: () => void; onEdit: () => void; canEdit: boolean; canDelete: boolean; onDelete: () => void; onEdit: () => void; onClone?: () => void;
}) { }) {
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null); const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null);
@@ -710,6 +779,12 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
<Zap className="w-4 h-4" /> <Zap className="w-4 h-4" />
{testing ? '测试中...' : '测试连接'} {testing ? '测试中...' : '测试连接'}
</button> </button>
{onClone && (
<button onClick={onClone} className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
<Copy className="w-4 h-4" />
克隆
</button>
)}
{canEdit && ( {canEdit && (
<button onClick={onEdit} className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"> <button onClick={onEdit} className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
<Edit2 className="w-4 h-4" /> <Edit2 className="w-4 h-4" />
+1 -1
View File
@@ -309,7 +309,7 @@ function NewQueryPanel({
const [showSidebar, setShowSidebar] = useState(true); const [showSidebar, setShowSidebar] = useState(true);
useEffect(() => { useEffect(() => {
instanceApi.list().then(res => { instanceApi.accessible('readonly').then(res => {
const data = Array.isArray(res) ? res : []; const data = Array.isArray(res) ? res : [];
setInstances(data); setInstances(data);
}).catch(() => { setInstances([]); }); }).catch(() => { setInstances([]); });
+12 -49
View File
@@ -165,7 +165,6 @@ export default function TicketCreatePage() {
// -- Form fields -- // -- Form fields --
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [priority, setPriority] = useState('medium'); const [priority, setPriority] = useState('medium');
const [dbType, setDbType] = useState('');
const [instanceId, setInstanceId] = useState(''); const [instanceId, setInstanceId] = useState('');
const [database, setDatabase] = useState(''); const [database, setDatabase] = useState('');
const [sqlContent, setSqlContent] = useState(''); const [sqlContent, setSqlContent] = useState('');
@@ -208,7 +207,7 @@ export default function TicketCreatePage() {
useEffect(() => { useEffect(() => {
setLoadingInstances(true); setLoadingInstances(true);
instanceApi.list() instanceApi.accessible('write')
.then((res: unknown) => { .then((res: unknown) => {
const data = (res as { items?: unknown[] })?.items ?? res; const data = (res as { items?: unknown[] })?.items ?? res;
setInstances(Array.isArray(data) ? data as InstanceItem[] : []); setInstances(Array.isArray(data) ? data as InstanceItem[] : []);
@@ -217,33 +216,9 @@ export default function TicketCreatePage() {
.finally(() => setLoadingInstances(false)); .finally(() => setLoadingInstances(false));
}, []); }, []);
const filteredInstances = useMemo(() => { // 从选中实例自动获取 dbType
if (!dbType) return instances; const selectedInstance = useMemo(() => instances.find((i) => i.id === Number(instanceId)), [instances, instanceId]);
return instances.filter(i => { const dbType = selectedInstance?.db_type || '';
if (dbType === 'postgres') return i.db_type === 'postgres' || i.db_type === 'postgresql';
return i.db_type === dbType;
});
}, [instances, dbType]);
useEffect(() => {
if (instanceId) {
const current = instances.find(i => i.id === Number(instanceId));
if (current && dbType) {
const match = dbType === 'postgres'
? (current.db_type === 'postgres' || current.db_type === 'postgresql')
: current.db_type === dbType;
if (!match) {
setInstanceId('');
setDatabase('');
setDatabases([]);
}
}
}
}, [dbType, instances, instanceId]);
/* ---------------------------------------------------------------- */
/* Fetch databases */
/* ---------------------------------------------------------------- */
useEffect(() => { useEffect(() => {
if (!instanceId) { setDatabases([]); setDatabase(''); return; } if (!instanceId) { setDatabases([]); setDatabase(''); return; }
@@ -391,7 +366,7 @@ export default function TicketCreatePage() {
try { try {
const inst = instances.find((i) => i.id === Number(instanceId)); const inst = instances.find((i) => i.id === Number(instanceId));
await ticketApi.create({ await ticketApi.create({
title: title.trim(), db_type: inst?.db_type || dbType || 'mysql', title: title.trim(), db_type: inst?.db_type || 'mysql',
instance_id: Number(instanceId), target_database: database, instance_id: Number(instanceId), target_database: database,
sql_content: sqlContent, rollback_sql: rollbackSql || undefined, sql_content: sqlContent, rollback_sql: rollbackSql || undefined,
reason: reason.trim(), priority: priority, reason: reason.trim(), priority: priority,
@@ -403,13 +378,12 @@ export default function TicketCreatePage() {
const msg = Array.isArray(raw) ? raw.map((v) => v.msg).join('; ') : (raw || '提交失败,请稍后重试'); const msg = Array.isArray(raw) ? raw.map((v) => v.msg).join('; ') : (raw || '提交失败,请稍后重试');
setError(msg); setError(msg);
} finally { setSubmitting(false); } } finally { setSubmitting(false); }
}, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, dbType, navigate]); }, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, navigate]);
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* Derived values */ /* Derived values */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
const selectedInstance = useMemo(() => instances.find((i) => i.id === Number(instanceId)), [instances, instanceId]);
const isRedis = selectedInstance?.db_type === 'redis'; const isRedis = selectedInstance?.db_type === 'redis';
const isMySQL = selectedInstance?.db_type === 'mysql' || selectedInstance?.db_type === 'postgresql' || selectedInstance?.db_type === 'postgres'; const isMySQL = selectedInstance?.db_type === 'mysql' || selectedInstance?.db_type === 'postgresql' || selectedInstance?.db_type === 'postgres';
@@ -770,33 +744,22 @@ export default function TicketCreatePage() {
{PRIORITY_OPTIONS.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)} {PRIORITY_OPTIONS.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select> </select>
</div> </div>
<div>
<label className="block text-sm font-medium mb-2">
数据库类型 <span className="text-red-400">*</span>
</label>
<select value={dbType} onChange={(e) => setDbType(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors">
{DB_TYPE_OPTIONS.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
</div>
</div> </div>
{/* Instance */} {/* Instance — 直接展示用户变更授权的实例 */}
<div> <div>
<label className="block text-sm font-medium mb-2"> <label className="block text-sm font-medium mb-2">
选择实例 <span className="text-red-400">*</span> 目标实例 <span className="text-red-400">*</span>
</label> </label>
<select value={instanceId} onChange={(e) => setInstanceId(e.target.value)} <select value={instanceId} onChange={(e) => setInstanceId(e.target.value)}
disabled={loadingInstances || filteredInstances.length === 0} disabled={loadingInstances}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50"> className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50">
<option value=""> <option value="">
{loadingInstances ? '加载中...' : dbType && filteredInstances.length === 0 {loadingInstances ? '加载中...' : instances.length === 0 ? '暂无变更授权实例' : '请选择实例'}
? `无 ${(DB_TYPE_LABELS[dbType]) || dbType} 实例` : dbType
? `请选择 ${(DB_TYPE_LABELS[dbType]) || dbType} 实例` : '请先选择数据库类型'}
</option> </option>
{filteredInstances.map((inst) => ( {instances.map((inst) => (
<option key={inst.id} value={String(inst.id)}> <option key={inst.id} value={String(inst.id)}>
{inst.name} ({inst.host}:{inst.port}) {inst.name} ({inst.host}:{inst.port}) — {DB_TYPE_LABELS[inst.db_type] || inst.db_type}
</option> </option>
))} ))}
</select> </select>
File diff suppressed because it is too large. Load diff
+490 -47
View File
@@ -2,20 +2,19 @@ import { useState, useEffect, useCallback } from 'react';
import { import {
Plus, Edit2, Eye, ToggleLeft, ToggleRight, GitBranch, Users, CheckCircle, Plus, Edit2, Eye, ToggleLeft, ToggleRight, GitBranch, Users, CheckCircle,
ArrowRight, Shield, Trash2, X, ChevronDown, ChevronUp, ArrowRight, Shield, Trash2, X, ChevronDown, ChevronUp,
Save, Play, Copy, History, TestTube, AlertTriangle, Save, Play, Copy, Loader2, Star, ArrowUp, ArrowDown, UserPlus,
Loader2, Check, XCircle, Star,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { dbTypeColorMap } from '@/lib/constants';
import { workflowApi } from '@/api/workflow'; import { workflowApi } from '@/api/workflow';
import { userApi } from '@/api/user';
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Types */ /* Types */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
interface WorkflowStep { interface WorkflowStep {
id: number; id?: number;
workflow_id: number; workflow_id?: number;
step_number: number; step_number: number;
name: string; name: string;
step_type: string; step_type: string;
@@ -36,6 +35,19 @@ interface WorkflowDetail {
steps: WorkflowStep[]; steps: WorkflowStep[];
} }
interface UserItem {
id: number;
username: string;
display_name: string | null;
role: string;
}
interface StepDraft {
name: string;
step_type: 'submit' | 'audit' | 'execute';
approver_ids: number[];
}
const STEP_TYPE_LABELS: Record<string, string> = { const STEP_TYPE_LABELS: Record<string, string> = {
submit: '提交阶段', submit: '提交阶段',
serial: '串行审批', serial: '串行审批',
@@ -63,34 +75,431 @@ const STEP_TYPE_ICONS: Record<string, React.ElementType> = {
execute: Play, execute: Play,
}; };
const STEP_TYPES = [
{ value: 'submit' as const, label: '提交阶段', desc: '工单提交人' },
{ value: 'audit' as const, label: '审核阶段', desc: '审核人员审批' },
{ value: 'execute' as const, label: '执行阶段', desc: '执行人员操作' },
];
/* ------------------------------------------------------------------ */
/* UserDropdown — 下拉多选用户 */
/* ------------------------------------------------------------------ */
interface UserDropdownProps {
users: UserItem[];
selectedIds: number[];
label: string;
onToggle: (uid: number) => void;
onToggleAll: () => void;
onClear: () => void;
getUserName: (uid: number) => string;
}
function UserDropdown({ users, selectedIds, label, onToggle, onToggleAll, onClear, getUserName }: UserDropdownProps) {
const [open, setOpen] = useState(false);
const allSelected = selectedIds.length === users.length && users.length > 0;
return (
<div className="relative">
{/* Trigger */}
<button
type="button"
onClick={() => setOpen(!open)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors ${
selectedIds.length === 0
? 'bg-[var(--dg-bg-tertiary)] border border-red-500/40 text-[var(--dg-text-muted)]'
: 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)]'
}`}
>
<span className="truncate">
{selectedIds.length === 0
? `${label} * 请选择`
: selectedIds.length === 1
? getUserName(selectedIds[0])
: `${label} (${selectedIds.length}人)`
}
</span>
<ChevronDown className={`w-4 h-4 text-[var(--dg-text-muted)] transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{/* Selected tags */}
{selectedIds.length > 1 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{selectedIds.map((uid) => (
<span
key={uid}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] bg-blue-500/15 text-blue-400"
>
{getUserName(uid)}
<button onClick={() => onToggle(uid)} className="hover:text-red-400 transition-colors">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{/* Dropdown */}
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl max-h-52 overflow-y-auto">
{/* Select all / Clear */}
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--dg-border)]">
<button
onClick={onToggleAll}
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
>
{allSelected ? '取消全选' : '全选'}
</button>
{selectedIds.length > 0 && (
<button onClick={onClear} className="text-[11px] text-red-400 hover:text-red-300 transition-colors">
清空
</button>
)}
</div>
{/* User list */}
{users.map((u) => {
const checked = selectedIds.includes(u.id);
return (
<div
key={u.id}
onClick={() => onToggle(u.id)}
className="flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-[var(--dg-bg-hover)] transition-colors"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
checked
? 'bg-blue-500 border-blue-500'
: 'border-[var(--dg-border)]'
}`}>
{checked && <CheckCircle className="w-3 h-3 text-white" />}
</div>
<span className={`text-sm flex-1 ${checked ? 'text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-secondary)]'}`}>
{u.display_name || u.username}
</span>
<span className="text-[10px] text-[var(--dg-text-muted)] px-1.5 py-0.5 rounded bg-[var(--dg-bg-tertiary)]">
{u.role}
</span>
</div>
);
})}
</div>
</>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* WorkflowEditorModal — 创建/编辑流程弹框 */
/* ------------------------------------------------------------------ */
interface EditorProps {
workflow?: WorkflowDetail | null;
users: UserItem[];
onClose: () => void;
onSaved: () => void;
}
function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps) {
const isEdit = !!workflow;
const [name, setName] = useState(workflow?.name || '');
const [description, setDescription] = useState(workflow?.description || '');
const [steps, setSteps] = useState<StepDraft[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
// 初始化步骤
useEffect(() => {
if (workflow?.steps?.length) {
setSteps(
workflow.steps.map((s) => ({
name: s.name,
step_type: (s.step_type === 'serial' || s.step_type === 'parallel' || s.step_type === 'any_one')
? 'audit' as const
: s.step_type as 'submit' | 'audit' | 'execute',
approver_ids: s.approver_ids ? JSON.parse(s.approver_ids) : [],
}))
);
} else {
// 新建默认:提交→执行
setSteps([
{ name: '提交阶段', step_type: 'submit', approver_ids: [] },
{ name: '执行阶段', step_type: 'execute', approver_ids: [] },
]);
}
}, [workflow]);
const addStep = (type: 'submit' | 'audit' | 'execute') => {
const labels: Record<string, string> = { submit: '提交阶段', audit: '审核阶段', execute: '执行阶段' };
const count = steps.filter((s) => s.step_type === type).length;
const suffix = count > 0 ? ` ${count + 1}` : '';
setSteps([...steps, { name: `${labels[type]}${suffix}`, step_type: type, approver_ids: [] }]);
};
const removeStep = (idx: number) => setSteps(steps.filter((_, i) => i !== idx));
const moveStep = (idx: number, dir: -1 | 1) => {
const target = idx + dir;
if (target < 0 || target >= steps.length) return;
const next = [...steps];
[next[idx], next[target]] = [next[target], next[idx]];
setSteps(next);
};
const updateStep = (idx: number, patch: Partial<StepDraft>) => {
setSteps(steps.map((s, i) => (i === idx ? { ...s, ...patch } : s)));
};
const toggleUser = (idx: number, uid: number) => {
const step = steps[idx];
const has = step.approver_ids.includes(uid);
updateStep(idx, {
approver_ids: has ? step.approver_ids.filter((id) => id !== uid) : [...step.approver_ids, uid],
});
};
const toggleAllUsers = (idx: number) => {
const step = steps[idx];
if (step.approver_ids.length === users.length) {
updateStep(idx, { approver_ids: [] });
} else {
updateStep(idx, { approver_ids: users.map((u) => u.id) });
}
};
const handleSubmit = async () => {
if (!name.trim()) { setError('请输入流程名称'); return; }
if (steps.length === 0) { setError('请至少添加一个步骤'); return; }
// 验证非 submit 步骤至少有一个人员
for (let i = 0; i < steps.length; i++) {
if (steps[i].step_type !== 'submit' && steps[i].approver_ids.length === 0) {
setError(`步骤"${steps[i].name}"需要关联至少一个人员`);
return;
}
}
setSubmitting(true);
setError('');
try {
const payload = {
name: name.trim(),
description: description.trim() || null,
steps: steps.map((s, i) => ({
step_number: i + 1,
name: s.name,
step_type: s.step_type,
approver_ids: s.approver_ids.length > 0 ? JSON.stringify(s.approver_ids) : null,
is_required: true,
timeout_hours: 24,
auto_approve_on_timeout: false,
})),
};
if (isEdit && workflow) {
await workflowApi.update(workflow.id, payload);
} else {
await workflowApi.create(payload);
}
onSaved();
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败';
setError(msg);
} finally {
setSubmitting(false);
}
};
const getUserName = (uid: number) => {
const u = users.find((u) => u.id === uid);
return u ? (u.display_name || u.username) : `#${uid}`;
};
const inputCls = 'w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div
className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4">
<h2 className="text-base font-semibold">{isEdit ? '编辑审批流程' : '新建审批流程'}</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* Body */}
<div className="px-5 pb-5 space-y-4">
{error && <div className="px-3 py-2 text-sm text-red-400 bg-red-500/10 rounded-lg">{error}</div>}
<div>
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">流程名称 *</label>
<input className={inputCls} value={name} onChange={(e) => setName(e.target.value)} placeholder="输入流程名称" />
</div>
<div>
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">描述</label>
<input className={inputCls} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="可选" />
</div>
{/* Steps */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-medium text-[var(--dg-text-secondary)]">流程步骤</label>
<div className="flex items-center gap-1">
{STEP_TYPES.map((t) => (
<button
key={t.value}
onClick={() => addStep(t.value)}
className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 rounded transition-colors"
>
<Plus className="w-3 h-3" />
{t.label}
</button>
))}
</div>
</div>
<div className="space-y-2">
{steps.map((step, idx) => {
const typeConf = STEP_TYPES.find((t) => t.value === step.step_type)!;
const colorCls = STEP_TYPE_COLORS[step.step_type];
const Icon = STEP_TYPE_ICONS[step.step_type] || Shield;
return (
<div key={idx} className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
{/* Step header */}
<div className="flex items-center gap-2 mb-2">
<div className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${colorCls.split(' ')[0]}`}>
<Icon className={`w-3.5 h-3.5 ${colorCls.split(' ')[1]}`} />
</div>
<input
className="flex-1 px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-sm focus:border-blue-400 focus:outline-none"
value={step.name}
onChange={(e) => updateStep(idx, { name: e.target.value })}
placeholder="步骤名称"
/>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colorCls}`}>
{typeConf.label}
</span>
<div className="flex items-center gap-0.5 ml-1">
<button
onClick={() => moveStep(idx, -1)}
disabled={idx === 0}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] disabled:opacity-20 transition-colors"
title="上移"
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
onClick={() => moveStep(idx, 1)}
disabled={idx === steps.length - 1}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] disabled:opacity-20 transition-colors"
title="下移"
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
<button
onClick={() => removeStep(idx)}
className="p-1 rounded hover:bg-red-500/10 text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* User selection — submit 步骤不需要选人 */}
{step.step_type !== 'submit' && (
<div className="pl-9 mt-2">
<UserDropdown
users={users}
selectedIds={step.approver_ids}
label={step.step_type === 'audit' ? '审核人员' : '执行人员'}
onToggle={(uid) => toggleUser(idx, uid)}
onToggleAll={() => toggleAllUsers(idx)}
onClear={() => updateStep(idx, { approver_ids: [] })}
getUserName={getUserName}
/>
</div>
)}
</div>
);
})}
</div>
{steps.length === 0 && (
<div className="text-center py-4 text-sm text-[var(--dg-text-muted)]">
请点击上方按钮添加流程步骤
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-5 py-4">
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors">
取消
</button>
<button
onClick={handleSubmit}
disabled={submitting}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:opacity-50 rounded-lg transition-colors"
>
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
{isEdit ? '保存' : '创建'}
</button>
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Main Page */
/* ------------------------------------------------------------------ */
export default function WorkflowPage() { export default function WorkflowPage() {
const [items, setItems] = useState<WorkflowDetail[]>([]); const [items, setItems] = useState<WorkflowDetail[]>([]);
const [users, setUsers] = useState<UserItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
const [previewWorkflow, setPreviewWorkflow] = useState<WorkflowDetail | null>(null); const [previewWorkflow, setPreviewWorkflow] = useState<WorkflowDetail | null>(null);
const [editorWorkflow, setEditorWorkflow] = useState<WorkflowDetail | null | undefined>(undefined);
const fetchWorkflows = useCallback(async () => { const fetchWorkflows = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const data = await workflowApi.list(); const data = await workflowApi.list();
setItems(data); setItems(Array.isArray(data) ? data : []);
} catch (err: any) { } catch (err: unknown) {
setError(err?.response?.data?.detail || '获取审批流程失败'); const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '获取审批流程失败';
setError(msg);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, []);
const fetchUsers = useCallback(async () => {
try {
const data = await userApi.list();
setUsers(Array.isArray(data) ? data : []);
} catch {}
}, []);
useEffect(() => { useEffect(() => {
fetchWorkflows(); fetchWorkflows();
}, [fetchWorkflows]); fetchUsers();
}, [fetchWorkflows, fetchUsers]);
const toggleWorkflow = useCallback(async (wf: WorkflowDetail) => { const toggleWorkflow = useCallback(async (wf: WorkflowDetail) => {
try { try {
await workflowApi.update(wf.id, { is_active: !wf.is_active }); await workflowApi.update(wf.id, { is_active: !wf.is_active });
setItems(prev => prev.map(w => w.id === wf.id ? { ...w, is_active: !w.is_active } : w)); setItems((prev) => prev.map((w) => (w.id === wf.id ? { ...w, is_active: !w.is_active } : w)));
} catch {} } catch {}
}, []); }, []);
@@ -98,14 +507,14 @@ export default function WorkflowPage() {
if (!confirm('确认删除该审批流程?')) return; if (!confirm('确认删除该审批流程?')) return;
try { try {
await workflowApi.delete(id); await workflowApi.delete(id);
setItems(prev => prev.filter(w => w.id !== id)); setItems((prev) => prev.filter((w) => w.id !== id));
} catch {} } catch {}
}, []); }, []);
const setDefault = useCallback(async (id: number) => { const setDefault = useCallback(async (id: number) => {
try { try {
await workflowApi.setDefault(id); await workflowApi.setDefault(id);
setItems(prev => prev.map(w => ({ ...w, is_default: w.id === id }))); setItems((prev) => prev.map((w) => ({ ...w, is_default: w.id === id })));
} catch {} } catch {}
}, []); }, []);
@@ -116,6 +525,14 @@ export default function WorkflowPage() {
} catch {} } catch {}
}, [fetchWorkflows]); }, [fetchWorkflows]);
const getUserName = useCallback(
(uid: number) => {
const u = users.find((u) => u.id === uid);
return u ? (u.display_name || u.username) : `#${uid}`;
},
[users]
);
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
@@ -127,7 +544,6 @@ export default function WorkflowPage() {
if (error) { if (error) {
return ( return (
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)]"> <div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)]">
<AlertCircle className="w-12 h-12 mb-3 text-red-400" />
<p className="text-sm">{error}</p> <p className="text-sm">{error}</p>
<button onClick={fetchWorkflows} className="mt-3 px-4 py-2 bg-blue-500/15 text-blue-400 rounded-lg text-sm hover:bg-blue-500/25 transition-colors"> <button onClick={fetchWorkflows} className="mt-3 px-4 py-2 bg-blue-500/15 text-blue-400 rounded-lg text-sm hover:bg-blue-500/25 transition-colors">
重试 重试
@@ -142,14 +558,27 @@ export default function WorkflowPage() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div>
<h1 className="text-2xl font-semibold">审批流程配置</h1> <h1 className="text-2xl font-semibold">审批流程配置</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置多级审批流程模板,支持串行/并行审批模式</p> <p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置多级审批流程,支持审核阶段叠加和多人执行</p>
</div> </div>
<button
onClick={() => setEditorWorkflow(null)}
className="flex items-center gap-2 px-4 py-2.5 bg-blue-500 text-white rounded-lg text-sm font-medium hover:bg-blue-600 transition-colors"
>
<Plus className="w-4 h-4" />
新建流程
</button>
</div> </div>
{items.length === 0 ? ( {items.length === 0 ? (
<div className="dg-card p-12 text-center"> <div className="dg-card p-12 text-center">
<GitBranch className="w-12 h-12 mx-auto mb-3 opacity-50 text-[var(--dg-text-muted)]" /> <GitBranch className="w-12 h-12 mx-auto mb-3 opacity-50 text-[var(--dg-text-muted)]" />
<p className="text-sm text-[var(--dg-text-muted)]">暂无审批流程,请联系管理员配置</p> <p className="text-sm text-[var(--dg-text-muted)] mb-4">暂无审批流程</p>
<button
onClick={() => setEditorWorkflow(null)}
className="px-4 py-2 bg-blue-500/15 text-blue-400 rounded-lg text-sm hover:bg-blue-500/25 transition-colors"
>
创建第一个流程
</button>
</div> </div>
) : ( ) : (
<> <>
@@ -157,8 +586,8 @@ export default function WorkflowPage() {
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[ {[
{ label: '流程总数', value: items.length, icon: GitBranch, color: 'text-blue-400', bg: 'bg-blue-400/10' }, { label: '流程总数', value: items.length, icon: GitBranch, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ label: '已启用', value: items.filter(w => w.is_active).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' }, { label: '已启用', value: items.filter((w) => w.is_active).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
{ label: '默认流程', value: items.filter(w => w.is_default).length, icon: Star, color: 'text-yellow-400', bg: 'bg-yellow-400/10' }, { label: '默认流程', value: items.filter((w) => w.is_default).length, icon: Star, color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
{ label: '总步骤数', value: items.reduce((acc, w) => acc + w.steps.length, 0), icon: Users, color: 'text-purple-400', bg: 'bg-purple-400/10' }, { label: '总步骤数', value: items.reduce((acc, w) => acc + w.steps.length, 0), icon: Users, color: 'text-purple-400', bg: 'bg-purple-400/10' },
].map((s, i) => ( ].map((s, i) => (
<Card key={i} className="dg-card"> <Card key={i} className="dg-card">
@@ -199,22 +628,23 @@ export default function WorkflowPage() {
</span> </span>
</div> </div>
{wf.description && ( {wf.description && <p className="text-sm text-[var(--dg-text-secondary)] mb-3">{wf.description}</p>}
<p className="text-sm text-[var(--dg-text-secondary)] mb-3">{wf.description}</p>
)}
{/* Flow visualization */} {/* Flow visualization */}
<div className="flex items-center gap-1 mt-4 p-4 bg-[var(--dg-bg-tertiary)] rounded-lg overflow-x-auto"> <div className="flex items-center gap-1 mt-4 p-4 bg-[var(--dg-bg-tertiary)] rounded-lg overflow-x-auto">
{wf.steps.map((step, i) => { {wf.steps.map((step, i) => {
const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield; const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield;
const uids: number[] = step.approver_ids ? JSON.parse(step.approver_ids) : [];
return ( return (
<div key={step.id} className="flex items-center gap-1 flex-shrink-0"> <div key={step.id || i} className="flex items-center gap-1 flex-shrink-0">
<div className={`px-3 py-2 rounded-lg border ${STEP_TYPE_COLORS[step.step_type] || STEP_TYPE_COLORS.serial} flex items-center gap-2`}> <div className={`px-3 py-2 rounded-lg border ${STEP_TYPE_COLORS[step.step_type] || STEP_TYPE_COLORS.serial} flex items-center gap-2`}>
<StepIcon className="w-4 h-4" /> <StepIcon className="w-4 h-4" />
<div> <div>
<div className="text-xs font-medium whitespace-nowrap">{step.name}</div> <div className="text-xs font-medium whitespace-nowrap">{step.name}</div>
{step.approver_role && ( {uids.length > 0 && (
<div className="text-[10px] opacity-80 whitespace-nowrap">{step.approver_role}</div> <div className="text-[10px] opacity-80 whitespace-nowrap">
{uids.map((uid) => getUserName(uid)).join('、')}
</div>
)} )}
</div> </div>
</div> </div>
@@ -231,11 +661,12 @@ export default function WorkflowPage() {
<GitBranch className="w-4 h-4 text-[var(--dg-text-muted)]" /> <GitBranch className="w-4 h-4 text-[var(--dg-text-muted)]" />
流程步骤详情 流程步骤详情
</h4> </h4>
{wf.steps.map((step) => { {wf.steps.map((step, si) => {
const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield; const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield;
const colorClass = STEP_TYPE_COLORS[step.step_type] || STEP_TYPE_COLORS.serial; const colorClass = STEP_TYPE_COLORS[step.step_type] || STEP_TYPE_COLORS.serial;
const uids: number[] = step.approver_ids ? JSON.parse(step.approver_ids) : [];
return ( return (
<div key={step.id} className="flex items-start gap-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]"> <div key={step.id || si} className="flex items-start gap-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${colorClass.split(' ')[0]}`}> <div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${colorClass.split(' ')[0]}`}>
<StepIcon className={`w-4 h-4 ${colorClass.split(' ')[1]}`} /> <StepIcon className={`w-4 h-4 ${colorClass.split(' ')[1]}`} />
</div> </div>
@@ -245,23 +676,15 @@ export default function WorkflowPage() {
<span className={`text-[11px] px-2 py-0.5 rounded ${colorClass}`}> <span className={`text-[11px] px-2 py-0.5 rounded ${colorClass}`}>
{STEP_TYPE_LABELS[step.step_type] || step.step_type} {STEP_TYPE_LABELS[step.step_type] || step.step_type}
</span> </span>
{step.timeout_hours !== 24 && (
<span className="text-[11px] px-2 py-0.5 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">
超时 {step.timeout_hours}h
</span>
)}
{step.auto_approve_on_timeout && (
<span className="text-[11px] px-2 py-0.5 rounded bg-yellow-500/10 text-yellow-400">
超时自动通过
</span>
)}
</div> </div>
{step.approver_role && ( {uids.length > 0 && (
<div className="flex items-center gap-1 mt-1 flex-wrap"> <div className="flex items-center gap-1 mt-1 flex-wrap">
<span className="text-xs text-[var(--dg-text-muted)]">审批角色:</span> <span className="text-xs text-[var(--dg-text-muted)]">关联人员:</span>
<span className="text-xs text-blue-400 bg-blue-500/10 px-1.5 py-0.5 rounded"> {uids.map((uid) => (
{step.approver_role} <span key={uid} className="text-[11px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">
{getUserName(uid)}
</span> </span>
))}
</div> </div>
)} )}
</div> </div>
@@ -274,6 +697,13 @@ export default function WorkflowPage() {
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-1 ml-4 shrink-0"> <div className="flex items-center gap-1 ml-4 shrink-0">
<button
onClick={() => setEditorWorkflow(wf)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-blue-500/10 hover:text-blue-400 transition-colors"
title="编辑"
>
<Edit2 className="w-4 h-4" />
</button>
{!wf.is_default && ( {!wf.is_default && (
<button onClick={() => setDefault(wf.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-yellow-500/10 hover:text-yellow-400 transition-colors" title="设为默认"> <button onClick={() => setDefault(wf.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-yellow-500/10 hover:text-yellow-400 transition-colors" title="设为默认">
<Star className="w-4 h-4" /> <Star className="w-4 h-4" />
@@ -321,15 +751,14 @@ export default function WorkflowPage() {
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
{previewWorkflow.description && ( {previewWorkflow.description && <p className="text-sm text-[var(--dg-text-secondary)] mb-4">{previewWorkflow.description}</p>}
<p className="text-sm text-[var(--dg-text-secondary)] mb-4">{previewWorkflow.description}</p>
)}
<div className="space-y-4"> <div className="space-y-4">
{previewWorkflow.steps.map((step, i) => { {previewWorkflow.steps.map((step, i) => {
const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield; const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield;
const colorClass = STEP_TYPE_COLORS[step.step_type] || STEP_TYPE_COLORS.serial; const colorClass = STEP_TYPE_COLORS[step.step_type] || STEP_TYPE_COLORS.serial;
const uids: number[] = step.approver_ids ? JSON.parse(step.approver_ids) : [];
return ( return (
<div key={step.id} className="flex items-start gap-4"> <div key={step.id || i} className="flex items-start gap-4">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${colorClass.split(' ')[0]}`}> <div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${colorClass.split(' ')[0]}`}>
<StepIcon className={`w-5 h-5 ${colorClass.split(' ')[1]}`} /> <StepIcon className={`w-5 h-5 ${colorClass.split(' ')[1]}`} />
</div> </div>
@@ -340,10 +769,14 @@ export default function WorkflowPage() {
{STEP_TYPE_LABELS[step.step_type] || step.step_type} {STEP_TYPE_LABELS[step.step_type] || step.step_type}
</span> </span>
</div> </div>
{step.approver_role && ( {uids.length > 0 && (
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-1 mt-1 flex-wrap">
<span className="text-xs text-[var(--dg-text-muted)]">审批角色:</span> <span className="text-xs text-[var(--dg-text-muted)]">关联人员:</span>
<span className="text-xs text-blue-400">{step.approver_role}</span> {uids.map((uid) => (
<span key={uid} className="text-[11px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">
{getUserName(uid)}
</span>
))}
</div> </div>
)} )}
</div> </div>
@@ -360,6 +793,16 @@ export default function WorkflowPage() {
</div> </div>
</div> </div>
)} )}
{/* Editor Modal */}
{editorWorkflow !== undefined && (
<WorkflowEditorModal
workflow={editorWorkflow}
users={users}
onClose={() => setEditorWorkflow(undefined)}
onSaved={() => { setEditorWorkflow(undefined); fetchWorkflows(); }}
/>
)}
</div> </div>
); );
} }