feat: 工单执行人直接执行(自动通过) + 审批时间线 + 执行结果展示
后端: - 创建工单时检测是否有审核步骤,无审核步骤直接设为 reviewing - ExecuteTicket 允许 reviewing 状态,自动通过 execute 审批记录再执行 - 验证执行人权限(非执行人返回 403) - 列表 API 返回 has_audit_step/is_executor/is_approver 元信息 - 详情 API 返回 approvals 带 approver_name/actual_approver_name 前端: - 按钮权限精确化:通过/拒绝只对审核人显示,执行只对执行人显示 - 详情弹框加载完整审批数据,展示操作时间线 - 时间线:提交人→每个步骤的指派人+实际操作人+操作时间+备注 - 执行结果文本格式 fallback 显示 - 失败工单显示失败原因
This commit is contained in:
1 parent
cdb52aa4a9
commit
b4c982d565
2 files changed
+262
-16
No files matched your search
@@ -101,9 +101,34 @@ func ListTickets(c *gin.Context) {
|
|||||||
|
|
||||||
var tickets []model.Ticket
|
var tickets []model.Ticket
|
||||||
query.Offset((page - 1) * limit).Limit(limit).Find(&tickets)
|
query.Offset((page - 1) * limit).Limit(limit).Find(&tickets)
|
||||||
|
|
||||||
|
// Enrich tickets with has_audit_step info
|
||||||
|
type TicketWithMeta struct {
|
||||||
|
model.Ticket
|
||||||
|
HasAuditStep bool `json:"has_audit_step"`
|
||||||
|
HasExecuteStep bool `json:"has_execute_step"`
|
||||||
|
IsExecutor bool `json:"is_executor"`
|
||||||
|
IsApprover bool `json:"is_approver"`
|
||||||
|
}
|
||||||
|
var result []TicketWithMeta
|
||||||
|
for _, t := range tickets {
|
||||||
|
var auditCount, execCount int64
|
||||||
|
model.DB.Model(&model.TicketApproval{}).Where("ticket_id = ? AND step_type = 'audit'", t.ID).Count(&auditCount)
|
||||||
|
model.DB.Model(&model.TicketApproval{}).Where("ticket_id = ? AND step_type = 'execute'", t.ID).Count(&execCount)
|
||||||
|
var isExec, isAppr int64
|
||||||
|
model.DB.Model(&model.TicketApproval{}).Where("ticket_id = ? AND step_type = 'execute' AND approver_id = ?", t.ID, userID).Count(&isExec)
|
||||||
|
model.DB.Model(&model.TicketApproval{}).Where("ticket_id = ? AND step_type != 'execute' AND step_type != 'submit' AND approver_id = ?", t.ID, userID).Count(&isAppr)
|
||||||
|
result = append(result, TicketWithMeta{
|
||||||
|
Ticket: t,
|
||||||
|
HasAuditStep: auditCount > 0,
|
||||||
|
HasExecuteStep: execCount > 0,
|
||||||
|
IsExecutor: isExec > 0,
|
||||||
|
IsApprover: isAppr > 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"items": tickets,
|
"items": result,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": limit,
|
"page_size": limit,
|
||||||
@@ -119,9 +144,31 @@ func GetTicket(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get approvals
|
// Get approvals — enrich with user display names
|
||||||
var approvals []model.TicketApproval
|
var approvals []model.TicketApproval
|
||||||
model.DB.Where("ticket_id = ?", id).Order("step_number").Find(&approvals)
|
model.DB.Where("ticket_id = ?", id).Order("step_number, id").Find(&approvals)
|
||||||
|
type ApprovalWithNames struct {
|
||||||
|
model.TicketApproval
|
||||||
|
ApproverName string `json:"approver_name"`
|
||||||
|
ActualApproverName string `json:"actual_approver_name"`
|
||||||
|
}
|
||||||
|
var approvalsResult []ApprovalWithNames
|
||||||
|
for _, a := range approvals {
|
||||||
|
item := ApprovalWithNames{TicketApproval: a}
|
||||||
|
if a.ApproverID != nil {
|
||||||
|
var u model.User
|
||||||
|
if model.DB.First(&u, *a.ApproverID).Error == nil {
|
||||||
|
item.ApproverName = u.DisplayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.ActualApproverID != nil {
|
||||||
|
var u model.User
|
||||||
|
if model.DB.First(&u, *a.ActualApproverID).Error == nil {
|
||||||
|
item.ActualApproverName = u.DisplayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
approvalsResult = append(approvalsResult, item)
|
||||||
|
}
|
||||||
|
|
||||||
// Get SQL items
|
// Get SQL items
|
||||||
var sqlItems []model.TicketSQLItem
|
var sqlItems []model.TicketSQLItem
|
||||||
@@ -132,7 +179,7 @@ func GetTicket(c *gin.Context) {
|
|||||||
// Marshal ticket to JSON then unmarshal to map for flattening
|
// Marshal ticket to JSON then unmarshal to map for flattening
|
||||||
ticketBytes, _ := json.Marshal(ticket)
|
ticketBytes, _ := json.Marshal(ticket)
|
||||||
json.Unmarshal(ticketBytes, &result)
|
json.Unmarshal(ticketBytes, &result)
|
||||||
result["approvals"] = approvals
|
result["approvals"] = approvalsResult
|
||||||
result["sql_items"] = sqlItems
|
result["sql_items"] = sqlItems
|
||||||
|
|
||||||
// Add applicant_name and instance_name for frontend
|
// Add applicant_name and instance_name for frontend
|
||||||
@@ -213,6 +260,7 @@ func CreateTicket(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create approval records based on workflow or default to admin
|
// Create approval records based on workflow or default to admin
|
||||||
|
hasAuditStep := false
|
||||||
if input.WorkflowID != nil && *input.WorkflowID > 0 {
|
if input.WorkflowID != nil && *input.WorkflowID > 0 {
|
||||||
// Load workflow steps
|
// Load workflow steps
|
||||||
var wfSteps []model.WorkflowStep
|
var wfSteps []model.WorkflowStep
|
||||||
@@ -221,6 +269,9 @@ func CreateTicket(c *gin.Context) {
|
|||||||
if ws.StepType == "submit" {
|
if ws.StepType == "submit" {
|
||||||
continue // submit step needs no approver
|
continue // submit step needs no approver
|
||||||
}
|
}
|
||||||
|
if ws.StepType != "execute" {
|
||||||
|
hasAuditStep = true
|
||||||
|
}
|
||||||
// Parse approver_ids from JSON
|
// Parse approver_ids from JSON
|
||||||
var approverIDs []int
|
var approverIDs []int
|
||||||
if ws.ApproverIDs != nil && *ws.ApproverIDs != "" {
|
if ws.ApproverIDs != nil && *ws.ApproverIDs != "" {
|
||||||
@@ -283,7 +334,14 @@ func CreateTicket(c *gin.Context) {
|
|||||||
model.DB.Create(&approval)
|
model.DB.Create(&approval)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If workflow has no audit step (only submit + execute), skip to reviewing
|
||||||
|
if !hasAuditStep {
|
||||||
|
reviewing := "reviewing"
|
||||||
|
model.DB.Model(&ticket).Update("status", reviewing)
|
||||||
|
ticket.Status = &reviewing
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, ticket)
|
c.JSON(http.StatusCreated, ticket)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,11 +427,57 @@ func ExecuteTicket(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check ticket status
|
// Check ticket status — allow approved OR reviewing (will auto-approve execute step)
|
||||||
if ticket.Status != nil && *ticket.Status != "approved" {
|
if ticket.Status != nil && *ticket.Status != "approved" && *ticket.Status != "reviewing" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "Ticket must be approved before execution"})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "Ticket must be approved before execution"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-approve any pending execute-type approval for the current user
|
||||||
|
userID, _, _ := middleware.GetCurrentUser(c)
|
||||||
|
|
||||||
|
// Verify current user is an executor for this ticket
|
||||||
|
var executorCheck int64
|
||||||
|
model.DB.Model(&model.TicketApproval{}).
|
||||||
|
Where("ticket_id = ? AND step_type = 'execute' AND (approver_id = ? OR approver_id IS NULL)", id, userID).
|
||||||
|
Count(&executorCheck)
|
||||||
|
if executorCheck == 0 {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"detail": "你不是此工单的执行人"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingExec model.TicketApproval
|
||||||
|
if err := model.DB.Where("ticket_id = ? AND step_type = 'execute' AND action IS NULL", id).First(&pendingExec).Error; err == nil {
|
||||||
|
// Auto-approve the execute step
|
||||||
|
now := time.Now()
|
||||||
|
action := "approve"
|
||||||
|
comment := "自动通过(执行触发)"
|
||||||
|
pendingExec.Action = &action
|
||||||
|
pendingExec.Comment = &comment
|
||||||
|
pendingExec.ActualApproverID = &userID
|
||||||
|
pendingExec.CompletedAt = &now
|
||||||
|
model.DB.Save(&pendingExec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all audit steps are approved
|
||||||
|
var pendingAudit int64
|
||||||
|
model.DB.Model(&model.TicketApproval{}).
|
||||||
|
Where("ticket_id = ? AND step_type = 'audit' AND action IS NULL", id).
|
||||||
|
Count(&pendingAudit)
|
||||||
|
if pendingAudit > 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "还有未完成的审核步骤"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for rejected audit steps
|
||||||
|
var rejectedAudit int64
|
||||||
|
model.DB.Model(&model.TicketApproval{}).
|
||||||
|
Where("ticket_id = ? AND step_type = 'audit' AND action = 'reject'", id).
|
||||||
|
Count(&rejectedAudit)
|
||||||
|
if rejectedAudit > 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "审核已被拒绝,无法执行"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get instance info
|
// Get instance info
|
||||||
var instance model.Instance
|
var instance model.Instance
|
||||||
|
|||||||
@@ -45,6 +45,25 @@ interface TicketItem {
|
|||||||
create_time: string;
|
create_time: string;
|
||||||
review_time: string | null;
|
review_time: string | null;
|
||||||
execute_time: string | null;
|
execute_time: string | null;
|
||||||
|
has_audit_step: boolean;
|
||||||
|
has_execute_step: boolean;
|
||||||
|
is_executor: boolean;
|
||||||
|
is_approver: boolean;
|
||||||
|
approvals?: ApprovalRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApprovalRecord {
|
||||||
|
id: number;
|
||||||
|
step_number: number;
|
||||||
|
step_name: string | null;
|
||||||
|
step_type: string | null;
|
||||||
|
approver_id: number | null;
|
||||||
|
approver_name: string;
|
||||||
|
actual_approver_id: number | null;
|
||||||
|
actual_approver_name: string;
|
||||||
|
action: string | null;
|
||||||
|
comment: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TicketListResponse {
|
interface TicketListResponse {
|
||||||
@@ -426,11 +445,11 @@ export default function TicketsPage() {
|
|||||||
{ticket.status === 'success' || ticket.status === 'failed' ? '详情' : '查看'}
|
{ticket.status === 'success' || ticket.status === 'failed' ? '详情' : '查看'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{canApprove && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await ticketApi.review(ticket.id, { action: 'approve', comment: '审批通过' });
|
await ticketApi.review(ticket.id, { action: 'approve', comment: '通过' });
|
||||||
fetchTickets(); fetchStats();
|
fetchTickets(); fetchStats();
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}}
|
}}
|
||||||
@@ -441,7 +460,7 @@ export default function TicketsPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canApprove && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
@@ -456,7 +475,7 @@ export default function TicketsPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canExecute && ticket.status === 'approved' && (
|
{canExecute && ticket.is_executor && (ticket.status === 'approved' || ticket.status === 'reviewing') && (
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
@@ -560,7 +579,7 @@ export default function TicketsPage() {
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Ticket Detail Dialog */
|
/* Ticket Detail Dialog */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
function TicketDetailDialog({ ticket, onClose, canApprove, canExecute, onActionDone }: {
|
function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExecute, onActionDone }: {
|
||||||
ticket: TicketItem;
|
ticket: TicketItem;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
canApprove: boolean;
|
canApprove: boolean;
|
||||||
@@ -568,6 +587,20 @@ function TicketDetailDialog({ ticket, onClose, canApprove, canExecute, onActionD
|
|||||||
onActionDone: () => void;
|
onActionDone: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [ticket, setTicket] = useState<TicketItem>(initialTicket);
|
||||||
|
const [approvals, setApprovals] = useState<ApprovalRecord[]>([]);
|
||||||
|
|
||||||
|
// Load full detail on mount
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const detail = await ticketApi.get(ticket.id) as any;
|
||||||
|
setTicket(prev => ({ ...prev, ...detail }));
|
||||||
|
setApprovals(detail.approvals || []);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})();
|
||||||
|
}, [ticket.id]);
|
||||||
|
|
||||||
const execResult = parseExecuteResult(ticket.execute_result);
|
const execResult = parseExecuteResult(ticket.execute_result);
|
||||||
|
|
||||||
const currentStepIndex = getStatusIndex(ticket.status);
|
const currentStepIndex = getStatusIndex(ticket.status);
|
||||||
@@ -609,7 +642,7 @@ function TicketDetailDialog({ ticket, onClose, canApprove, canExecute, onActionD
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{canApprove && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
{canApprove && ticket.is_approver && (ticket.status === 'pending' || ticket.status === 'reviewing') && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleReview('approve')}
|
onClick={() => handleReview('approve')}
|
||||||
@@ -627,7 +660,7 @@ function TicketDetailDialog({ ticket, onClose, canApprove, canExecute, onActionD
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canExecute && ticket.status === 'approved' && (
|
{canExecute && ticket.is_executor && (ticket.status === 'approved' || ticket.status === 'reviewing') && (
|
||||||
<button
|
<button
|
||||||
onClick={handleExecute}
|
onClick={handleExecute}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -785,8 +818,117 @@ function TicketDetailDialog({ ticket, onClose, canApprove, canExecute, onActionD
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Execute Result — plain text fallback for non-JSON results */}
|
||||||
|
{!execResult && ticket.execute_result && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||||
|
<Play className="w-4 h-4 text-purple-400" /> 执行结果
|
||||||
|
</h3>
|
||||||
|
<div className={`p-3 rounded-lg text-xs font-mono whitespace-pre-wrap ${
|
||||||
|
ticket.status === 'failed' ? 'bg-red-500/5 border border-red-500/20 text-red-400' : 'bg-[var(--dg-bg-tertiary)]'
|
||||||
|
}`}>
|
||||||
|
{ticket.execute_result}
|
||||||
|
</div>
|
||||||
|
{ticket.execute_time && (
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mt-2">
|
||||||
|
执行时间: {new Date(ticket.execute_time).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Failure reason for failed/rejected tickets without execute_result */}
|
||||||
|
{(ticket.status === 'failed' || ticket.status === 'rejected') && !ticket.execute_result && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-red-400" /> 失败原因
|
||||||
|
</h3>
|
||||||
|
<div className="p-3 bg-red-500/5 border border-red-500/20 rounded-lg text-xs text-red-400">
|
||||||
|
{ticket.status === 'rejected' ? '工单已被拒绝' : '执行失败,请查看审批记录了解详情'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline — approval history */}
|
||||||
|
{approvals.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
||||||
|
<Clock className="w-4 h-4 text-blue-400" /> 操作时间线
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-0">
|
||||||
|
{/* Submit step */}
|
||||||
|
<div className="flex gap-3 pb-3">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-green-500/15 text-green-400 flex items-center justify-center">
|
||||||
|
<FileCode className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
<div className="w-[2px] flex-1 bg-[var(--dg-border)]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 pb-3">
|
||||||
|
<div className="text-xs font-medium text-green-400">提交申请</div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
||||||
|
{ticket.applicant_name || `用户#${ticket.applicant_id}`} · {new Date(ticket.create_time).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
{ticket.reason && (
|
||||||
|
<div className="text-xs text-[var(--dg-text-secondary)] mt-1 bg-[var(--dg-bg-tertiary)] rounded px-2 py-1">{ticket.reason}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group approvals by step_number */}
|
||||||
|
{Object.values(
|
||||||
|
approvals.reduce((acc, a) => {
|
||||||
|
const key = `${a.step_number}_${a.step_name || ''}`;
|
||||||
|
if (!acc[key]) acc[key] = { step: a, users: [] };
|
||||||
|
acc[key].users.push(a);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, { step: ApprovalRecord; users: ApprovalRecord[] }>)
|
||||||
|
).map(({ step, users }, idx, arr) => {
|
||||||
|
const isCompleted = users.some(u => u.action);
|
||||||
|
const actor = users.find(u => u.actual_approver_id);
|
||||||
|
const StepIcon = step.step_type === 'execute' ? Play : Eye;
|
||||||
|
const isLast = idx === arr.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.id} className="flex gap-3">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${
|
||||||
|
isCompleted ? 'bg-green-500/15 text-green-400' :
|
||||||
|
step.action === 'reject' ? 'bg-red-500/15 text-red-400' :
|
||||||
|
'bg-blue-500/15 text-blue-400'
|
||||||
|
}`}>
|
||||||
|
<StepIcon className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
{!isLast && <div className="w-[2px] flex-1 bg-[var(--dg-border)]" />}
|
||||||
|
</div>
|
||||||
|
<div className={`flex-1 ${!isLast ? 'pb-3' : ''}`}>
|
||||||
|
<div className={`text-xs font-medium ${
|
||||||
|
isCompleted ? 'text-green-400' :
|
||||||
|
step.action === 'reject' ? 'text-red-400' :
|
||||||
|
'text-blue-400'
|
||||||
|
}`}>
|
||||||
|
{step.step_name || (step.step_type === 'execute' ? '执行变更' : '审批')}
|
||||||
|
{!isCompleted && <span className="text-[var(--dg-text-muted)] font-normal ml-1">(待处理)</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
||||||
|
指派人: {users.map(u => u.approver_name).filter(Boolean).join(', ') || '-'}
|
||||||
|
</div>
|
||||||
|
{actor && actor.completed_at && (
|
||||||
|
<div className="text-xs text-[var(--dg-text-secondary)] mt-0.5">
|
||||||
|
{actor.actual_approver_name} · {actor.action === 'approve' ? '通过' : actor.action === 'reject' ? '拒绝' : '执行'} · {new Date(actor.completed_at).toLocaleString()}
|
||||||
|
{actor.comment && <span className="ml-1 text-[var(--dg-text-muted)]">({actor.comment})</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalOverlay>
|
</ModalOverlay>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user