feat: 任务大厅增加一键导出功能,仅部门负责人可用

This commit is contained in:
Your Name 2026-01-19 16:28:24 +08:00
parent 60fcfbb9ce
commit b0898656d8
6 changed files with 458 additions and 32 deletions

Binary file not shown.

View File

@ -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")

View File

@ -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);
}
}
}

View File

@ -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<Task> 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<Task> tasks = taskMapper.selectList(queryWrapper);
log.info("查询到 {} 条任务", tasks.size());
// 预加载关联数据
Map<Long, String> taskTypeMap = loadTaskTypeMap();
Map<Long, String> demandTypeMap = loadDemandTypeMap();
Map<Long, String> brandMap = loadBrandMap();
Map<Long, String> categoryMap = loadCategoryMap();
Map<Long, String> platformMap = loadPlatformMap();
Map<Long, String> userMap = loadUserMap();
Map<Long, String> deptMap = loadDeptMap();
// 转换为导出数据
List<TaskHallExportVO> 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<Long, String> loadTaskTypeMap() {
Map<Long, String> 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<Long, String> loadDemandTypeMap() {
Map<Long, String> 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<Long, String> loadBrandMap() {
Map<Long, String> 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<Long, String> loadCategoryMap() {
Map<Long, String> 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<Long, String> loadPlatformMap() {
Map<Long, String> 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<Long, String> loadUserMap() {
Map<Long, String> 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<Long, String> loadDeptMap() {
Map<Long, String> 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;
}
/**
* 导出任务列表
*/

View File

@ -679,3 +679,69 @@ export const confirmTaskProductFilled = (taskId: number) => {
export const deleteTaskByTaskNo = (taskNo: string) => {
return request.delete<any>(`/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)
}

View File

@ -36,6 +36,17 @@
<Icon name="plus" size="xs" style="margin-right: 4px;" />
发起任务
</a-button>
<a-button
v-if="canExportTaskHall"
type="default"
size="middle"
@click="handleExportTaskHall"
:loading="exportLoading"
style="margin-left: 8px;"
>
<Icon name="download" size="xs" style="margin-right: 4px;" />
一键导出
</a-button>
</div>
</div>
<div class="stats-grid">
@ -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<number | undefined>(undefined) // 当前选择的部
const userDeptId = ref<number | null>(null)
const userDeptName = ref<string>('')
const userDeptType = ref<string>('')
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
}
}
// 加载进行中任务
// - 需求部门用户:只看母任务卡片(自己发起的任务)
// - 接单部门用户:只看子任务卡片(自己接单的任务)