diff --git a/backend-src.tar.gz b/backend-src.tar.gz index 0b20e25..92988cd 100644 Binary files a/backend-src.tar.gz and b/backend-src.tar.gz differ diff --git a/backend/src/main/java/com/designhub/controller/ExportController.java b/backend/src/main/java/com/designhub/controller/ExportController.java index 5f3610c..e790475 100644 --- a/backend/src/main/java/com/designhub/controller/ExportController.java +++ b/backend/src/main/java/com/designhub/controller/ExportController.java @@ -1,10 +1,19 @@ package com.designhub.controller; +import com.designhub.common.ResultCode; +import com.designhub.entity.User; +import com.designhub.exception.BusinessException; +import com.designhub.service.DepartmentService; import com.designhub.service.ExportService; +import com.designhub.service.UserService; +import com.designhub.util.JwtUtil; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; import java.io.IOException; @@ -16,9 +25,51 @@ import java.io.IOException; @RestController @RequestMapping("/api/export") @RequiredArgsConstructor +@Slf4j public class ExportController { private final ExportService exportService; + private final UserService userService; + private final DepartmentService departmentService; + private final JwtUtil jwtUtil; + + @Operation(summary = "导出任务大厅任务明细", description = "只有部门负责人或管理员可以导出") + @GetMapping("/task-hall") + public void exportTaskHall( + HttpServletRequest request, + HttpServletResponse response, + @Parameter(description = "任务状态") @RequestParam(required = false) String status, + @Parameter(description = "发布部门ID") @RequestParam(required = false) Long publisherDeptId, + @Parameter(description = "接收部门ID") @RequestParam(required = false) Long receiverDeptId, + @Parameter(description = "品牌ID") @RequestParam(required = false) Long brandId, + @Parameter(description = "类目ID") @RequestParam(required = false) Long categoryId, + @Parameter(description = "平台ID") @RequestParam(required = false) Long platformId + ) throws IOException { + // 获取当前用户 + String token = request.getHeader("Authorization"); + if (token != null && token.startsWith("Bearer ")) { + token = token.substring(7); + } + Long userId = jwtUtil.getUserIdFromToken(token); + User user = userService.getUserById(userId); + + if (user == null) { + throw new BusinessException(ResultCode.UNAUTHORIZED, "用户未登录"); + } + + // 检查权限:只有部门负责人或管理员可以导出 + boolean isAdmin = user.getIsAdmin() != null && user.getIsAdmin() == 1; + boolean isDeptLeader = departmentService.isUserDepartmentLeader(userId); + + if (!isAdmin && !isDeptLeader) { + throw new BusinessException(ResultCode.FORBIDDEN, "只有部门负责人或管理员可以导出任务明细"); + } + + log.info("用户 {} ({}) 导出任务大厅数据, isAdmin={}, isDeptLeader={}", + user.getRealName(), userId, isAdmin, isDeptLeader); + + exportService.exportTaskHall(response, status, publisherDeptId, receiverDeptId, brandId, categoryId, platformId); + } @Operation(summary = "导出任务列表") @GetMapping("/tasks") diff --git a/backend/src/main/java/com/designhub/service/AnalyticsService.java b/backend/src/main/java/com/designhub/service/AnalyticsService.java index 2bd0d6c..8c0f3b9 100644 --- a/backend/src/main/java/com/designhub/service/AnalyticsService.java +++ b/backend/src/main/java/com/designhub/service/AnalyticsService.java @@ -813,13 +813,16 @@ public class AnalyticsService { .isNotNull(TaskItem::getCreateTime) ); - // 处理已完成的子任务的延误扣分 + // 处理已完成的子任务的延误扣分(与getDesignerDeductionDetails保持一致,使用deadline或claimTime) for (TaskItem item : completedTaskItems) { - if (item.getEstimatedHours() != null && item.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0 - && item.getCreateTime() != null && item.getCompleteTime() != null) { + if (item.getCompleteTime() != null) { - // 计算子任务的预期deadline:createTime + estimatedHours(使用工作时间计算) - LocalDateTime expectedDeadline = workHoursCalculator.calculateDeadline(item.getCreateTime(), item.getEstimatedHours()); + // 优先使用数据库中已存储的deadline,如果没有则根据claimTime计算 + LocalDateTime expectedDeadline = item.getDeadline(); + if (expectedDeadline == null && item.getClaimTime() != null && item.getEstimatedHours() != null + && item.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0) { + expectedDeadline = workHoursCalculator.calculateDeadline(item.getClaimTime(), item.getEstimatedHours()); + } // 如果完成时间超过预期deadline,计算延误扣分 if (expectedDeadline != null && item.getCompleteTime().isAfter(expectedDeadline)) { @@ -848,36 +851,36 @@ public class AnalyticsService { } } - // 处理进行中的子任务的延误扣分 + // 处理进行中的子任务的延误扣分(与getDesignerDeductionDetails保持一致,使用deadline或claimTime) for (TaskItem item : inProgressTaskItems) { - if (item.getEstimatedHours() != null && item.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0 - && item.getCreateTime() != null) { + // 优先使用数据库中已存储的deadline,如果没有则根据claimTime计算 + LocalDateTime expectedDeadline = item.getDeadline(); + if (expectedDeadline == null && item.getClaimTime() != null && item.getEstimatedHours() != null + && item.getEstimatedHours().compareTo(BigDecimal.ZERO) > 0) { + expectedDeadline = workHoursCalculator.calculateDeadline(item.getClaimTime(), item.getEstimatedHours()); + } + + // 如果当前时间超过预期deadline,计算延误扣分 + if (expectedDeadline != null && now.isAfter(expectedDeadline)) { + BigDecimal itemScore = item.getTotalScore() != null ? item.getTotalScore() : BigDecimal.ZERO; - // 计算子任务的预期deadline:createTime + estimatedHours(使用工作时间计算) - LocalDateTime expectedDeadline = workHoursCalculator.calculateDeadline(item.getCreateTime(), item.getEstimatedHours()); - - // 如果当前时间超过预期deadline,计算延误扣分 - if (expectedDeadline != null && now.isAfter(expectedDeadline)) { - BigDecimal itemScore = item.getTotalScore() != null ? item.getTotalScore() : BigDecimal.ZERO; + if (itemScore.compareTo(BigDecimal.ZERO) > 0) { + // 计算延误小时数(只计算工作时间段内的延误) + BigDecimal overdueHours = workHoursCalculator.calculateDelayHours(expectedDeadline, now); - if (itemScore.compareTo(BigDecimal.ZERO) > 0) { - // 计算延误小时数(只计算工作时间段内的延误) - BigDecimal overdueHours = workHoursCalculator.calculateDelayHours(expectedDeadline, now); + if (overdueHours.compareTo(BigDecimal.ZERO) > 0) { + // 计算扣分 + BigDecimal itemDeduction = itemScore + .multiply(overdueHours) + .multiply(deductionRate) + .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); - if (overdueHours.compareTo(BigDecimal.ZERO) > 0) { - // 计算扣分 - BigDecimal itemDeduction = itemScore - .multiply(overdueHours) - .multiply(deductionRate) - .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); - - // 扣分不能超过任务积分 - if (itemDeduction.compareTo(itemScore) > 0) { - itemDeduction = itemScore; - } - - deductionScore = deductionScore.add(itemDeduction); + // 扣分不能超过任务积分 + if (itemDeduction.compareTo(itemScore) > 0) { + itemDeduction = itemScore; } + + deductionScore = deductionScore.add(itemDeduction); } } } diff --git a/backend/src/main/java/com/designhub/service/ExportService.java b/backend/src/main/java/com/designhub/service/ExportService.java index cdffec1..0d650d2 100644 --- a/backend/src/main/java/com/designhub/service/ExportService.java +++ b/backend/src/main/java/com/designhub/service/ExportService.java @@ -6,28 +6,268 @@ import com.designhub.mapper.*; import com.designhub.util.ExcelExportUtil; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.io.IOException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; -import java.util.stream.Collectors; +import java.util.Map; /** * 数据导出Service */ @Service @RequiredArgsConstructor +@Slf4j public class ExportService { private final TaskMapper taskMapper; private final UserMapper userMapper; private final DepartmentMapper departmentMapper; private final DesignerService designerService; + private final DemandTypeMapper demandTypeMapper; + private final BrandMapper brandMapper; + private final CategoryMapper categoryMapper; + private final PlatformMapper platformMapper; + private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + /** + * 导出任务大厅数据 + */ + public void exportTaskHall(HttpServletResponse response, String status, Long publisherDeptId, + Long receiverDeptId, Long brandId, Long categoryId, Long platformId) throws IOException { + log.info("开始导出任务大厅数据: status={}, publisherDeptId={}, receiverDeptId={}, brandId={}, categoryId={}, platformId={}", + status, publisherDeptId, receiverDeptId, brandId, categoryId, platformId); + + // 构建查询条件 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(Task::getDeleted, 0); + queryWrapper.eq(Task::getApprovalStatus, "approved"); // 只导出已审批的任务 + + if (status != null && !status.isEmpty()) { + queryWrapper.eq(Task::getStatus, status); + } else { + // 默认导出待接单任务 + queryWrapper.eq(Task::getStatus, "pending"); + } + + if (publisherDeptId != null) { + queryWrapper.eq(Task::getPublisherDeptId, publisherDeptId); + } + if (receiverDeptId != null) { + queryWrapper.eq(Task::getReceiverDeptId, receiverDeptId); + } + if (brandId != null) { + queryWrapper.eq(Task::getBrandId, brandId); + } + if (categoryId != null) { + queryWrapper.eq(Task::getCategoryId, categoryId); + } + if (platformId != null) { + queryWrapper.eq(Task::getPlatformId, platformId); + } + + queryWrapper.orderByDesc(Task::getCreateTime); + + List tasks = taskMapper.selectList(queryWrapper); + log.info("查询到 {} 条任务", tasks.size()); + + // 预加载关联数据 + Map taskTypeMap = loadTaskTypeMap(); + Map demandTypeMap = loadDemandTypeMap(); + Map brandMap = loadBrandMap(); + Map categoryMap = loadCategoryMap(); + Map platformMap = loadPlatformMap(); + Map userMap = loadUserMap(); + Map deptMap = loadDeptMap(); + + // 转换为导出数据 + List exportList = new ArrayList<>(); + for (Task task : tasks) { + TaskHallExportVO vo = new TaskHallExportVO(); + vo.setTaskNo(task.getTaskNo()); + vo.setTitle(task.getTitle()); + vo.setTaskType(taskTypeMap.getOrDefault(task.getTaskTypeId(), "")); + vo.setDemandType(demandTypeMap.getOrDefault(task.getDemandTypeId(), "")); + vo.setBrand(brandMap.getOrDefault(task.getBrandId(), "")); + vo.setCategory(categoryMap.getOrDefault(task.getCategoryId(), "")); + vo.setPlatform(platformMap.getOrDefault(task.getPlatformId(), "")); + vo.setSpuInfo(task.getSpuInfo()); + vo.setScore(task.getScore() != null ? task.getScore().toString() : "0"); + vo.setEstimatedHours(task.getEstimatedHours() != null ? task.getEstimatedHours().toString() : "0"); + vo.setQuantity(task.getQuantity() != null ? task.getQuantity() : 1); + vo.setPublisher(userMap.getOrDefault(task.getPublisherId(), "")); + vo.setPublisherDept(deptMap.getOrDefault(task.getPublisherDeptId(), "")); + vo.setReceiver(userMap.getOrDefault(task.getReceiverId(), "")); + vo.setReceiverDept(deptMap.getOrDefault(task.getReceiverDeptId(), "")); + vo.setStatus(getStatusText(task.getStatus())); + vo.setReleaseType(getReleaseTypeText(task.getReleaseType())); + vo.setIsUrgent(task.getIsUrgent() != null && task.getIsUrgent() == 1 ? "是" : "否"); + vo.setIsPipeline(task.getIsPipeline() != null && task.getIsPipeline() == 1 ? "是" : "否"); + vo.setCreateTime(task.getCreateTime() != null ? task.getCreateTime().format(DATE_FORMATTER) : ""); + vo.setDeadline(task.getDeadline() != null ? task.getDeadline().format(DATE_FORMATTER) : ""); + exportList.add(vo); + } + + // 定义表头 + String[] headers = { + "任务编号", "任务标题", "任务类型", "需求类型", "品牌", "类目", "平台", + "SPU信息", "任务积分", "预估工时(小时)", "数量", "发布人", "发布部门", + "接收人", "接收部门", "状态", "发布类型", "是否紧急", "是否流水线", + "创建时间", "截止时间" + }; + + // 定义字段名 + String[] fieldNames = { + "taskNo", "title", "taskType", "demandType", "brand", "category", "platform", + "spuInfo", "score", "estimatedHours", "quantity", "publisher", "publisherDept", + "receiver", "receiverDept", "status", "releaseType", "isUrgent", "isPipeline", + "createTime", "deadline" + }; + + // 生成文件名 + String fileName = "任务大厅数据_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + + // 导出Excel + ExcelExportUtil.exportExcel(response, fileName, "任务明细", headers, fieldNames, exportList); + log.info("任务大厅数据导出完成,共 {} 条", exportList.size()); + } + + /** + * 获取状态文本 + */ + private String getStatusText(String status) { + if (status == null) return ""; + return switch (status) { + case "pending" -> "待接单"; + case "in_progress" -> "进行中"; + case "completed" -> "已完成"; + case "rejected" -> "已驳回"; + case "cancelled" -> "已取消"; + default -> status; + }; + } + + /** + * 获取发布类型文本 + */ + private String getReleaseTypeText(String releaseType) { + if (releaseType == null) return ""; + return switch (releaseType) { + case "recruitment" -> "招募"; + case "assigned" -> "指派"; + case "default" -> "默认"; + default -> releaseType; + }; + } + + // 加载关联数据的辅助方法 + private Map loadTaskTypeMap() { + Map map = new HashMap<>(); + try { + jdbcTemplate.query("SELECT id, type_name FROM task_type", rs -> { + map.put(rs.getLong("id"), rs.getString("type_name")); + }); + } catch (Exception e) { + log.warn("加载任务类型失败: {}", e.getMessage()); + } + return map; + } + + private Map loadDemandTypeMap() { + Map map = new HashMap<>(); + try { + demandTypeMapper.selectList(null).forEach(t -> map.put(t.getId(), t.getName())); + } catch (Exception e) { + log.warn("加载需求类型失败: {}", e.getMessage()); + } + return map; + } + + private Map loadBrandMap() { + Map map = new HashMap<>(); + try { + brandMapper.selectList(null).forEach(t -> map.put(t.getId(), t.getName())); + } catch (Exception e) { + log.warn("加载品牌失败: {}", e.getMessage()); + } + return map; + } + + private Map loadCategoryMap() { + Map map = new HashMap<>(); + try { + categoryMapper.selectList(null).forEach(t -> map.put(t.getId(), t.getName())); + } catch (Exception e) { + log.warn("加载类目失败: {}", e.getMessage()); + } + return map; + } + + private Map loadPlatformMap() { + Map map = new HashMap<>(); + try { + platformMapper.selectList(null).forEach(t -> map.put(t.getId(), t.getName())); + } catch (Exception e) { + log.warn("加载平台失败: {}", e.getMessage()); + } + return map; + } + + private Map loadUserMap() { + Map map = new HashMap<>(); + try { + userMapper.selectList(null).forEach(t -> map.put(t.getId(), t.getRealName() != null ? t.getRealName() : t.getUsername())); + } catch (Exception e) { + log.warn("加载用户失败: {}", e.getMessage()); + } + return map; + } + + private Map loadDeptMap() { + Map map = new HashMap<>(); + try { + departmentMapper.selectList(null).forEach(t -> map.put(t.getId(), t.getDeptName())); + } catch (Exception e) { + log.warn("加载部门失败: {}", e.getMessage()); + } + return map; + } + + /** + * 任务大厅导出VO + */ + @lombok.Data + public static class TaskHallExportVO { + private String taskNo; + private String title; + private String taskType; + private String demandType; + private String brand; + private String category; + private String platform; + private String spuInfo; + private String score; + private String estimatedHours; + private Integer quantity; + private String publisher; + private String publisherDept; + private String receiver; + private String receiverDept; + private String status; + private String releaseType; + private String isUrgent; + private String isPipeline; + private String createTime; + private String deadline; + } + /** * 导出任务列表 */ diff --git a/frontend/src/api/task.ts b/frontend/src/api/task.ts index a51086d..a5ebe86 100644 --- a/frontend/src/api/task.ts +++ b/frontend/src/api/task.ts @@ -679,3 +679,69 @@ export const confirmTaskProductFilled = (taskId: number) => { export const deleteTaskByTaskNo = (taskNo: string) => { return request.delete(`/tasks/by-task-no/${taskNo}`) } + +// ==================== 导出功能 ==================== + +/** + * 导出任务大厅数据(仅部门负责人和管理员) + */ +export interface ExportTaskHallParams { + status?: string + publisherDeptId?: number + receiverDeptId?: number + brandId?: number + categoryId?: number + platformId?: number +} + +export const exportTaskHall = async (params: ExportTaskHallParams = {}) => { + const queryParams = new URLSearchParams() + if (params.status) queryParams.append('status', params.status) + if (params.publisherDeptId) queryParams.append('publisherDeptId', params.publisherDeptId.toString()) + if (params.receiverDeptId) queryParams.append('receiverDeptId', params.receiverDeptId.toString()) + if (params.brandId) queryParams.append('brandId', params.brandId.toString()) + if (params.categoryId) queryParams.append('categoryId', params.categoryId.toString()) + if (params.platformId) queryParams.append('platformId', params.platformId.toString()) + + const url = `/export/task-hall${queryParams.toString() ? '?' + queryParams.toString() : ''}` + + // 使用 fetch 直接下载文件 + const token = localStorage.getItem('token') + const response = await fetch(`/api${url}`, { + method: 'GET', + headers: { + 'Authorization': token ? `Bearer ${token}` : '' + } + }) + + if (!response.ok) { + const errorText = await response.text() + try { + const errorJson = JSON.parse(errorText) + throw new Error(errorJson.message || '导出失败') + } catch { + throw new Error('导出失败') + } + } + + // 获取文件名 + const contentDisposition = response.headers.get('Content-Disposition') + let fileName = '任务大厅数据.xlsx' + if (contentDisposition) { + const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) + if (fileNameMatch && fileNameMatch[1]) { + fileName = decodeURIComponent(fileNameMatch[1].replace(/['"]/g, '')) + } + } + + // 下载文件 + const blob = await response.blob() + const downloadUrl = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = downloadUrl + link.download = fileName + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(downloadUrl) +} diff --git a/frontend/src/views/TaskHall.vue b/frontend/src/views/TaskHall.vue index 9886ee1..91a2bba 100644 --- a/frontend/src/views/TaskHall.vue +++ b/frontend/src/views/TaskHall.vue @@ -36,6 +36,17 @@ 发起任务 + + + 一键导出 +
@@ -2210,7 +2221,8 @@ import * as configApi from '@/api/config' import * as authApi from '@/api/auth' import * as departmentApi from '@/api/department' import { getWorkHoursConfig } from '@/api/system' -import type { Task, TaskApprovalDTO, TaskProduct } from '@/api/task' +import type { Task, TaskApprovalDTO, TaskProduct, ExportTaskHallParams } from '@/api/task' +import { exportTaskHall } from '@/api/task' import type { DesignerWithWorkload } from '@/api/user' import { useConfigStore } from '@/stores/config' import dayjs from 'dayjs' @@ -2271,6 +2283,7 @@ const currentDeptView = ref(undefined) // 当前选择的部 const userDeptId = ref(null) const userDeptName = ref('') const userDeptType = ref('') +const exportLoading = ref(false) // 导出加载状态 // 获取当前有效的部门类型(根据选择的视角) const effectiveDeptType = computed(() => { @@ -2286,6 +2299,29 @@ const effectiveDeptId = computed(() => { return currentDeptView.value !== undefined ? currentDeptView.value : userDeptId.value }) +// 计算是否有导出权限(部门负责人或管理员) +const canExportTaskHall = computed(() => { + const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}') + if (!userInfo || !userInfo.id) return false + + // 管理员可以导出 + if (userInfo.isAdmin === 1 || userInfo.isAdmin === true) { + return true + } + + // 部门负责人可以导出 + if (userInfo.isDepartmentLeader === 1 || userInfo.isDepartmentLeader === true) { + return true + } + + // 管理多个部门的用户也是部门负责人 + if (managedDepartments.value.length > 0) { + return true + } + + return false +}) + // 初始化部门视角数据 const initDeptViewData = async () => { try { @@ -4245,6 +4281,36 @@ const handleCreateTask = () => { router.push('/tasks/create') } +// 一键导出任务大厅数据 +const handleExportTaskHall = async () => { + if (!canExportTaskHall.value) { + message.warning('您没有导出权限,只有部门负责人或管理员可以导出') + return + } + + try { + exportLoading.value = true + + // 构建导出参数(使用当前筛选条件) + const params: ExportTaskHallParams = { + status: filters.value.status || 'pending', + publisherDeptId: filters.value.publisherDeptId, + receiverDeptId: filters.value.receiverDeptId, + brandId: filters.value.brandId, + categoryId: filters.value.categoryId, + platformId: filters.value.platformId + } + + await exportTaskHall(params) + message.success('导出成功') + } catch (error: any) { + console.error('导出失败:', error) + message.error(error.message || '导出失败') + } finally { + exportLoading.value = false + } +} + // 加载进行中任务 // - 需求部门用户:只看母任务卡片(自己发起的任务) // - 接单部门用户:只看子任务卡片(自己接单的任务)