feat: emergency unlock queued tasks to hall

This commit is contained in:
Your Name 2026-03-12 10:26:37 +08:00
parent 40de164628
commit 088ba915ca
8 changed files with 478 additions and 23 deletions

View File

@ -225,4 +225,26 @@ public class AdminTaskController {
return Result.success(result);
}
@Operation(summary = "应急解锁排队任务到大厅", description = "管理员可将排队中的任务(主任务编号/子任务编号)直接解锁为可抢单状态")
@PostMapping("/unlock/manual")
public Result<Map<String, Object>> emergencyUnlockToHall(
@RequestBody EmergencyUnlockRequest request,
@RequestHeader("Authorization") String authHeader
) {
String token = jwtUtil.extractTokenFromHeader(authHeader);
Long adminId = jwtUtil.getUserIdFromToken(token);
Map<String, Object> result = adminTaskService.emergencyUnlockQueuedTaskToHall(
request == null ? null : request.getTaskNoOrItemNo(),
adminId,
request == null ? null : request.getReason()
);
return Result.success(result);
}
@Data
public static class EmergencyUnlockRequest {
private String taskNoOrItemNo;
private String reason;
}
}

View File

@ -2,6 +2,7 @@ package com.designhub.controller;
import com.designhub.common.Result;
import com.designhub.service.SystemConfigService;
import com.designhub.service.TaskUnlockService;
import com.designhub.service.TaskService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -39,9 +40,10 @@ public class OpenTaskController {
private static final String KEY_IP_WHITELIST = "openapi.task_delete_ip_whitelist";
private final TaskService taskService;
private final TaskUnlockService taskUnlockService;
private final SystemConfigService systemConfigService;
@Operation(summary = "开放接口删除任务", description = "供外部机器人按任务编号删除任务")
@Operation(summary = "开放接口删除/解锁任务", description = "供外部机器人按任务编号删除任务或解锁排队任务到大厅")
@PostMapping("/delete")
public Result<Map<String, Object>> deleteTaskByTaskNo(
@RequestBody OpenDeleteTaskRequest requestBody,
@ -83,22 +85,49 @@ public class OpenTaskController {
return Result.error("签名已过期");
}
String payload = requestBody.getAppKey() + "|" + requestBody.getTaskNo().trim() + "|" + requestBody.getTimestamp();
String expectedSign = hmacSha256Hex(configuredSecret, payload);
if (!secureEquals(expectedSign, requestBody.getSign().trim().toLowerCase(Locale.ROOT))) {
String action = normalizeAction(requestBody.getAction());
String sign = requestBody.getSign().trim().toLowerCase(Locale.ROOT);
// v1签名: appKey|taskNo|timestamp (兼容旧机器人,仅支持 delete)
String payloadV1 = requestBody.getAppKey() + "|" + requestBody.getTaskNo().trim() + "|" + requestBody.getTimestamp();
String expectedSignV1 = hmacSha256Hex(configuredSecret, payloadV1);
// v2签名: appKey|taskNo|action|timestamp (推荐,新功能 unlock 必须用 v2)
String payloadV2 = requestBody.getAppKey() + "|" + requestBody.getTaskNo().trim() + "|" + action + "|" + requestBody.getTimestamp();
String expectedSignV2 = hmacSha256Hex(configuredSecret, payloadV2);
boolean signOk;
if ("unlock".equals(action)) {
// unlock 必须包含 action 参与签名,防止篡改 action
signOk = secureEquals(expectedSignV2, sign);
} else {
// delete 兼容 v1/v2
signOk = secureEquals(expectedSignV1, sign) || secureEquals(expectedSignV2, sign);
}
if (!signOk) {
log.warn("开放删除接口签名不匹配: traceId={}, ip={}, taskNo={}",
traceId, clientIp, requestBody.getTaskNo());
return Result.error("签名校验失败");
}
Map<String, Object> actionResult;
if ("unlock".equals(action)) {
actionResult = taskUnlockService.emergencyUnlockToHallByTaskNo(requestBody.getTaskNo().trim());
} else {
taskService.deleteTaskByTaskNo(requestBody.getTaskNo().trim());
actionResult = new LinkedHashMap<>();
actionResult.put("deleted", true);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("taskNo", requestBody.getTaskNo().trim());
result.put("deleted", true);
result.put("action", action);
result.putAll(actionResult);
result.put("traceId", traceId);
result.put("time", Instant.now().toString());
log.info("开放删除接口执行成功: traceId={}, ip={}, taskNo={}", traceId, clientIp, requestBody.getTaskNo());
log.info("开放任务接口执行成功: traceId={}, ip={}, action={}, taskNo={}",
traceId, clientIp, action, requestBody.getTaskNo());
return Result.success(result);
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
@ -174,11 +203,26 @@ public class OpenTaskController {
return MessageDigest.isEqual(aa, bb);
}
private String normalizeAction(String action) {
String value = action == null ? "" : action.trim().toLowerCase(Locale.ROOT);
if (!StringUtils.hasText(value) || "delete".equals(value)) {
return "delete";
}
if ("unlock".equals(value)) {
return "unlock";
}
throw new IllegalArgumentException("action参数错误,仅支持 delete/unlock");
}
@Data
public static class OpenDeleteTaskRequest {
private String appKey;
private String taskNo;
private String timestamp;
private String sign;
/**
* delete(默认) / unlock(解锁排队任务到大厅)
*/
private String action;
}
}

View File

@ -41,6 +41,14 @@ public interface TaskUnlockStatusMapper extends BaseMapper<TaskUnlockStatus> {
"WHERE tus.dept_id = #{deptId} AND tus.is_unlocked = 1 AND ti.status = 'pending'")
int countUnlockedPendingTasks(@Param("deptId") Long deptId);
/**
* 获取指定部门排队中且当前待接单的任务数
*/
@Select("SELECT COUNT(*) FROM task_unlock_status tus " +
"INNER JOIN task_item ti ON tus.task_item_id = ti.id " +
"WHERE tus.dept_id = #{deptId} AND tus.is_unlocked = 0 AND ti.status = 'pending'")
int countQueuedPendingTasks(@Param("deptId") Long deptId);
/**
* 批量解锁任务
*/

View File

@ -43,6 +43,28 @@ public class AdminTaskService {
private final TaskTransferLogMapper taskTransferLogMapper;
private final TaskUnlockService taskUnlockService;
/**
* 应急解锁排队任务到大厅(支持主任务编号/子任务编号)
*/
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> emergencyUnlockQueuedTaskToHall(String taskNoOrItemNo, Long adminId, String reason) {
if (!isAdminUser(adminId)) {
throw new BusinessException(ResultCode.FORBIDDEN, "只有管理员可以解锁排队任务");
}
if (!StringUtils.hasText(taskNoOrItemNo)) {
throw new BusinessException(ResultCode.PARAM_ERROR, "任务编号不能为空");
}
Map<String, Object> unlockResult = taskUnlockService.emergencyUnlockToHall(taskNoOrItemNo.trim());
log.info("管理员应急解锁到大厅: adminId={}, key={}, reason={}, result={}",
adminId,
taskNoOrItemNo,
StringUtils.hasText(reason) ? reason.trim() : "",
unlockResult
);
return unlockResult;
}
/**
* 管理员分配任务
*/

View File

@ -48,6 +48,7 @@ public class TaskApprovalService {
private final ObjectMapper objectMapper;
private final DingTalkService dingTalkService;
private final com.designhub.util.WorkHoursCalculator workHoursCalculator;
private final TaskUnlockService taskUnlockService;
// ==================== 一级需求部门任务审批 ====================
@ -524,6 +525,15 @@ public class TaskApprovalService {
task.setCurrentApproverRole(null);
taskMapper.updateById(task);
if (task.getIsPipeline() != null && task.getIsPipeline() == 1 && finalReceiverId == null) {
List<TaskItem> pipelineItems = taskItemMapper.selectList(
new LambdaQueryWrapper<TaskItem>()
.eq(TaskItem::getTaskId, task.getId())
);
taskUnlockService.onTaskApproved(task, pipelineItems);
log.info("任务审核通过后已写入排队池: taskId={}, items={}", task.getId(), pipelineItems.size());
}
// 扣除部门积分
try {
teamScorePoolService.deductScore(task.getPublisherDeptId(), task.getScore() != null ? task.getScore().intValue() : 0);

View File

@ -31,6 +31,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
@RequiredArgsConstructor
public class TaskItemService {
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(TaskItemService.class);
private static final Long GRAPHIC_VISUAL_DEPT_ID = 109L;
private static final Long AI_VISUAL_DEPT_ID = 117L;
private final TaskItemMapper taskItemMapper;
private final TaskMapper taskMapper;
@ -279,6 +281,9 @@ public class TaskItemService {
if (userDeptId == null || targetDeptId == null) {
return false;
}
if (isSharedGrabDept(userDeptId, targetDeptId)) {
return true;
}
Long currentDeptId = userDeptId;
while (currentDeptId != null) {
if (targetDeptId.equals(currentDeptId)) {
@ -294,6 +299,27 @@ public class TaskItemService {
return false;
}
private boolean isSharedGrabDept(Long userDeptId, Long targetDeptId) {
return isDeptInTree(userDeptId, AI_VISUAL_DEPT_ID)
&& isDeptInTree(targetDeptId, GRAPHIC_VISUAL_DEPT_ID);
}
private boolean isDeptInTree(Long deptId, Long rootDeptId) {
Long currentDeptId = deptId;
while (currentDeptId != null) {
if (rootDeptId.equals(currentDeptId)) {
return true;
}
com.designhub.entity.Department currentDept = departmentMapper.selectById(currentDeptId);
if (currentDept == null || currentDept.getParentId() == null
|| currentDeptId.equals(currentDept.getParentId())) {
break;
}
currentDeptId = currentDept.getParentId();
}
return false;
}
/**
* 获取进行中的子任务列表
* @param receiverId 接单人ID,null表示不按接单人过滤
@ -940,6 +966,7 @@ public class TaskItemService {
nextItem.setPrevDownloadUrl(prevDownloadUrlStr);
nextItem.setUpdateTime(now);
taskItemMapper.updateById(nextItem);
markPipelineItemUnlocked(nextItem);
log.info("✅ 解锁预先指定的子任务: {} -> 设计师 {} (ID: {}),状态设为待接单",
nextItem.getItemNo(), nextItem.getReceiverName(), nextItem.getReceiverId());
@ -972,6 +999,7 @@ public class TaskItemService {
nextItem.setPrevDownloadUrl(prevDownloadUrlStr);
nextItem.setUpdateTime(now);
taskItemMapper.updateById(nextItem);
markPipelineItemUnlocked(nextItem);
log.info("✅ 解锁未指定的子任务: {} (部门序号: {}, ID: {}),待接单部门负责人审核",
nextItem.getItemNo(), nextDeptIndex, nextItem.getId());
@ -1998,6 +2026,18 @@ public class TaskItemService {
return null;
}
private void markPipelineItemUnlocked(TaskItem item) {
Long targetDeptId = item.getReceiverDeptId();
if (targetDeptId == null) {
targetDeptId = getTargetDeptIdFromTaskType(item.getTaskTypeId());
}
if (targetDeptId == null) {
log.warn("流水线子任务 {} 无法确定接单部门,跳过已解锁标记", item.getItemNo());
return;
}
taskUnlockService.onPipelineUnlock(item, targetDeptId);
}
/**
* 格式化工时显示(基于工作时间配置)
*/

View File

@ -1,6 +1,7 @@
package com.designhub.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.designhub.entity.*;
import com.designhub.mapper.*;
import lombok.extern.slf4j.Slf4j;
@ -172,6 +173,214 @@ public class TaskUnlockService {
return unlocked;
}
// ==================== 应急解锁(管理员/机器人调用) ====================
/**
* 应急解锁:将指定任务(主任务编号/子任务编号)从排队中直接解锁到大厅
* <p>
* - 如果传入的是子任务编号(itemNo),仅解锁该子任务(仅限 pending)
* - 如果传入的是主任务编号(taskNo),解锁该主任务下所有 pending 子任务(仅解锁排队中的)
* </p>
*/
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> emergencyUnlockToHall(String taskNoOrItemNo) {
String key = taskNoOrItemNo == null ? "" : taskNoOrItemNo.trim();
if (key.isEmpty()) {
throw new IllegalArgumentException("任务编号不能为空");
}
TaskItem item = taskItemMapper.selectOne(
new LambdaQueryWrapper<TaskItem>()
.eq(TaskItem::getItemNo, key)
.last("LIMIT 1")
);
if (item != null) {
return emergencyUnlockTaskItemToHall(item);
}
Task task = taskMapper.selectOne(
new LambdaQueryWrapper<Task>()
.eq(Task::getTaskNo, key)
.last("LIMIT 1")
);
if (task != null) {
return emergencyUnlockTaskToHall(task);
}
throw new IllegalArgumentException("任务不存在,请检查编号是否正确");
}
/**
* 应急解锁:按主任务编号解锁
*/
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> emergencyUnlockToHallByTaskNo(String taskNo) {
String key = taskNo == null ? "" : taskNo.trim();
if (key.isEmpty()) {
throw new IllegalArgumentException("任务编号不能为空");
}
Task task = taskMapper.selectOne(
new LambdaQueryWrapper<Task>()
.eq(Task::getTaskNo, key)
.last("LIMIT 1")
);
if (task == null) {
throw new IllegalArgumentException("任务不存在,请检查任务编号是否正确");
}
return emergencyUnlockTaskToHall(task);
}
private Map<String, Object> emergencyUnlockTaskItemToHall(TaskItem item) {
if (item.getTaskId() == null) {
throw new IllegalArgumentException("子任务缺少主任务ID,无法解锁");
}
Task task = taskMapper.selectById(item.getTaskId());
if (task == null) {
throw new IllegalArgumentException("主任务不存在,无法解锁");
}
if ("assigned".equals(task.getReleaseType())) {
Map<String, Object> result = new HashMap<>();
result.put("type", "item");
result.put("taskNo", task.getTaskNo());
result.put("itemNo", item.getItemNo());
result.put("taskItemId", item.getId());
result.put("unlockedItemCount", 0);
result.put("message", "指定任务不受排队限制,无需解锁");
return result;
}
if (!"pending".equals(item.getStatus())) {
throw new IllegalArgumentException("子任务状态不是待接单,无法解锁到大厅");
}
List<TaskUnlockStatus> queuedRows = unlockStatusMapper.selectList(
new LambdaQueryWrapper<TaskUnlockStatus>()
.eq(TaskUnlockStatus::getTaskItemId, item.getId())
.eq(TaskUnlockStatus::getIsUnlocked, 0)
);
if (queuedRows == null || queuedRows.isEmpty()) {
Map<String, Object> result = new HashMap<>();
result.put("type", "item");
result.put("taskNo", task.getTaskNo());
result.put("itemNo", item.getItemNo());
result.put("taskItemId", item.getId());
result.put("unlockedItemCount", 0);
result.put("message", "该子任务不在排队中或已解锁");
return result;
}
LocalDateTime now = LocalDateTime.now();
int affectedRows = unlockStatusMapper.update(
null,
new LambdaUpdateWrapper<TaskUnlockStatus>()
.eq(TaskUnlockStatus::getTaskItemId, item.getId())
.eq(TaskUnlockStatus::getIsUnlocked, 0)
.set(TaskUnlockStatus::getIsUnlocked, 1)
.set(TaskUnlockStatus::getUnlockTime, now)
.set(TaskUnlockStatus::getUpdateTime, now)
);
try {
notifyUnlockedTasks(Collections.singletonList(item.getId()));
} catch (Exception e) {
log.error("发送应急解锁通知失败: itemId={}, error={}", item.getId(), e.getMessage());
}
Map<String, Object> result = new HashMap<>();
result.put("type", "item");
result.put("taskNo", task.getTaskNo());
result.put("itemNo", item.getItemNo());
result.put("taskItemId", item.getId());
result.put("deptId", queuedRows.get(0).getDeptId());
result.put("unlockedItemCount", affectedRows > 0 ? 1 : 0);
result.put("affectedRows", affectedRows);
result.put("message", affectedRows > 0 ? "已解锁到大厅" : "解锁失败或已被解锁");
return result;
}
private Map<String, Object> emergencyUnlockTaskToHall(Task task) {
if ("assigned".equals(task.getReleaseType())) {
Map<String, Object> result = new HashMap<>();
result.put("type", "task");
result.put("taskNo", task.getTaskNo());
result.put("taskId", task.getId());
result.put("unlockedItemCount", 0);
result.put("message", "指定任务不受排队限制,无需解锁");
return result;
}
List<TaskItem> pendingItems = taskItemMapper.selectList(
new LambdaQueryWrapper<TaskItem>()
.eq(TaskItem::getTaskId, task.getId())
.eq(TaskItem::getStatus, "pending")
);
Map<String, Object> result = new HashMap<>();
result.put("type", "task");
result.put("taskNo", task.getTaskNo());
result.put("taskId", task.getId());
result.put("pendingItemCount", pendingItems == null ? 0 : pendingItems.size());
if (pendingItems == null || pendingItems.isEmpty()) {
result.put("unlockedItemCount", 0);
result.put("message", "主任务下没有待接单子任务,无需解锁");
return result;
}
List<Long> itemIds = pendingItems.stream()
.map(TaskItem::getId)
.filter(Objects::nonNull)
.toList();
if (itemIds.isEmpty()) {
result.put("unlockedItemCount", 0);
result.put("message", "子任务ID为空,无法解锁");
return result;
}
List<TaskUnlockStatus> queuedRows = unlockStatusMapper.selectList(
new LambdaQueryWrapper<TaskUnlockStatus>()
.in(TaskUnlockStatus::getTaskItemId, itemIds)
.eq(TaskUnlockStatus::getIsUnlocked, 0)
);
if (queuedRows == null || queuedRows.isEmpty()) {
result.put("unlockedItemCount", 0);
result.put("message", "该主任务下没有排队中的待接单子任务");
return result;
}
List<Long> queuedItemIds = queuedRows.stream()
.map(TaskUnlockStatus::getTaskItemId)
.filter(Objects::nonNull)
.distinct()
.toList();
LocalDateTime now = LocalDateTime.now();
int affectedRows = unlockStatusMapper.update(
null,
new LambdaUpdateWrapper<TaskUnlockStatus>()
.in(TaskUnlockStatus::getTaskItemId, queuedItemIds)
.eq(TaskUnlockStatus::getIsUnlocked, 0)
.set(TaskUnlockStatus::getIsUnlocked, 1)
.set(TaskUnlockStatus::getUnlockTime, now)
.set(TaskUnlockStatus::getUpdateTime, now)
);
try {
notifyUnlockedTasks(queuedItemIds);
} catch (Exception e) {
log.error("发送应急解锁通知失败: taskNo={}, error={}", task.getTaskNo(), e.getMessage());
}
result.put("unlockedItemCount", queuedItemIds.size());
result.put("affectedRows", affectedRows);
result.put("message", queuedItemIds.isEmpty() ? "无可解锁子任务" : "已解锁到大厅");
return result;
}
/**
* 任务被抢单后,检查并补充解锁
*/
@ -485,20 +694,6 @@ public class TaskUnlockService {
public Map<String, Object> getUnlockStats() {
Map<String, Object> stats = new HashMap<>();
// 总待解锁数
long totalQueued = unlockStatusMapper.selectCount(
new LambdaQueryWrapper<TaskUnlockStatus>()
.eq(TaskUnlockStatus::getIsUnlocked, 0)
);
stats.put("totalQueued", totalQueued);
// 总已解锁数
long totalUnlocked = unlockStatusMapper.selectCount(
new LambdaQueryWrapper<TaskUnlockStatus>()
.eq(TaskUnlockStatus::getIsUnlocked, 1)
);
stats.put("totalUnlocked", totalUnlocked);
// 按部门统计
List<Department> depts = departmentMapper.selectList(
new LambdaQueryWrapper<Department>()
@ -507,14 +702,23 @@ public class TaskUnlockService {
);
List<Map<String, Object>> deptStats = new ArrayList<>();
long totalQueued = 0;
long totalUnlocked = 0;
for (Department dept : depts) {
Map<String, Object> deptStat = new HashMap<>();
int queuedCount = unlockStatusMapper.countQueuedPendingTasks(dept.getId());
int unlockedPendingCount = unlockStatusMapper.countUnlockedPendingTasks(dept.getId());
deptStat.put("deptId", dept.getId());
deptStat.put("deptName", dept.getDeptName());
deptStat.put("queuedCount", unlockStatusMapper.countQueuedTasks(dept.getId()));
deptStat.put("unlockedPendingCount", unlockStatusMapper.countUnlockedPendingTasks(dept.getId()));
deptStat.put("queuedCount", queuedCount);
deptStat.put("unlockedPendingCount", unlockedPendingCount);
deptStats.add(deptStat);
totalQueued += queuedCount;
totalUnlocked += unlockedPendingCount;
}
stats.put("totalQueued", totalQueued);
stats.put("totalUnlocked", totalUnlocked);
stats.put("deptStats", deptStats);
return stats;

View File

@ -2006,6 +2006,64 @@
style="margin-top: 16px;"
row-key="deptId"
/>
<!-- 应急解锁 -->
<a-divider orientation="left" style="margin-top: 24px;">应急解锁</a-divider>
<a-card title="应急解锁到大厅(管理员)" class="task-delete-card">
<a-alert
type="warning"
show-icon
message="适用场景"
description="当任务处于排队中但需要紧急处理时,可手动解锁到任务大厅,立即变为可抢单状态(仅影响待接单节点)。"
style="margin-bottom: 16px;"
/>
<a-form layout="vertical">
<a-row :gutter="16">
<a-col :xs="24" :md="10">
<a-form-item label="任务编号 / 子任务编号">
<a-input
v-model:value="taskUnlockManualForm.taskNoOrItemNo"
placeholder="例如:TASK202603041636485827 或 TASK202603041636485827-03-01"
size="large"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="10">
<a-form-item label="解锁原因(可选)">
<a-input
v-model:value="taskUnlockManualForm.reason"
maxlength="200"
show-count
placeholder="例如:紧急插单,先处理此任务"
size="large"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="4">
<a-form-item label="操作">
<a-popconfirm
title="确认解锁到大厅?"
description="将把排队中的待接单节点直接解锁为可抢单状态"
ok-text="确认解锁"
cancel-text="取消"
@confirm="handleEmergencyUnlockToHall"
>
<a-button
type="primary"
block
size="large"
:loading="unlockingQueuedTask"
:disabled="!taskUnlockManualForm.taskNoOrItemNo"
>
解锁到大厅
</a-button>
</a-popconfirm>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-card>
</div>
</a-tab-pane>
@ -5787,6 +5845,11 @@ const taskUnlockConfig = reactive({
const savingTaskUnlockConfig = ref(false)
const loadingTaskUnlockStats = ref(false)
const taskUnlockStats = ref<any>(null)
const taskUnlockManualForm = reactive({
taskNoOrItemNo: '',
reason: ''
})
const unlockingQueuedTask = ref(false)
// 任务排队统计表格列
const taskUnlockStatsColumns = [
@ -5855,6 +5918,48 @@ const loadTaskUnlockStats = async () => {
}
}
// 应急解锁到大厅
const handleEmergencyUnlockToHall = async () => {
const key = taskUnlockManualForm.taskNoOrItemNo?.trim()
if (!key) {
message.warning('请输入任务编号或子任务编号')
return
}
unlockingQueuedTask.value = true
try {
const response = await fetch('/api/admin/tasks/unlock/manual', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
taskNoOrItemNo: key,
reason: taskUnlockManualForm.reason?.trim() || ''
})
})
const result = await response.json()
if (result.code === 200) {
const data = result.data || {}
const unlockedCount = data.unlockedItemCount ?? 0
message.success(data.message || `已解锁到大厅(解锁节点数:${unlockedCount})`)
taskUnlockManualForm.taskNoOrItemNo = ''
taskUnlockManualForm.reason = ''
// 刷新统计
await loadTaskUnlockStats()
} else {
message.error(result.message || '解锁失败')
}
} catch (error: any) {
console.error('应急解锁失败:', error)
message.error(error.message || '解锁失败')
} finally {
unlockingQueuedTask.value = false
}
}
// 加载延误扣分配置
const loadDelayPenaltyConfig = async () => {
try {