优化
This commit is contained in:
parent
db10401b13
commit
52fd2e7560
@ -524,7 +524,7 @@ public class AuditService {
|
||||
|
||||
if (task.getNode() == AuditNode.INIT_REVIEW) {
|
||||
// 初审拒绝:整体审核结束,会议审核状态置为已拒绝
|
||||
meetingService.updateAuditStatus(meetingId, MeetingAuditStatus.REJECTED);
|
||||
meetingService.updateAuditStatus(meetingId, MeetingAuditStatus.REJECTED, request.getOpinion());
|
||||
triggerAuditNotification(task, request.getOpinion(), "AUDIT_REJECTED");
|
||||
} else {
|
||||
// 复审 / 终审拒绝:整体回到初审阶段,重新创建初审任务
|
||||
@ -539,7 +539,7 @@ public class AuditService {
|
||||
assigneeUserId = auditFlowConfigService.resolveAssigneeUserId(tenantId, firstNode);
|
||||
}
|
||||
|
||||
meetingService.updateAuditStatus(meetingId, MeetingAuditStatus.IN_REVIEW);
|
||||
meetingService.updateAuditStatus(meetingId, MeetingAuditStatus.IN_REVIEW, request.getOpinion());
|
||||
meetingService.updateCurrentAuditNode(meetingId, firstNode.name(), assigneeUserId);
|
||||
AuditTask resetTask = new AuditTask(
|
||||
null,
|
||||
@ -566,7 +566,7 @@ public class AuditService {
|
||||
task.setStatus(AuditTaskStatus.REJECTED);
|
||||
task.setOpinion("退回修改:" + request.getOpinion());
|
||||
auditTaskRepository.save(task);
|
||||
meetingService.updateAuditStatus(task.getMeetingId(), MeetingAuditStatus.PENDING);
|
||||
meetingService.updateAuditStatus(task.getMeetingId(), MeetingAuditStatus.PENDING, "退回修改:" + request.getOpinion());
|
||||
logAuditTaskAction(task, "AUDIT_TASK_RETURNED", request.getOpinion());
|
||||
triggerAuditNotification(task, request.getOpinion(), "AUDIT_RETURNED");
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
@ -120,7 +120,7 @@ public class AuthController {
|
||||
.body(ApiErrorResponse.of(11005, "账号已被锁定,请" + (remaining / 60 + 1) + "分钟后重试", errors));
|
||||
}
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT u.id, u.tenant_id, u.user_name, u.phone, u.status, u.password_hash, u.valid_from, u.valid_to, " +
|
||||
"SELECT u.id, u.tenant_id, u.user_name, u.phone, u.email, u.status, u.password_hash, u.valid_from, u.valid_to, " +
|
||||
"t.tenant_code, t.tenant_name, t.status AS tenant_status " +
|
||||
"FROM sys_user u JOIN tenant t ON u.tenant_id=t.id " +
|
||||
"WHERE u.phone=? AND t.tenant_code=? AND u.is_deleted=0 AND t.is_deleted=0 LIMIT 1",
|
||||
@ -169,7 +169,7 @@ public class AuthController {
|
||||
String refreshToken = String.valueOf(issueResult.get("refreshToken"));
|
||||
String token = jwtTokenService.createTenantToken(userId, tenantId, request.getPhone(), sessionId);
|
||||
setRefreshCookie(httpResponse, refreshToken, false);
|
||||
Map<String, Object> data = buildTenantAuthData(userId, tenantId, String.valueOf(row.get("user_name")), request.getPhone(), token);
|
||||
Map<String, Object> data = buildTenantAuthData(userId, tenantId, String.valueOf(row.get("user_name")), request.getPhone(), String.valueOf(row.get("email")), token);
|
||||
return ResponseEntity.ok(ApiResponse.success(data));
|
||||
}
|
||||
|
||||
@ -194,7 +194,7 @@ public class AuthController {
|
||||
.body(ApiErrorResponse.of(11005, "账号已被锁定,请" + (remaining / 60 + 1) + "分钟后重试", errors));
|
||||
}
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT id, user_name, phone, status, password_hash, valid_from, valid_to " +
|
||||
"SELECT id, user_name, phone, email, status, password_hash, valid_from, valid_to " +
|
||||
"FROM platform_user WHERE phone=? AND is_deleted=0 LIMIT 1",
|
||||
request.getPhone()
|
||||
);
|
||||
@ -235,7 +235,7 @@ public class AuthController {
|
||||
String refreshToken = String.valueOf(issueResult.get("refreshToken"));
|
||||
String token = jwtTokenService.createPlatformToken(userId, request.getPhone(), sessionId);
|
||||
setRefreshCookie(httpResponse, refreshToken, false);
|
||||
Map<String, Object> data = buildPlatformAuthData(userId, String.valueOf(row.get("user_name")), request.getPhone(), token);
|
||||
Map<String, Object> data = buildPlatformAuthData(userId, String.valueOf(row.get("user_name")), request.getPhone(), String.valueOf(row.get("email")), token);
|
||||
return ResponseEntity.ok(ApiResponse.success(data));
|
||||
}
|
||||
|
||||
@ -292,36 +292,29 @@ public class AuthController {
|
||||
Map<String, Object> data;
|
||||
if (scope == AuthScope.TENANT) {
|
||||
validateTenantSession(userId, tenantId);
|
||||
String phone = jdbcTemplate.queryForObject(
|
||||
"SELECT phone FROM sys_user WHERE id=? AND tenant_id=? AND is_deleted=0 LIMIT 1",
|
||||
String.class,
|
||||
userId,
|
||||
tenantId
|
||||
);
|
||||
String userName = jdbcTemplate.queryForObject(
|
||||
"SELECT user_name FROM sys_user WHERE id=? AND tenant_id=? AND is_deleted=0 LIMIT 1",
|
||||
String.class,
|
||||
Map<String, Object> uRow = jdbcTemplate.queryForMap(
|
||||
"SELECT phone, user_name, email FROM sys_user WHERE id=? AND tenant_id=? AND is_deleted=0 LIMIT 1",
|
||||
userId,
|
||||
tenantId
|
||||
);
|
||||
String phone = String.valueOf(uRow.get("phone"));
|
||||
String userName = String.valueOf(uRow.get("user_name"));
|
||||
String email = String.valueOf(uRow.get("email"));
|
||||
Long sessionId = rotateResult.get("sessionId") == null ? null : ((Number) rotateResult.get("sessionId")).longValue();
|
||||
String token = jwtTokenService.createTenantToken(userId, tenantId, phone, sessionId);
|
||||
data = buildTenantAuthData(userId, tenantId, userName, phone, token);
|
||||
data = buildTenantAuthData(userId, tenantId, userName, phone, email, token);
|
||||
} else {
|
||||
validatePlatformSession(userId);
|
||||
String phone = jdbcTemplate.queryForObject(
|
||||
"SELECT phone FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1",
|
||||
String.class,
|
||||
userId
|
||||
);
|
||||
String userName = jdbcTemplate.queryForObject(
|
||||
"SELECT user_name FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1",
|
||||
String.class,
|
||||
Map<String, Object> puRow = jdbcTemplate.queryForMap(
|
||||
"SELECT phone, user_name, email FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1",
|
||||
userId
|
||||
);
|
||||
String phone = String.valueOf(puRow.get("phone"));
|
||||
String userName = String.valueOf(puRow.get("user_name"));
|
||||
String email = String.valueOf(puRow.get("email"));
|
||||
Long sessionId = rotateResult.get("sessionId") == null ? null : ((Number) rotateResult.get("sessionId")).longValue();
|
||||
String token = jwtTokenService.createPlatformToken(userId, phone, sessionId);
|
||||
data = buildPlatformAuthData(userId, userName, phone, token);
|
||||
data = buildPlatformAuthData(userId, userName, phone, email, token);
|
||||
}
|
||||
setRefreshCookie(response, nextRefreshToken, false);
|
||||
return ApiResponse.success(data);
|
||||
@ -377,7 +370,7 @@ public class AuthController {
|
||||
String phone = String.valueOf(targetIdentity.get("phone"));
|
||||
String token = jwtTokenService.createTenantToken(targetUserId, targetTenantId, phone, sessionId);
|
||||
setRefreshCookie(httpResponse, refreshToken, false);
|
||||
return ApiResponse.success(buildTenantAuthData(targetUserId, targetTenantId, String.valueOf(targetIdentity.get("user_name")), phone, token));
|
||||
return ApiResponse.success(buildTenantAuthData(targetUserId, targetTenantId, String.valueOf(targetIdentity.get("user_name")), phone, String.valueOf(targetIdentity.get("email")), token));
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
@ -462,7 +455,7 @@ public class AuthController {
|
||||
return dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||
}
|
||||
|
||||
private Map<String, Object> buildTenantAuthData(Long userId, Long tenantId, String userName, String phone, String token) {
|
||||
private Map<String, Object> buildTenantAuthData(Long userId, Long tenantId, String userName, String phone, String email, String token) {
|
||||
Map<String, Object> tenant = loadTenantInfo(tenantId);
|
||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||
data.put("token", token);
|
||||
@ -476,11 +469,12 @@ public class AuthController {
|
||||
data.put("roles", systemUserService.getUserRoles(userId, tenantId));
|
||||
data.put("permissions", permissionService.getPermissions(userId, tenantId));
|
||||
data.put("phone", phone);
|
||||
data.put("email", email);
|
||||
data.put("appearance", loadTenantPreferences(userId, tenantId));
|
||||
return data;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildPlatformAuthData(Long userId, String userName, String phone, String token) {
|
||||
private Map<String, Object> buildPlatformAuthData(Long userId, String userName, String phone, String email, String token) {
|
||||
Map<String, Object> data = new LinkedHashMap<String, Object>();
|
||||
data.put("token", token);
|
||||
data.put("scope", AuthScope.PLATFORM.name());
|
||||
@ -490,6 +484,7 @@ public class AuthController {
|
||||
data.put("roles", permissionService.getPlatformRoles(userId));
|
||||
data.put("permissions", permissionService.getPlatformPermissions(userId));
|
||||
data.put("phone", phone);
|
||||
data.put("email", email);
|
||||
data.put("appearance", loadPlatformPreferences(userId));
|
||||
return data;
|
||||
}
|
||||
@ -678,7 +673,7 @@ public class AuthController {
|
||||
String normalizedSwitchAccountKey = normalizeTenantSwitchAccountKey(switchAccountKey);
|
||||
if (!normalizedSwitchAccountKey.isEmpty()) {
|
||||
List<Map<String, Object>> accountKeyRows = jdbcTemplate.queryForList(
|
||||
"SELECT u.id AS user_id, u.tenant_id, u.user_name, u.phone, u.password_hash, u.tenant_switch_account_key " +
|
||||
"SELECT u.id AS user_id, u.tenant_id, u.user_name, u.phone, u.email, u.password_hash, u.tenant_switch_account_key " +
|
||||
"FROM sys_user u " +
|
||||
"JOIN tenant t ON u.tenant_id=t.id " +
|
||||
"WHERE u.tenant_id=? AND u.tenant_switch_account_key=? " +
|
||||
@ -692,7 +687,7 @@ public class AuthController {
|
||||
}
|
||||
}
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT u.id AS user_id, u.tenant_id, u.user_name, u.phone, u.password_hash, u.tenant_switch_account_key " +
|
||||
"SELECT u.id AS user_id, u.tenant_id, u.user_name, u.phone, u.email, u.password_hash, u.tenant_switch_account_key " +
|
||||
"FROM sys_user u " +
|
||||
"JOIN tenant t ON u.tenant_id=t.id " +
|
||||
"WHERE u.tenant_id=? AND u.phone=? AND u.password_hash=? " +
|
||||
|
||||
@ -23,8 +23,9 @@ public class ExportTaskController {
|
||||
|
||||
@GetMapping
|
||||
@RequirePermission(value = "export.task.read", dataScope = DataScopeType.TENANT, auditAction = "EXPORT_TASK_LIST")
|
||||
public ApiResponse<PageResult<ExportTaskInfo>> list() {
|
||||
return ApiResponse.success(exportTaskService.list());
|
||||
public ApiResponse<PageResult<ExportTaskInfo>> list(@RequestParam(value = "pageNo", defaultValue = "1") int pageNo,
|
||||
@RequestParam(value = "pageSize", defaultValue = "10") int pageSize) {
|
||||
return ApiResponse.success(exportTaskService.list(pageNo, pageSize));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
|
||||
@ -64,16 +64,32 @@ public class ExportTaskService {
|
||||
this.meetingSummaryExportService = meetingSummaryExportService;
|
||||
}
|
||||
|
||||
public PageResult<ExportTaskInfo> list() {
|
||||
List<ExportTaskInfo> list = jdbcTemplate.query(
|
||||
public PageResult<ExportTaskInfo> list(int pageNo, int pageSize) {
|
||||
int page = Math.max(1, pageNo);
|
||||
int size = Math.max(1, Math.min(1000, pageSize));
|
||||
int offset = (page - 1) * size;
|
||||
|
||||
Integer totalObj = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(1) FROM export_task WHERE tenant_id=? AND is_deleted=0",
|
||||
Integer.class,
|
||||
tenantId()
|
||||
);
|
||||
int totalElements = totalObj == null ? 0 : totalObj;
|
||||
|
||||
List<ExportTaskInfo> list = new ArrayList<>();
|
||||
if (totalElements > 0) {
|
||||
list = jdbcTemplate.query(
|
||||
"SELECT id, task_code, biz_type, biz_id, file_name, file_oss_key, status, retry_count, IFNULL(download_count,0) AS download_count, " +
|
||||
"DATE_FORMAT(download_token_expire_at, '%Y-%m-%d %H:%i:%s') AS token_expire_at, error_message, " +
|
||||
"DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, DATE_FORMAT(finished_at, '%Y-%m-%d %H:%i:%s') AS finished_at " +
|
||||
"FROM export_task WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC LIMIT 300",
|
||||
"FROM export_task WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
ROW_MAPPER,
|
||||
tenantId()
|
||||
tenantId(),
|
||||
size,
|
||||
offset
|
||||
);
|
||||
return new PageResult<ExportTaskInfo>(list, list.size(), 1, 300);
|
||||
}
|
||||
return new PageResult<ExportTaskInfo>(list, totalElements, page, size);
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
|
||||
@ -77,6 +77,15 @@ public class MeetingController {
|
||||
return ApiResponse.success(meetingService.list(query));
|
||||
}
|
||||
|
||||
@GetMapping("/default-budget")
|
||||
@RequirePermission(value = "meeting.create", dataScope = DataScopeType.TENANT, auditAction = "MEETING_DEFAULT_BUDGET")
|
||||
public ApiResponse<Map<String, Long>> getDefaultBudget(@RequestParam("projectId") Long projectId) {
|
||||
long defaultBudgetCent = meetingService.getDefaultMeetingBudgetCent(projectId);
|
||||
java.util.Map<String, Long> result = new java.util.LinkedHashMap<>();
|
||||
result.put("defaultMeetingBudgetCent", defaultBudgetCent);
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
@GetMapping("/tenant-experts")
|
||||
@RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.TENANT, auditAction = "MEETING_TENANT_EXPERT_LIST")
|
||||
public ApiResponse<PageResult<ExpertInfo>> tenantExperts(@RequestParam(value = "keyword", required = false) String keyword) {
|
||||
|
||||
@ -915,7 +915,7 @@ public class MeetingMaterialService {
|
||||
return true;
|
||||
}
|
||||
String status = stringValue(issue.get("status")).toUpperCase(Locale.ROOT);
|
||||
return "CHANGED".equals(status) || "RESOLVED".equals(status);
|
||||
return "CHANGED".equals(status) || "RESOLVED".equals(status) || "PENDING_CONFIRM".equals(status);
|
||||
}
|
||||
|
||||
private String normalizeSupportedModuleCode(String moduleCode) {
|
||||
@ -2106,7 +2106,7 @@ public class MeetingMaterialService {
|
||||
Map<String, Object> profileFile = asObjectMapOrEmpty(root.get("profileFile"));
|
||||
String ossKey = stringValue(firstNonNull(profileFile.get("ossKey"), root.get("ossKey")));
|
||||
if (ossKey != null && !ossKey.isEmpty()) {
|
||||
addReviewItem(items, "expert_profile_file", "涓撳绠€浠?涓插満鏂囦欢");
|
||||
addReviewItem(items, "expert_profile_file", "专家简介/串场文件");
|
||||
}
|
||||
} else if ("EXPERT_LIST".equals(moduleCode)) {
|
||||
Map<String, Object> onsiteRoot = asObjectMapOrEmpty(root.get("onsitePhoto"));
|
||||
@ -2380,6 +2380,19 @@ public class MeetingMaterialService {
|
||||
}
|
||||
addAttachmentNodeFromSingleFile(index, "signInSheet", "签到表", root.get("signInSheet"));
|
||||
addAttachmentNodeFromSingleFile(index, "themePhoto", "主题照片", root.get("themePhoto"));
|
||||
Object videoObj = root.get("meetingVideo");
|
||||
if (videoObj instanceof Map) {
|
||||
Map<String, Object> videoMap = asObjectMapOrEmpty(videoObj);
|
||||
String type = stringValue(videoMap.get("type"));
|
||||
if ("link".equals(type)) {
|
||||
String link = stringValue(videoMap.get("link"));
|
||||
if (!link.isEmpty()) {
|
||||
addFieldNode(index, "meetingVideo", "会议视频", "网盘链接:" + link);
|
||||
}
|
||||
} else {
|
||||
addAttachmentNodeFromSingleFile(index, "meetingVideo", "会议视频", videoObj);
|
||||
}
|
||||
}
|
||||
Object invitationObj = root.get("invitation");
|
||||
if (invitationObj instanceof Collection) {
|
||||
int idx = 1;
|
||||
|
||||
@ -315,7 +315,15 @@ public class MeetingService {
|
||||
}
|
||||
}
|
||||
|
||||
private long calculateDefaultMeetingBudgetCent(Project project) {
|
||||
public long getDefaultMeetingBudgetCent(Long projectId) {
|
||||
Project project = projectService.getById(projectId);
|
||||
if (project == null) {
|
||||
throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "项目不存在");
|
||||
}
|
||||
return calculateDefaultMeetingBudgetCent(project);
|
||||
}
|
||||
|
||||
public long calculateDefaultMeetingBudgetCent(Project project) {
|
||||
long projectBudgetCent = Math.max(0L, project.getBudgetCent());
|
||||
ProjectFeeSummary feeSummary = parseProjectFeeSummary(project.getProjectFeeJson());
|
||||
long distributableBudgetCent = projectBudgetCent - feeSummary.managementFeeCent - feeSummary.taxFeeCent - feeSummary.customFeeTotalCent;
|
||||
@ -737,8 +745,18 @@ public class MeetingService {
|
||||
}
|
||||
|
||||
public void updateAuditStatus(Long meetingId, MeetingAuditStatus status) {
|
||||
updateAuditStatus(meetingId, status, null);
|
||||
}
|
||||
|
||||
public void updateAuditStatus(Long meetingId, MeetingAuditStatus status, String rejectReason) {
|
||||
Meeting meeting = getById(meetingId);
|
||||
meeting.setAuditStatus(status);
|
||||
if (rejectReason != null && !rejectReason.isEmpty()) {
|
||||
meeting.setLastRejectReason(rejectReason);
|
||||
meeting.setRejectCount(meeting.getRejectCount() + 1);
|
||||
} else if (status == MeetingAuditStatus.APPROVED) {
|
||||
meeting.setLastRejectReason("");
|
||||
}
|
||||
meetingRepository.save(meeting);
|
||||
}
|
||||
|
||||
|
||||
@ -30,8 +30,11 @@ public class ProjectController {
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<Project>> list(@RequestParam(value = "parentOnly", required = false) Boolean parentOnly,
|
||||
@RequestParam(value = "includeDeleted", required = false) Boolean includeDeleted) {
|
||||
return ApiResponse.success(projectService.list(parentOnly, includeDeleted));
|
||||
@RequestParam(value = "includeDeleted", required = false) Boolean includeDeleted,
|
||||
@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "pageNo", required = false) Integer pageNo,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
|
||||
return ApiResponse.success(projectService.list(parentOnly, includeDeleted, keyword, pageNo, pageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/children")
|
||||
|
||||
@ -264,6 +264,10 @@ public class Project {
|
||||
return budgetCent;
|
||||
}
|
||||
|
||||
public void setBudgetCent(long budgetCent) {
|
||||
this.budgetCent = budgetCent;
|
||||
}
|
||||
|
||||
public int getMeetingTotal() {
|
||||
return meetingTotal;
|
||||
}
|
||||
|
||||
@ -64,13 +64,18 @@ public class ProjectService {
|
||||
this(projectRepository, null, null, null, null, null);
|
||||
}
|
||||
|
||||
public PageResult<Project> list(Boolean parentOnly, Boolean includeDeleted) {
|
||||
public PageResult<Project> list(Boolean parentOnly, Boolean includeDeleted, String keyword, Integer pageNo, Integer pageSize) {
|
||||
List<Project> list = projectRepository.findAll(Boolean.TRUE.equals(includeDeleted));
|
||||
if (Boolean.TRUE.equals(parentOnly)) {
|
||||
list = list.stream()
|
||||
.filter(project -> project.getParentProjectId() == null)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
if (keyword != null && !keyword.trim().isEmpty()) {
|
||||
list = list.stream()
|
||||
.filter(project -> project.getName() != null && project.getName().contains(keyword.trim()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
if (dataPermissionService != null) {
|
||||
DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope();
|
||||
final Map<Long, Long> creatorMap = scope.isProjectOwnerOnly()
|
||||
@ -80,7 +85,14 @@ public class ProjectService {
|
||||
.filter(project -> dataPermissionService.canAccessProject(project.getId(), creatorMap.get(project.getId()), scope))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
return new PageResult<>(list, list.size(), 1, 20);
|
||||
int page = pageNo == null || pageNo < 1 ? 1 : pageNo;
|
||||
int size = pageSize == null || pageSize < 1 ? 20 : pageSize;
|
||||
int total = list.size();
|
||||
int fromIndex = (page - 1) * size;
|
||||
int toIndex = Math.min(fromIndex + size, total);
|
||||
List<Project> pagedList = fromIndex < total ? list.subList(fromIndex, toIndex) : new ArrayList<>();
|
||||
redactSensitiveData(pagedList);
|
||||
return new PageResult<>(pagedList, total, page, size);
|
||||
}
|
||||
|
||||
public List<Project> listChildren(Long parentProjectId) {
|
||||
@ -95,6 +107,7 @@ public class ProjectService {
|
||||
.filter(project -> dataPermissionService.canAccessProject(project.getId(), creatorMap.get(project.getId()), scope))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
redactSensitiveData(children);
|
||||
return children;
|
||||
}
|
||||
|
||||
@ -110,6 +123,7 @@ public class ProjectService {
|
||||
.filter(project -> dataPermissionService.canAccessProject(project.getId(), creatorMap.get(project.getId()), scope))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
redactSensitiveData(children);
|
||||
return children;
|
||||
}
|
||||
|
||||
@ -134,7 +148,7 @@ public class ProjectService {
|
||||
double budgetExecutionRatio = calculateBudgetExecutionRatio(request.getBudgetCent(), projectFee.totalCent);
|
||||
assertProjectBudgetConstraint(
|
||||
request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(),
|
||||
request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(),
|
||||
request.getOverBudgetThresholdRatio() == null ? 0.0d : request.getOverBudgetThresholdRatio(),
|
||||
budgetExecutionRatio,
|
||||
request.getBudgetCent(),
|
||||
projectFee.totalCent
|
||||
@ -157,7 +171,7 @@ public class ProjectService {
|
||||
request.getMeetingTotal(),
|
||||
0,
|
||||
request.getAllowMeetingOverBudget() != null && request.getAllowMeetingOverBudget(),
|
||||
request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(),
|
||||
request.getOverBudgetThresholdRatio() == null ? 0.0d : request.getOverBudgetThresholdRatio(),
|
||||
request.getOverBudgetApprovalChainJson(),
|
||||
budgetExecutionRatio,
|
||||
null,
|
||||
@ -215,7 +229,7 @@ public class ProjectService {
|
||||
assertCurrentBudgetCanCoverChildren(projectId, request.getBudgetCent());
|
||||
assertProjectBudgetConstraint(
|
||||
request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(),
|
||||
request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(),
|
||||
request.getOverBudgetThresholdRatio() == null ? 0.0d : request.getOverBudgetThresholdRatio(),
|
||||
budgetExecutionRatio,
|
||||
request.getBudgetCent(),
|
||||
projectFee.totalCent
|
||||
@ -238,7 +252,7 @@ public class ProjectService {
|
||||
request.getMeetingTotal(),
|
||||
existing.getMeetingCompletedCount(),
|
||||
request.getAllowMeetingOverBudget() != null && request.getAllowMeetingOverBudget(),
|
||||
request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(),
|
||||
request.getOverBudgetThresholdRatio() == null ? 0.0d : request.getOverBudgetThresholdRatio(),
|
||||
request.getOverBudgetApprovalChainJson(),
|
||||
budgetExecutionRatio,
|
||||
existing.getRiskFlagsJson(),
|
||||
@ -607,7 +621,7 @@ public class ProjectService {
|
||||
try {
|
||||
return new ProjectFeeSummary(
|
||||
objectMapper.writeValueAsString(normalizedRoot),
|
||||
managementFeeCent + taxFeeCent + paidAmountCent + customTotalCent
|
||||
managementFeeCent + taxFeeCent + customTotalCent
|
||||
);
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException(10001, "项目费用配置序列化失败");
|
||||
@ -704,10 +718,10 @@ public class ProjectService {
|
||||
double budgetExecutionRatio,
|
||||
Long budgetCent,
|
||||
long feeTotalCent) {
|
||||
if (allowProjectOverBudget) {
|
||||
return;
|
||||
}
|
||||
double allowedRatio = 1d + Math.max(0d, thresholdRatio);
|
||||
// 如果关闭了“允许超支”开关,强制阈值为 0
|
||||
double actualThreshold = allowProjectOverBudget ? Math.max(0d, thresholdRatio) : 0d;
|
||||
double allowedRatio = 1d + actualThreshold;
|
||||
|
||||
if (budgetExecutionRatio > allowedRatio) {
|
||||
long budget = budgetCent == null ? 0L : budgetCent;
|
||||
long allowedTotalCent = Math.round(budget * allowedRatio);
|
||||
@ -893,6 +907,19 @@ public class ProjectService {
|
||||
}
|
||||
return "项目变更";
|
||||
}
|
||||
private void redactSensitiveData(List<Project> list) {
|
||||
if (list == null || list.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Long userId = AuthContext.userId();
|
||||
boolean canReadSensitive = permissionService != null && userId != null && permissionService.hasPermission(userId, "project.sensitive_data.read");
|
||||
if (!canReadSensitive) {
|
||||
for (Project project : list) {
|
||||
project.setBudgetCent(-1L);
|
||||
project.setProjectFeeJson("{}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class ProjectFeeSummary {
|
||||
private final String normalizedJson;
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission);
|
||||
INSERT INTO permission (id, permission_code, permission_name, module)
|
||||
SELECT @next_permission_id, 'project.sensitive_data.read', '查看项目敏感数据', 'project'
|
||||
FROM dual
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM permission
|
||||
WHERE permission_code = 'project.sensitive_data.read'
|
||||
);
|
||||
|
||||
SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission);
|
||||
INSERT INTO role_permission (id, tenant_id, role_id, permission_id)
|
||||
SELECT
|
||||
(@next_role_permission_id := @next_role_permission_id + 1) AS id,
|
||||
r.tenant_id,
|
||||
r.id AS role_id,
|
||||
p.id AS permission_id
|
||||
FROM role r
|
||||
JOIN permission p ON p.permission_code = 'project.sensitive_data.read'
|
||||
WHERE r.role_code IN ('TENANT_ADMIN', 'PROJECT_OWNER', 'FINANCE')
|
||||
AND r.is_deleted = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM role_permission rp
|
||||
WHERE rp.tenant_id = r.tenant_id
|
||||
AND rp.role_id = r.id
|
||||
AND rp.permission_id = p.id
|
||||
);
|
||||
1
find_mojibake.js
Normal file
1
find_mojibake.js
Normal file
@ -0,0 +1 @@
|
||||
const fs = require('fs'); ['backend/src/main/java/com/writeoff/module/meeting/service/MeetingMaterialService.java', 'frontend/src/views/modules/MeetingPage.vue'].forEach(f => { const lines = fs.readFileSync(f, 'utf8').split('\n'); lines.forEach((l, i) => { if (l.includes('\uFFFD')) console.log(f + ':' + (i+1) + ': ' + l.trim()); }); });
|
||||
106
frontend/package-lock.json
generated
106
frontend/package-lock.json
generated
@ -13,7 +13,8 @@
|
||||
"element-plus": "^2.8.4",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.10",
|
||||
"vue-router": "^4.4.5"
|
||||
"vue-router": "^4.4.5",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
@ -1076,6 +1077,15 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/async-validator": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
|
||||
@ -1127,6 +1137,28 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@ -1164,6 +1196,18 @@
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
||||
@ -1362,6 +1406,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@ -1747,6 +1800,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/superjson": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz",
|
||||
@ -1868,6 +1933,45 @@
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,7 +14,8 @@
|
||||
"element-plus": "^2.8.4",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.10",
|
||||
"vue-router": "^4.4.5"
|
||||
"vue-router": "^4.4.5",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
|
||||
@ -23,7 +23,7 @@ export const logoutAuth = () => http.post("/auth/logout");
|
||||
export const logoutAllAuth = () => http.post("/auth/logout-all");
|
||||
export const fetchGlobalSearch = (params: { q: string; limitPerType?: number }) => http.get("/search/global", { params });
|
||||
|
||||
export const fetchProjects = (params?: { parentOnly?: boolean; includeDeleted?: boolean }) => http.get("/projects", { params });
|
||||
export const fetchProjects = (params?: { parentOnly?: boolean; includeDeleted?: boolean; keyword?: string; pageNo?: number; pageSize?: number }) => http.get("/projects", { params });
|
||||
export const fetchProjectChildren = (id: number, params?: { includeDeleted?: boolean }) => http.get(`/projects/${id}/children`, { params });
|
||||
export const createProject = (payload: {
|
||||
name: string;
|
||||
@ -156,6 +156,8 @@ export const bindMeetingExperts = (meetingId: number, payload: { expertIds: numb
|
||||
http.post(`/meetings/${meetingId}/experts/bind`, payload);
|
||||
export const unbindMeetingExpert = (meetingId: number, expertId: number) =>
|
||||
http.delete(`/meetings/${meetingId}/experts/${expertId}`);
|
||||
export const fetchDefaultMeetingBudgetCent = (projectId: number) =>
|
||||
http.get(`/meetings/default-budget?projectId=${projectId}`);
|
||||
export const createMeeting = (payload: {
|
||||
projectId: number;
|
||||
topic: string;
|
||||
@ -1046,7 +1048,7 @@ export const fetchInAppNotificationSummary = (params?: { ts?: number }) =>
|
||||
http.get("/in-app-notifications/summary", { params });
|
||||
export const markInAppNotificationRead = (id: number) => http.post(`/in-app-notifications/${id}/read`);
|
||||
export const markAllInAppNotificationsRead = () => http.post("/in-app-notifications/read-all");
|
||||
export const fetchExportTasks = () => http.get("/export-tasks");
|
||||
export const fetchExportTasks = (params?: { pageNo?: number; pageSize?: number }) => http.get("/export-tasks", { params });
|
||||
export const createExportTask = (payload: {
|
||||
idempotencyKey: string;
|
||||
taskCode: string;
|
||||
|
||||
@ -7,6 +7,7 @@ export const PERMS = {
|
||||
archive: "project.archive",
|
||||
bindUser: "project.bind.user",
|
||||
bindExecutorUser: "project.bind.executor_user",
|
||||
sensitiveDataRead: "project.sensitive_data.read",
|
||||
},
|
||||
meeting: {
|
||||
read: "meeting.read",
|
||||
|
||||
@ -14,6 +14,7 @@ export type AuthPayload = {
|
||||
roles?: string[];
|
||||
userId?: number | string | null;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
tenantId?: number | string | null;
|
||||
userName?: string;
|
||||
tenantName?: string;
|
||||
@ -37,6 +38,7 @@ type AuthState = {
|
||||
roles: string[];
|
||||
userId: number | null;
|
||||
phone: string;
|
||||
email: string;
|
||||
tenantId: number | null;
|
||||
userName: string;
|
||||
tenantName: string;
|
||||
@ -151,6 +153,7 @@ const readAuthStateFromStorage = (): AuthState => {
|
||||
roles: parseStringArray(localStorage.getItem("roles")),
|
||||
userId: parseNumber(localStorage.getItem("userId")),
|
||||
phone: String(localStorage.getItem("phone") || "").trim(),
|
||||
email: String(localStorage.getItem("email") || "").trim(),
|
||||
tenantId: parseNumber(localStorage.getItem("tenantId")),
|
||||
userName: String(localStorage.getItem("userName") || "").trim(),
|
||||
tenantName: String(localStorage.getItem("tenantName") || "").trim(),
|
||||
@ -170,6 +173,7 @@ const applyAuthState = (target: AuthState, source: AuthState) => {
|
||||
target.roles = source.roles;
|
||||
target.userId = source.userId;
|
||||
target.phone = source.phone;
|
||||
target.email = source.email;
|
||||
target.tenantId = source.tenantId;
|
||||
target.userName = source.userName;
|
||||
target.tenantName = source.tenantName;
|
||||
@ -212,6 +216,7 @@ export const useAuthStore = defineStore("auth", {
|
||||
localStorage.setItem("token", String(data.token || ""));
|
||||
localStorage.setItem("userId", String(data.userId || ""));
|
||||
localStorage.setItem("phone", String(data.phone || ""));
|
||||
localStorage.setItem("email", String(data.email || ""));
|
||||
localStorage.setItem("tenantId", String(data.tenantId || ""));
|
||||
localStorage.setItem("userName", String(data.userName || ""));
|
||||
localStorage.setItem("tenantName", String(data.tenantName || ""));
|
||||
@ -253,6 +258,7 @@ export const useAuthStore = defineStore("auth", {
|
||||
localStorage.removeItem("roles");
|
||||
localStorage.removeItem("userId");
|
||||
localStorage.removeItem("phone");
|
||||
localStorage.removeItem("email");
|
||||
localStorage.removeItem("tenantId");
|
||||
localStorage.removeItem("userName");
|
||||
localStorage.removeItem("tenantName");
|
||||
|
||||
@ -310,6 +310,18 @@ const notifPageSize = ref(10);
|
||||
const notifDetailVisible = ref(false);
|
||||
const currentNotifDetail = ref<Record<string, any> | null>(null);
|
||||
|
||||
const prefersDark = ref(false);
|
||||
let mediaQueryList: MediaQueryList | null = null;
|
||||
const updatePrefersDark = (e: MediaQueryListEvent | MediaQueryList) => {
|
||||
prefersDark.value = e.matches;
|
||||
};
|
||||
|
||||
const isDarkMode = computed(() => {
|
||||
if (appearanceStore.themeMode === 'DARK') return true;
|
||||
if (appearanceStore.themeMode === 'LIGHT') return false;
|
||||
return prefersDark.value;
|
||||
});
|
||||
|
||||
let suppressNextAuthRefresh = false;
|
||||
|
||||
const tenantSwitchSharedRoutePaths = new Set(["/dashboard", "/profile"]);
|
||||
@ -337,15 +349,21 @@ const escapeSvgText = (value: string) =>
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const createWatermarkImage = (lines: string[]) => {
|
||||
const createWatermarkImage = (lines: string[], isDark: boolean) => {
|
||||
const [line1 = "", line2 = ""] = lines.map((item) => escapeSvgText(item));
|
||||
|
||||
const textColor1 = isDark ? "#ffffff" : "#0f172a";
|
||||
const opacity1 = isDark ? "0.15" : "0.1";
|
||||
const textColor2 = isDark ? "#e2e8f0" : "#334155";
|
||||
const opacity2 = isDark ? "0.2" : "0.14";
|
||||
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240" viewBox="0 0 320 240">
|
||||
<g transform="rotate(-24 160 120)">
|
||||
<text x="24" y="112" font-size="15" font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif" fill="#0f172a" fill-opacity="0.1">
|
||||
<text x="24" y="112" font-size="15" font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif" fill="${textColor1}" fill-opacity="${opacity1}">
|
||||
${line1}
|
||||
</text>
|
||||
<text x="24" y="144" font-size="13" font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif" fill="#334155" fill-opacity="0.14">
|
||||
<text x="24" y="144" font-size="13" font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif" fill="${textColor2}" fill-opacity="${opacity2}">
|
||||
${line2}
|
||||
</text>
|
||||
</g>
|
||||
@ -355,7 +373,7 @@ const createWatermarkImage = (lines: string[]) => {
|
||||
};
|
||||
|
||||
const watermarkStyle = computed(() => ({
|
||||
backgroundImage: createWatermarkImage([userDisplay.value, `${watermarkScopeLabel.value} ${watermarkDate}`.trim()]),
|
||||
backgroundImage: createWatermarkImage([userDisplay.value, `${watermarkScopeLabel.value} ${watermarkDate}`.trim()], isDarkMode.value),
|
||||
}));
|
||||
|
||||
const notifUnreadCount = computed(() => Number(unreadInAppCount.value || 0));
|
||||
@ -607,6 +625,16 @@ onMounted(async () => {
|
||||
window.addEventListener("storage", handleAuthStorageChanged);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
prefersDark.value = mediaQueryList.matches;
|
||||
if (mediaQueryList.addEventListener) {
|
||||
mediaQueryList.addEventListener('change', updatePrefersDark);
|
||||
} else {
|
||||
mediaQueryList.addListener(updatePrefersDark);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshLayoutState();
|
||||
} finally {
|
||||
@ -615,6 +643,13 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (mediaQueryList) {
|
||||
if (mediaQueryList.removeEventListener) {
|
||||
mediaQueryList.removeEventListener('change', updatePrefersDark);
|
||||
} else {
|
||||
mediaQueryList.removeListener(updatePrefersDark);
|
||||
}
|
||||
}
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("auth:token-updated", handleAuthTokenUpdated as EventListener);
|
||||
window.removeEventListener("storage", handleAuthStorageChanged);
|
||||
|
||||
@ -67,6 +67,7 @@
|
||||
:get-file-url="getFileUrl"
|
||||
:is-image-attachment="isImageAttachment"
|
||||
:is-pdf-attachment="isPdfAttachment"
|
||||
:is-excel-attachment="isExcelAttachment"
|
||||
:open-file-url="openFileUrl"
|
||||
:preview-oss-document="openAuditMaterialDocPreview"
|
||||
:build-photo-item-key="buildPhotoItemKey"
|
||||
@ -85,6 +86,7 @@
|
||||
<MeetingDocPreviewDialog
|
||||
v-model="auditMaterialPreviewVisible"
|
||||
:doc-preview-dialog-image-url="auditMaterialPreviewUrl"
|
||||
:doc-preview-excel-html="auditMaterialPreviewExcelHtml"
|
||||
:preview-kind="auditMaterialPreviewKind"
|
||||
/>
|
||||
<MeetingAuditProgressDialog
|
||||
@ -133,6 +135,8 @@ import { toZhAuditNode, toZhStatus } from "../../utils/status";
|
||||
import AuditListTable from "./audit-page/AuditListTable.vue";
|
||||
import AuditMaterialDrawer from "./audit-page/AuditMaterialDrawer.vue";
|
||||
import AuditQueryToolbar from "./audit-page/AuditQueryToolbar.vue";
|
||||
import * as echarts from "echarts";
|
||||
import * as XLSX from "xlsx";
|
||||
import MeetingAuditProgressDialog from "./meeting-page/MeetingAuditProgressDialog.vue";
|
||||
import MeetingDocPreviewDialog from "./meeting-page/MeetingDocPreviewDialog.vue";
|
||||
|
||||
@ -1787,9 +1791,30 @@ const openFileUrl = (ossKey: unknown) => {
|
||||
|
||||
const auditMaterialPreviewVisible = ref(false);
|
||||
const auditMaterialPreviewUrl = ref("");
|
||||
const auditMaterialPreviewKind = ref<"image" | "pdf">("image");
|
||||
const isExcelAttachment = (name: unknown, ossKey: unknown) => {
|
||||
const ext1 = extractExt(String(name || ""));
|
||||
const ext2 = extractExt(String(ossKey || ""));
|
||||
return [".xls", ".xlsx"].includes(ext1) || [".xls", ".xlsx"].includes(ext2);
|
||||
};
|
||||
|
||||
/** 与会议资料抽屉一致:弹窗内 iframe 预览 PDF 或大图预览图片 */
|
||||
const auditMaterialPreviewKind = ref<"image" | "pdf" | "excel">("image");
|
||||
const auditMaterialPreviewExcelHtml = ref<string>("");
|
||||
|
||||
const loadExcelHtmlFromUrl = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: "array" });
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
return XLSX.utils.sheet_to_html(worksheet);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse excel", e);
|
||||
return "无法解析该 Excel 文件";
|
||||
}
|
||||
};
|
||||
|
||||
/** 与会议资料抽屉一致:弹窗内 iframe 预览 PDF、大图预览图片或表格预览 Excel */
|
||||
const openAuditMaterialDocPreview = async (ossKey: unknown, fileNameHint?: string) => {
|
||||
const key = String(ossKey || "").trim();
|
||||
if (!key) {
|
||||
@ -1811,8 +1836,12 @@ const openAuditMaterialDocPreview = async (ossKey: unknown, fileNameHint?: strin
|
||||
ElMessage.warning("暂未获取到文件查看链接");
|
||||
return;
|
||||
}
|
||||
auditMaterialPreviewKind.value = isPdfAttachment(fileNameHint, key) ? "pdf" : "image";
|
||||
const isExcel = isExcelAttachment(fileNameHint, key);
|
||||
auditMaterialPreviewKind.value = isExcel ? "excel" : isPdfAttachment(fileNameHint, key) ? "pdf" : "image";
|
||||
auditMaterialPreviewUrl.value = url;
|
||||
if (isExcel) {
|
||||
auditMaterialPreviewExcelHtml.value = await loadExcelHtmlFromUrl(url);
|
||||
}
|
||||
auditMaterialPreviewVisible.value = true;
|
||||
};
|
||||
|
||||
@ -1837,9 +1866,9 @@ const handleApproveCurrentModule = async () => {
|
||||
} else if (savedCount <= 0) {
|
||||
ElMessage.warning("未写入审核结果,请刷新后重试");
|
||||
} else if (skippedRejected > 0) {
|
||||
ElMessage.success(`本模块审核通过(${savedCount}/${itemCount}),${skippedRejected}项不通过已保留`);
|
||||
ElMessage.success(`本模块审核通过,${skippedRejected}项不通过已保留`);
|
||||
} else {
|
||||
ElMessage.success(`本模块审核通过(${savedCount}/${itemCount})`);
|
||||
ElMessage.success(`本模块审核通过`);
|
||||
}
|
||||
await refreshAuditTaskDetailCache();
|
||||
await loadMaterial();
|
||||
@ -1996,6 +2025,10 @@ const loadMaterial = async () => {
|
||||
itemKey: buildInvitationItemKey(x, i),
|
||||
}))
|
||||
.filter((x: { ossKey: string }) => !!x.ossKey),
|
||||
videoType: parsed.meetingVideo?.type || "",
|
||||
videoName: parsed.meetingVideo?.name || parsed.meetingVideo?.fileName || "",
|
||||
videoOssKey: parsed.meetingVideo?.ossKey || "",
|
||||
videoLink: parsed.meetingVideo?.link || "",
|
||||
};
|
||||
expertProfileView.value = {
|
||||
fileName: String(parsed?.profileFile?.name || parsed?.fileName || "").trim(),
|
||||
@ -2006,6 +2039,7 @@ const loadMaterial = async () => {
|
||||
{ ossKey: docView.value.signInOssKey },
|
||||
{ ossKey: docView.value.themePhotoOssKey },
|
||||
...docView.value.invitations.map((x: any) => ({ ossKey: x?.ossKey || "" })),
|
||||
{ ossKey: docView.value.videoOssKey },
|
||||
].filter((x) => !!x.ossKey);
|
||||
await loadFileUrls(items);
|
||||
} else if (materialModule.value === "EXPERT_PROFILE") {
|
||||
|
||||
@ -26,6 +26,18 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="flex-end mt-md">
|
||||
<el-pagination
|
||||
v-model:current-page="pageNo"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="total"
|
||||
@current-change="load"
|
||||
@size-change="load"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="createVisible" title="创建导出任务" :size="DRAWER_SIZE.md" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="任务编码" prop="taskCode"><el-input v-model="form.taskCode" /></el-form-item>
|
||||
@ -79,6 +91,9 @@ const canRead = computed(() => authStore.hasPermission(PERMS.exportTask.read));
|
||||
const canManage = computed(() => authStore.hasPermission(PERMS.exportTask.manage));
|
||||
const rows = ref<any[]>([]);
|
||||
const tokens = ref<Record<number, string>>({});
|
||||
const pageNo = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const total = ref(0);
|
||||
const createVisible = ref(false);
|
||||
const form = ref({
|
||||
taskCode: "EXPORT_LEDGER",
|
||||
@ -111,8 +126,9 @@ const openCreateDrawer = () => {
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const resp = await fetchExportTasks();
|
||||
const resp = await fetchExportTasks({ pageNo: pageNo.value, pageSize: pageSize.value });
|
||||
rows.value = resp?.data?.list || [];
|
||||
total.value = Number(resp?.data?.total || 0);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
@ -130,6 +146,7 @@ const handleCreate = async () => {
|
||||
});
|
||||
ElMessage.success("导出任务创建成功");
|
||||
createVisible.value = false;
|
||||
pageNo.value = 1;
|
||||
await load();
|
||||
};
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<PageContainer title="会议管理">
|
||||
<MeetingQueryToolbar v-model:query-form="queryForm" @load="handleMeetingQueryLoad" @reset-query="resetQuery" />
|
||||
<MeetingListTable
|
||||
@ -62,6 +62,7 @@
|
||||
:meeting-location-options="meetingLocationOptions"
|
||||
:max-labor-ratio="currentEditProjectLaborRatio"
|
||||
:max-catering-ratio="currentEditProjectCateringRatio"
|
||||
:last-reject-reason="currentEditLastRejectReason"
|
||||
@save="handleUpdate"
|
||||
/>
|
||||
<MeetingDetailDrawer
|
||||
@ -89,6 +90,7 @@
|
||||
:material-submit-action-text="materialSubmitActionText"
|
||||
:material-resubmit-summary="materialResubmitPreview"
|
||||
:material-budget-summary="materialBudgetSummary"
|
||||
:last-reject-reason="materialLastRejectReason"
|
||||
:basic-form="basicForm"
|
||||
:bound-expert-rows="boundExpertRows"
|
||||
:is-review-approved="isReviewApproved"
|
||||
@ -111,6 +113,13 @@
|
||||
:handle-theme-photo-picture-change="handleThemePhotoPictureChange"
|
||||
:handle-theme-photo-picture-preview="handleThemePhotoPicturePreview"
|
||||
:handle-theme-photo-picture-remove="handleThemePhotoPictureRemove"
|
||||
v-model:meeting-video-type="docForm.videoType"
|
||||
v-model:meeting-video-link="docForm.videoLink"
|
||||
:meeting-video-upload-file-list="meetingVideoUploadFileList"
|
||||
:before-meeting-video-upload="beforeMeetingVideoUpload"
|
||||
:handle-meeting-video-picture-change="handleMeetingVideoChange"
|
||||
:handle-meeting-video-picture-remove="handleMeetingVideoRemove"
|
||||
:meeting-form="resolveSelectedMeetingForm()"
|
||||
:invitation-upload-file-list="invitationUploadFileList"
|
||||
:before-invitation-upload="beforeInvitationUpload"
|
||||
:handle-invitation-picture-change="handleInvitationPictureChange"
|
||||
@ -245,6 +254,7 @@
|
||||
<MeetingDocPreviewDialog
|
||||
v-model="docPreviewDialogVisible"
|
||||
:doc-preview-dialog-image-url="docPreviewDialogImageUrl"
|
||||
:doc-preview-excel-html="docPreviewExcelHtml"
|
||||
:preview-kind="docPreviewDialogKind"
|
||||
/>
|
||||
<MeetingOcrRawDialog
|
||||
@ -292,6 +302,9 @@ import { useRoute } from "vue-router";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import MeetingQueryToolbar from "./meeting-page/MeetingQueryToolbar.vue";
|
||||
import MeetingListTable from "./meeting-page/MeetingListTable.vue";
|
||||
import * as echarts from "echarts";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import MeetingEditDrawer from "./meeting-page/MeetingEditDrawer.vue";
|
||||
import MeetingDetailDrawer from "./meeting-page/MeetingDetailDrawer.vue";
|
||||
import MeetingMaterialDrawer from "./meeting-page/MeetingMaterialDrawer.vue";
|
||||
@ -403,6 +416,8 @@ const editDrawerVisible = ref(false);
|
||||
const detailDrawerVisible = ref(false);
|
||||
const currentEditMeetingId = ref<number | null>(null);
|
||||
const currentEditOriginalBudgetCent = ref(0);
|
||||
const currentEditLastRejectReason = ref("");
|
||||
const materialLastRejectReason = ref("");
|
||||
const currentEditAllowMeetingOverBudget = ref(false);
|
||||
const currentEditProjectLaborRatio = ref<number | null>(null);
|
||||
const currentEditProjectCateringRatio = ref<number | null>(null);
|
||||
@ -944,6 +959,10 @@ const docForm = ref({
|
||||
themePhotoName: "",
|
||||
themePhotoOssKey: "",
|
||||
invitationLines: "",
|
||||
videoType: "file" as "file" | "link",
|
||||
videoName: "",
|
||||
videoOssKey: "",
|
||||
videoLink: "",
|
||||
});
|
||||
const expertProfileForm = ref({
|
||||
fileName: "",
|
||||
@ -968,6 +987,7 @@ const agendaFile = ref<File | null>(null);
|
||||
const signInFile = ref<File | null>(null);
|
||||
const themePhotoFile = ref<File | null>(null);
|
||||
const invitationFile = ref<File | null>(null);
|
||||
const meetingVideoFile = ref<File | null>(null);
|
||||
const photoAutoUploading = ref(false);
|
||||
const laborProtocolAutoUploading = ref(false);
|
||||
const laborInvoiceAutoUploading = ref(false);
|
||||
@ -1067,7 +1087,8 @@ const materialBudgetLoading = ref(false);
|
||||
const materialLoadRequestId = ref(0);
|
||||
const docPreviewDialogVisible = ref(false);
|
||||
const docPreviewDialogImageUrl = ref("");
|
||||
const docPreviewDialogKind = ref<"image" | "pdf">("image");
|
||||
const docPreviewDialogKind = ref<"image" | "pdf" | "excel">("image");
|
||||
const docPreviewExcelHtml = ref<string>("");
|
||||
const pendingIssueJumpState = ref<{
|
||||
meetingId: number;
|
||||
moduleCode: MaterialModuleCode;
|
||||
@ -1425,6 +1446,11 @@ const buildAgendaJsonPayload = () =>
|
||||
}));
|
||||
const isImageDocFile = (name: string, ossKey: string) => isImageFile(name || "") || isImageFile(ossKey || "");
|
||||
const isPdfDocFile = (name: string, ossKey: string) => extractExt(name || "") === ".pdf" || extractExt(ossKey || "") === ".pdf";
|
||||
const isExcelDocFile = (name: string, ossKey: string) => {
|
||||
const ext1 = extractExt(name || "");
|
||||
const ext2 = extractExt(ossKey || "");
|
||||
return [".xls", ".xlsx"].includes(ext1) || [".xls", ".xlsx"].includes(ext2);
|
||||
};
|
||||
const agendaUploadFileList = computed<UploadUserFile[]>(() =>
|
||||
agendaDocRows.value.map((item, index) => {
|
||||
const name = item.name || `会议日程${index + 1}`;
|
||||
@ -1438,6 +1464,19 @@ const agendaUploadFileList = computed<UploadUserFile[]>(() =>
|
||||
} as UploadUserFile & { ossKey: string };
|
||||
}),
|
||||
);
|
||||
const meetingVideoUploadFileList = computed<UploadUserFile[]>(() => {
|
||||
if (!docForm.value.videoOssKey) {
|
||||
return [];
|
||||
}
|
||||
const name = displayDocName(docForm.value.videoName, docForm.value.videoOssKey, "会议视频");
|
||||
return [{
|
||||
name,
|
||||
url: "",
|
||||
status: "success",
|
||||
uid: 2,
|
||||
ossKey: docForm.value.videoOssKey,
|
||||
} as UploadUserFile & { ossKey: string }];
|
||||
});
|
||||
const signInUploadFileList = computed<UploadUserFile[]>(() => {
|
||||
if (!docForm.value.signInOssKey) {
|
||||
return [];
|
||||
@ -2623,6 +2662,7 @@ const openEditDrawer = async (row: any) => {
|
||||
}
|
||||
currentEditMeetingId.value = Number(row.id);
|
||||
currentEditOriginalBudgetCent.value = Math.max(0, Number(detail.budgetCent || 0));
|
||||
currentEditLastRejectReason.value = String(detail.lastRejectReason || "");
|
||||
currentEditAllowMeetingOverBudget.value = allowMeetingOverBudget;
|
||||
meetingForm.value = {
|
||||
projectId,
|
||||
@ -2699,7 +2739,6 @@ const REQUIRED_MODULES = [
|
||||
{ code: "WRITE_OFF_DOCS", name: "核销材料模块" },
|
||||
{ code: "EXPERT_PROFILE", name: "专家简介/串场模块" },
|
||||
{ code: "EXPERT_LIST", name: "专家列表模块" },
|
||||
{ code: "MEETING_INVOICE", name: "会议发票模块" },
|
||||
];
|
||||
|
||||
const resolveMeetingMaterialEffectiveContentJson = (material: any) =>
|
||||
@ -4275,9 +4314,7 @@ const showPendingIssueSubmitBlockDialog = async (meetingId: number, pendingIssue
|
||||
}, "当前会议仍有驳回项未处理完成,暂不能提交审核。"),
|
||||
h("div", {
|
||||
style: "margin-top:8px;font-size:13px;line-height:1.7;color:#606266;",
|
||||
}, firstOpenIssue
|
||||
? "请先按驳回原因完成修改;状态为“已修改,待审核确认”的问题,需要等待审核人员确认后才能再次提交。"
|
||||
: "以下驳回项已修改但仍待审核人员确认,确认完成前暂不能再次提交。"),
|
||||
}, "请先按驳回原因完成下方列出的问题修改后,再尝试提交审核。"),
|
||||
h("div", {
|
||||
style: "margin-top:16px;max-height:360px;overflow:auto;padding-right:6px;",
|
||||
}, groups.map((group) => h("div", {
|
||||
@ -4451,6 +4488,33 @@ const syncWriteOffDocPreviewUrls = async () => {
|
||||
];
|
||||
await Promise.allSettled(keys.map((key) => cacheDocPreviewUrl(key)));
|
||||
};
|
||||
const loadExcelHtmlFromUrl = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: "array" });
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
return XLSX.utils.sheet_to_html(worksheet);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse excel", e);
|
||||
return "无法解析该 Excel 文件";
|
||||
}
|
||||
};
|
||||
|
||||
const loadExcelHtmlFromFile = async (file: File) => {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: "array" });
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
return XLSX.utils.sheet_to_html(worksheet);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse excel", e);
|
||||
return "无法解析该 Excel 文件";
|
||||
}
|
||||
};
|
||||
|
||||
const openDocPicturePreview = async (ossKey: string, fileNameHint = "") => {
|
||||
const key = String(ossKey || "").trim();
|
||||
if (!key) {
|
||||
@ -4459,8 +4523,12 @@ const openDocPicturePreview = async (ossKey: string, fileNameHint = "") => {
|
||||
await cacheDocPreviewUrl(key);
|
||||
const url = String(docPreviewUrlMap.value[key] || "").trim();
|
||||
if (url) {
|
||||
docPreviewDialogKind.value = isPdfDocFile(fileNameHint, key) ? "pdf" : "image";
|
||||
const isExcel = isExcelDocFile(fileNameHint, key);
|
||||
docPreviewDialogKind.value = isExcel ? "excel" : isPdfDocFile(fileNameHint, key) ? "pdf" : "image";
|
||||
docPreviewDialogImageUrl.value = url;
|
||||
if (isExcel) {
|
||||
docPreviewExcelHtml.value = await loadExcelHtmlFromUrl(url);
|
||||
}
|
||||
docPreviewDialogVisible.value = true;
|
||||
return;
|
||||
}
|
||||
@ -4468,7 +4536,7 @@ const openDocPicturePreview = async (ossKey: string, fileNameHint = "") => {
|
||||
};
|
||||
const handleDocPicturePreview = async (
|
||||
uploadFile: Parameters<NonNullable<UploadProps["onPreview"]>>[0],
|
||||
target: "agenda" | "signIn" | "themePhoto" | "invitation",
|
||||
target: "agenda" | "signIn" | "themePhoto" | "invitation" | "meetingVideo",
|
||||
) => {
|
||||
const row = uploadFile as UploadUserFile & { ossKey?: string };
|
||||
const fallbackOssKey = target === "agenda"
|
||||
@ -4486,11 +4554,15 @@ const handleDocPicturePreview = async (
|
||||
}
|
||||
const url = String(uploadFile?.url || "").trim();
|
||||
if (url) {
|
||||
const rawMime = String((row?.raw as File | undefined)?.type || "").trim();
|
||||
const isPdf =
|
||||
isPdfDocFile(nameHint, "") || rawMime === "application/pdf";
|
||||
docPreviewDialogKind.value = isPdf ? "pdf" : "image";
|
||||
const rawFile = row?.raw as File | undefined;
|
||||
const rawMime = String(rawFile?.type || "").trim();
|
||||
const isExcel = isExcelDocFile(nameHint, "");
|
||||
const isPdf = isPdfDocFile(nameHint, "") || rawMime === "application/pdf";
|
||||
docPreviewDialogKind.value = isExcel ? "excel" : isPdf ? "pdf" : "image";
|
||||
docPreviewDialogImageUrl.value = url;
|
||||
if (isExcel && rawFile) {
|
||||
docPreviewExcelHtml.value = await loadExcelHtmlFromFile(rawFile);
|
||||
}
|
||||
docPreviewDialogVisible.value = true;
|
||||
}
|
||||
};
|
||||
@ -4604,7 +4676,7 @@ const removeInvitationDoc = (index: number) => {
|
||||
|
||||
const MATERIAL_ACCEPT_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".pdf", ".tif", ".tiff", ".doc", ".docx", ".xls", ".xlsx", ".ofd"]);
|
||||
const EXPERT_PROFILE_ACCEPT_EXT = new Set([".pdf", ".ppt", ".pptx"]);
|
||||
const MATERIAL_MAX_FILE_SIZE_MB = 2;
|
||||
const MATERIAL_MAX_FILE_SIZE_MB = 5;
|
||||
const MATERIAL_MAX_FILE_SIZE_BYTES = MATERIAL_MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||
const isMaterialUploadAllowed = (file?: File | null) => {
|
||||
if (!file) {
|
||||
@ -4639,11 +4711,11 @@ const validateMaterialFile = (file: File | null, label: string, maxSizeMb = MATE
|
||||
return false;
|
||||
}
|
||||
if (!isMaterialUploadAllowed(file)) {
|
||||
ElMessage.warning(`${label}仅支持图片或PDF文件`);
|
||||
ElMessage.warning(`${label}仅支持图片、PDF或Excel文件`);
|
||||
return false;
|
||||
}
|
||||
if (Number(file.size || 0) > maxSizeMb * 1024 * 1024) {
|
||||
ElMessage.warning(`${label}澶у皬涓嶈兘瓒呰繃${maxSizeMb}MB`);
|
||||
ElMessage.warning(`${label}大小不能超过${maxSizeMb}MB`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@ -4666,6 +4738,50 @@ const validateExpertProfileFile = (file: File | null, maxSizeMb = MATERIAL_MAX_F
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateVideoFile = (file: File | null, label: string, maxSizeMb = 500) => {
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
const ext = extractExt(file.name || "");
|
||||
const mime = String(file.type || "").toLowerCase();
|
||||
const allowedExts = new Set([".mp4", ".mov", ".avi", ".mkv"]);
|
||||
if (!allowedExts.has(ext) && !mime.startsWith("video/")) {
|
||||
ElMessage.warning(`${label}仅支持视频文件(mp4/mov/avi/mkv)`);
|
||||
return false;
|
||||
}
|
||||
if (Number(file.size || 0) > maxSizeMb * 1024 * 1024) {
|
||||
ElMessage.warning(`${label}大小不能超过${maxSizeMb}MB`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const beforeMeetingVideoUpload = (rawFile: any, maxSizeMb = 500) =>
|
||||
validateVideoFile((rawFile as File) || null, "会议视频", maxSizeMb);
|
||||
|
||||
const handleMeetingVideoChange = async (uploadFile: any, uploadFiles: any, maxSizeMb = 500) => {
|
||||
if (isReviewApproved("meetingVideo")) {
|
||||
warnReviewedMaterialLocked();
|
||||
return;
|
||||
}
|
||||
const file = (uploadFile?.raw as File) || null;
|
||||
meetingVideoFile.value = validateVideoFile(file, "会议视频", maxSizeMb) ? file : null;
|
||||
if (!meetingVideoFile.value) {
|
||||
return;
|
||||
}
|
||||
await uploadDocFile("meetingVideo");
|
||||
};
|
||||
|
||||
const handleMeetingVideoRemove = () => {
|
||||
if (isReviewApproved("meetingVideo")) {
|
||||
warnReviewedMaterialLocked();
|
||||
return false;
|
||||
}
|
||||
docForm.value.videoName = "";
|
||||
docForm.value.videoOssKey = "";
|
||||
};
|
||||
|
||||
const beforeAgendaUpload = (rawFile: any, maxSizeMb?: number) => {
|
||||
if (!validateMaterialFile((rawFile as File) || null, "会议日程", maxSizeMb)) {
|
||||
return false;
|
||||
@ -4771,7 +4887,7 @@ const handleExpertProfileFileChange = async (uploadFile: any, uploadFiles: any,
|
||||
const uploadUrl = signResp?.data?.uploadUrl;
|
||||
const objectKey = signResp?.data?.objectKey;
|
||||
if (!uploadUrl || !objectKey) {
|
||||
ElMessage.error("鑾峰彇涓婁紶绛惧悕澶辫触");
|
||||
ElMessage.error("获取上传签名失败");
|
||||
return;
|
||||
}
|
||||
await putToSignedUrl(uploadUrl, file, signResp?.data?.contentType || file.type);
|
||||
@ -4891,7 +5007,7 @@ const handleLaborInvoiceFileRemove: UploadProps["onRemove"] = async (uploadFile)
|
||||
laborInvoiceUploadFiles.value = [];
|
||||
};
|
||||
|
||||
const uploadDocFile = async (target: "agenda" | "signIn" | "themePhoto" | "invitation") => {
|
||||
const uploadDocFile = async (target: "agenda" | "signIn" | "themePhoto" | "invitation" | "meetingVideo") => {
|
||||
if (!selectedMeetingId.value) {
|
||||
return;
|
||||
}
|
||||
@ -4901,6 +5017,8 @@ const uploadDocFile = async (target: "agenda" | "signIn" | "themePhoto" | "invit
|
||||
? signInFile.value
|
||||
: target === "themePhoto"
|
||||
? themePhotoFile.value
|
||||
: target === "meetingVideo"
|
||||
? meetingVideoFile.value
|
||||
: invitationFile.value;
|
||||
if (!file) {
|
||||
ElMessage.warning("请先选择文件");
|
||||
@ -4914,7 +5032,7 @@ const uploadDocFile = async (target: "agenda" | "signIn" | "themePhoto" | "invit
|
||||
const objectKey = signResp?.data?.objectKey;
|
||||
const contentType = signResp?.data?.contentType || file.type || "application/octet-stream";
|
||||
if (!uploadUrl || !objectKey) {
|
||||
ElMessage.error("鑾峰彇涓婁紶绛惧悕澶辫触");
|
||||
ElMessage.error("获取上传签名失败");
|
||||
return;
|
||||
}
|
||||
await putToSignedUrl(uploadUrl, file, contentType);
|
||||
@ -4930,6 +5048,10 @@ const uploadDocFile = async (target: "agenda" | "signIn" | "themePhoto" | "invit
|
||||
docForm.value.themePhotoName = file.name;
|
||||
docForm.value.themePhotoOssKey = objectKey;
|
||||
themePhotoFile.value = null;
|
||||
} else if (target === "meetingVideo") {
|
||||
docForm.value.videoName = file.name;
|
||||
docForm.value.videoOssKey = objectKey;
|
||||
meetingVideoFile.value = null;
|
||||
} else {
|
||||
const line = `${file.name}|${objectKey}`;
|
||||
docForm.value.invitationLines = docForm.value.invitationLines ? `${docForm.value.invitationLines}\n${line}` : line;
|
||||
@ -5060,7 +5182,7 @@ const onMeetingInvoiceSectionFileChange = async (
|
||||
const uploadUrl = signResp?.data?.uploadUrl;
|
||||
const objectKey = signResp?.data?.objectKey;
|
||||
if (!uploadUrl || !objectKey) {
|
||||
ElMessage.error("鑾峰彇涓婁紶绛惧悕澶辫触");
|
||||
ElMessage.error("获取上传签名失败");
|
||||
return;
|
||||
}
|
||||
await putToSignedUrl(uploadUrl, file, signResp?.data?.contentType || file.type);
|
||||
@ -5246,7 +5368,7 @@ const uploadLaborProtocolFile = async () => {
|
||||
const uploadUrl = signResp?.data?.uploadUrl;
|
||||
const objectKey = signResp?.data?.objectKey;
|
||||
if (!uploadUrl || !objectKey) {
|
||||
ElMessage.error("鑾峰彇涓婁紶绛惧悕澶辫触");
|
||||
ElMessage.error("获取上传签名失败");
|
||||
return;
|
||||
}
|
||||
await putToSignedUrl(uploadUrl, laborProtocolUploadFile.value, signResp?.data?.contentType || laborProtocolUploadFile.value.type);
|
||||
@ -5284,7 +5406,7 @@ const uploadLaborInvoiceFile = async () => {
|
||||
const uploadUrl = signResp?.data?.uploadUrl;
|
||||
const objectKey = signResp?.data?.objectKey;
|
||||
if (!uploadUrl || !objectKey) {
|
||||
ElMessage.error("鑾峰彇涓婁紶绛惧悕澶辫触");
|
||||
ElMessage.error("获取上传签名失败");
|
||||
return;
|
||||
}
|
||||
await putToSignedUrl(uploadUrl, localFile, signResp?.data?.contentType || localFile.type);
|
||||
@ -5463,7 +5585,7 @@ const resetMaterialForms = () => {
|
||||
improvementSuggestion: "",
|
||||
meetingEffect: "",
|
||||
};
|
||||
docForm.value = { agendaLines: "", signInName: "", signInOssKey: "", themePhotoName: "", themePhotoOssKey: "", invitationLines: "" };
|
||||
docForm.value = { agendaLines: "", signInName: "", signInOssKey: "", themePhotoName: "", themePhotoOssKey: "", invitationLines: "", videoType: "file", videoName: "", videoOssKey: "", videoLink: "" };
|
||||
expertProfileForm.value = { fileName: "", ossKey: "" };
|
||||
photoForm.value = { photoLines: "", summary: "" };
|
||||
photoSummaryByExpert.value = {};
|
||||
@ -5900,6 +6022,10 @@ const loadMaterialModule = async (
|
||||
docForm.value.signInOssKey = parsed.signInSheet?.ossKey || "";
|
||||
docForm.value.themePhotoName = resolveStoredDocFileName(parsed.themePhoto);
|
||||
docForm.value.themePhotoOssKey = parsed.themePhoto?.ossKey || "";
|
||||
docForm.value.videoType = parsed.meetingVideo?.type || "file";
|
||||
docForm.value.videoName = resolveStoredDocFileName(parsed.meetingVideo) || String(parsed.meetingVideo?.name || "");
|
||||
docForm.value.videoOssKey = parsed.meetingVideo?.ossKey || "";
|
||||
docForm.value.videoLink = parsed.meetingVideo?.link || "";
|
||||
expertProfileForm.value.fileName = resolveStoredDocFileName(parsed?.profileFile) || String(parsed?.fileName || "").trim();
|
||||
expertProfileForm.value.ossKey = String(parsed?.profileFile?.ossKey || parsed?.ossKey || "").trim();
|
||||
const invitationList = Array.isArray(parsed.invitation) ? parsed.invitation : [];
|
||||
@ -5913,6 +6039,10 @@ const loadMaterialModule = async (
|
||||
docForm.value.signInOssKey = parsed.signInSheet?.ossKey || "";
|
||||
docForm.value.themePhotoName = resolveStoredDocFileName(parsed.themePhoto);
|
||||
docForm.value.themePhotoOssKey = parsed.themePhoto?.ossKey || "";
|
||||
docForm.value.videoType = parsed.meetingVideo?.type || "file";
|
||||
docForm.value.videoName = resolveStoredDocFileName(parsed.meetingVideo) || String(parsed.meetingVideo?.name || "");
|
||||
docForm.value.videoOssKey = parsed.meetingVideo?.ossKey || "";
|
||||
docForm.value.videoLink = parsed.meetingVideo?.link || "";
|
||||
const invitationList = Array.isArray(parsed.invitation) ? parsed.invitation : [];
|
||||
docForm.value.invitationLines = invitationList
|
||||
.map((x: any) => `${resolveStoredDocFileName(x)}|${x?.ossKey || ""}`.trim())
|
||||
@ -6277,6 +6407,7 @@ const openMaterial = (row: any, moduleCode: MaterialModuleCode = "BASIC_INFO") =
|
||||
});
|
||||
selectedMeetingId.value = meetingId;
|
||||
selectedModuleCode.value = initialModuleCode;
|
||||
materialLastRejectReason.value = String(row?.lastRejectReason || "");
|
||||
materialDialogVisible.value = true;
|
||||
clearMaterialAuditTaskDetailCache();
|
||||
startMaterialModuleLoad(meetingId, initialModuleCode, { reloadExperts: true });
|
||||
@ -6801,6 +6932,13 @@ const buildContentJson = () => {
|
||||
fileName: String(docForm.value.themePhotoName || "").trim(),
|
||||
ossKey: String(docForm.value.themePhotoOssKey || "").trim(),
|
||||
},
|
||||
meetingVideo: {
|
||||
type: docForm.value.videoType,
|
||||
name: String(docForm.value.videoName || "").trim(),
|
||||
fileName: String(docForm.value.videoName || "").trim(),
|
||||
ossKey: String(docForm.value.videoOssKey || "").trim(),
|
||||
link: String(docForm.value.videoLink || "").trim(),
|
||||
},
|
||||
invitation: (docForm.value.invitationLines || "")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
@ -6848,6 +6986,13 @@ const buildContentJson = () => {
|
||||
fileName: String(docForm.value.themePhotoName || "").trim(),
|
||||
ossKey: String(docForm.value.themePhotoOssKey || "").trim(),
|
||||
},
|
||||
meetingVideo: {
|
||||
type: docForm.value.videoType,
|
||||
name: String(docForm.value.videoName || "").trim(),
|
||||
fileName: String(docForm.value.videoName || "").trim(),
|
||||
ossKey: String(docForm.value.videoOssKey || "").trim(),
|
||||
link: String(docForm.value.videoLink || "").trim(),
|
||||
},
|
||||
invitation: invitations,
|
||||
profileFile: {
|
||||
name: String(expertProfileForm.value.fileName || "").trim(),
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
<div class="avatar-text">
|
||||
<h3>{{ userName }}</h3>
|
||||
<p>{{ phone }}</p>
|
||||
<p style="margin:4px 0 0;font-size:13px;color:var(--el-text-color-secondary);">{{ email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -197,7 +198,7 @@ import {
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const appearanceStore = useAppearanceStore();
|
||||
const { scope, tenantCode, tenantName: storedTenantName, userName: storedUserName, phone: storedPhone } = storeToRefs(authStore);
|
||||
const { scope, tenantCode, tenantName: storedTenantName, userName: storedUserName, phone: storedPhone, email: storedEmail } = storeToRefs(authStore);
|
||||
const { themeMode, density, themeScheme } = storeToRefs(appearanceStore);
|
||||
|
||||
const authScope = computed(() => scope.value);
|
||||
@ -205,6 +206,7 @@ const tenantCodeDisplay = computed(() => tenantCode.value || "-");
|
||||
const tenantName = computed(() => storedTenantName.value || (authScope.value === "PLATFORM" ? "平台侧" : "尚未关联"));
|
||||
const userName = computed(() => storedUserName.value || "未知用户");
|
||||
const phone = computed(() => storedPhone.value || "未设置");
|
||||
const email = computed(() => storedEmail.value && storedEmail.value !== "null" ? storedEmail.value : "暂无邮箱");
|
||||
|
||||
const themeModeLabelMap: Record<string, string> = {
|
||||
SYSTEM: "跟随系统",
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
<template>
|
||||
<template>
|
||||
<PageContainer title="项目管理">
|
||||
<QueryToolbar>
|
||||
<el-form :inline="true" @submit.prevent class="project-page-toolbar">
|
||||
<el-form-item>
|
||||
<el-input v-model="queryName" placeholder="搜索项目名称" clearable class="w-input-md" />
|
||||
<el-input v-model="queryName" placeholder="搜索项目名称" clearable class="w-input-md" @clear="load" @keyup.enter="load" />
|
||||
</el-form-item>
|
||||
<!-- <el-form-item>
|
||||
<el-checkbox v-model="includeDeleted">包含已删除</el-checkbox>
|
||||
</el-form-item> -->
|
||||
<el-form-item>
|
||||
<el-button v-if="canCreate" type="primary" @click="openCreateDrawer">新建项目</el-button>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="load">刷新</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@ -35,14 +36,14 @@
|
||||
</el-table-column> -->
|
||||
<el-table-column prop="budgetCent" label="预算(元)" :formatter="budgetFormatter" />
|
||||
<el-table-column label="费用合计(元)" :formatter="projectFeeTotalFormatter" />
|
||||
<el-table-column prop="budgetExecutionRatio" label="预算执行率" :formatter="executionRatioFormatter" />
|
||||
<!-- <el-table-column prop="budgetExecutionRatio" label="预算执行率" :formatter="executionRatioFormatter" /> -->
|
||||
<el-table-column prop="meetingTotal" label="会议总期数" width="100"/>
|
||||
<el-table-column label="项目周期" width="200">
|
||||
<template #default="{ row }">{{ row.startDate || "-" }} ~ {{ row.endDate || "-" }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="status" label="状态" :formatter="statusFormatter" width="100"/>
|
||||
<el-table-column label="操作" width="200">
|
||||
<el-table-column label="操作" width="370">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canCreate" size="small" type="primary" @click="openCreateDrawer(row)">添加子项目</el-button>
|
||||
<el-button size="small" @click="openDetailDrawer(row)">详情</el-button>
|
||||
@ -56,6 +57,18 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt-md flex justify-end" style="margin-top: 16px; display: flex; justify-content: flex-end;">
|
||||
<el-pagination
|
||||
v-model:current-page="pageNo"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
@size-change="load"
|
||||
@current-change="load"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProjectEditDrawer
|
||||
v-model="projectDrawerVisible"
|
||||
:mode="projectDrawerMode"
|
||||
@ -87,17 +100,17 @@
|
||||
<el-descriptions-item label="合作企业负责人">{{ currentProject.partnerOwnerUsers || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="合作企业项目执行人">{{ currentProject.partnerExecutorUsers || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目周期">{{ currentProject.startDate || "-" }} ~ {{ currentProject.endDate || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目预算(元)">{{ toYuan(currentProject.budgetCent) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目预算(元)">{{ canSensitiveDataRead ? toYuan(currentProject.budgetCent) : '***' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="劳务费协议签署类型">{{ formatLaborAgreementSignType(currentProject.laborAgreementSignType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="管理费(元)">{{ toYuan(currentProjectFee.managementFeeCent) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="税费(元)">{{ toYuan(currentProjectFee.taxFeeCent) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="到款金额(元)">{{ toYuan(currentProjectFee.paidAmountCent) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="管理费(元)">{{ canSensitiveDataRead ? toYuan(currentProjectFee.managementFeeCent) : '***' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="税费(元)">{{ canSensitiveDataRead ? toYuan(currentProjectFee.taxFeeCent) : '***' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="到款金额(元)">{{ canSensitiveDataRead ? toYuan(currentProjectFee.paidAmountCent) : '***' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="自定义费用(元)">{{ customFeeText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会议总期数">{{ currentProject.meetingTotal || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="完成期数">{{ currentProject.meetingCompletedCount || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目状态">{{ toZhStatus(currentProject.status) }}</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="预算执行率">{{ formatExecutionRatio(currentProject.budgetExecutionRatio) }}</el-descriptions-item>
|
||||
<!-- <el-descriptions-item label="预算执行率">{{ formatExecutionRatio(currentProject.budgetExecutionRatio) }}</el-descriptions-item> -->
|
||||
</el-descriptions>
|
||||
<el-divider v-if="canKeyChangeLogRead">关键变更日志</el-divider>
|
||||
<el-table v-if="canKeyChangeLogRead" :data="keyChangeLogs" style="width: 100%">
|
||||
@ -179,6 +192,7 @@ import ProjectEditDrawer from "./project-page/ProjectEditDrawer.vue";
|
||||
import MeetingEditDrawer from "./meeting-page/MeetingEditDrawer.vue";
|
||||
import {
|
||||
createMeeting,
|
||||
fetchDefaultMeetingBudgetCent,
|
||||
fetchDictionaries,
|
||||
fetchMeetings,
|
||||
fetchProjectBindingCandidates,
|
||||
@ -198,6 +212,9 @@ import { toZhStatus } from "../../utils/status";
|
||||
const rows = ref<any[]>([]);
|
||||
const queryName = ref("");
|
||||
const includeDeleted = ref(false);
|
||||
const pageNo = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const total = ref(0);
|
||||
const projectDrawerVisible = ref(false);
|
||||
const projectDrawerMode = ref<"create" | "edit">("create");
|
||||
const projectDrawerSourceRow = ref<any | null>(null);
|
||||
@ -232,6 +249,7 @@ const canArchive = computed(() => authStore.hasPermission(PERMS.project.archive)
|
||||
const canBindUser = computed(() => authStore.hasPermission(PERMS.project.bindUser));
|
||||
const canBindExecutorUser = computed(() => authStore.hasPermission(PERMS.project.bindExecutorUser));
|
||||
const canKeyChangeLogRead = computed(() => authStore.hasPermission(PERMS.project.keyChangeLogRead));
|
||||
const canSensitiveDataRead = computed(() => authStore.hasPermission(PERMS.project.sensitiveDataRead));
|
||||
const currentRoles = computed(() => authStore.roles || []);
|
||||
const currentUserId = computed(() => Number(authStore.userId || 0) || null);
|
||||
const isProjectExecutorRole = computed(() => currentRoles.value.includes("PROJECT_EXECUTOR"));
|
||||
@ -285,13 +303,13 @@ const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(
|
||||
const toYuan = (cent: unknown) => Number(((Number(cent) || 0) / 100).toFixed(2));
|
||||
const formatLaborAgreementSignType = (value: unknown) => Number(value) === 2 ? "线下签" : "放心签";
|
||||
const formatExecutionRatio = (ratio: unknown) => `${((Number(ratio) || 0) * 100).toFixed(2)}%`;
|
||||
const budgetFormatter = (_row: any, _column: any, value: unknown) => toYuan(value);
|
||||
const budgetFormatter = (_row: any, _column: any, value: unknown) => canSensitiveDataRead.value ? toYuan(value) : '***';
|
||||
const executionRatioFormatter = (_row: any, _column: any, value: unknown) => formatExecutionRatio(value);
|
||||
const projectFeeTotalFormatter = (row: any) => {
|
||||
if (!canSensitiveDataRead.value) return '***';
|
||||
const fee = parseProjectFee(row?.projectFeeJson);
|
||||
const total = (fee.managementFeeCent || 0)
|
||||
+ (fee.taxFeeCent || 0)
|
||||
+ (fee.paidAmountCent || 0)
|
||||
+ (fee.customFees || []).reduce((acc: number, cur: { amountCent: number }) => acc + (cur.amountCent || 0), 0);
|
||||
return toYuan(total);
|
||||
};
|
||||
@ -324,19 +342,18 @@ const parseProjectFee = (projectFeeJson: unknown) => {
|
||||
}
|
||||
};
|
||||
const currentProjectFee = computed(() => parseProjectFee(currentProject.value?.projectFeeJson));
|
||||
const customFeeText = computed(() =>
|
||||
currentProjectFee.value.customFees.length
|
||||
const customFeeText = computed(() => {
|
||||
if (!canSensitiveDataRead.value) return '***';
|
||||
return currentProjectFee.value.customFees.length
|
||||
? currentProjectFee.value.customFees.map((item: { name: string; amountCent: number }) => `${item.name}:${toYuan(item.amountCent)}元`).join(";")
|
||||
: "-",
|
||||
);
|
||||
: "-";
|
||||
});
|
||||
const normalizeProjectRow = (row: any) => ({
|
||||
...row,
|
||||
hasChildren: Number(row?.subProjectCount || 0) > 0,
|
||||
});
|
||||
const filteredRows = computed(() =>
|
||||
rows.value
|
||||
.map((row) => normalizeProjectRow(row))
|
||||
.filter((r) => !queryName.value || String(r.name || "").includes(queryName.value)),
|
||||
rows.value.map((row) => normalizeProjectRow(row))
|
||||
);
|
||||
|
||||
const parseDateOnly = (value: unknown) => {
|
||||
@ -391,8 +408,9 @@ const meetingCreateFormTimeRange = computed<string[]>({
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
const resp = await fetchProjects({ parentOnly: true });
|
||||
const resp = await fetchProjects({ parentOnly: true, keyword: queryName.value, pageNo: pageNo.value, pageSize: pageSize.value });
|
||||
rows.value = resp?.data?.list || [];
|
||||
total.value = resp?.data?.total || 0;
|
||||
childrenCache.clear();
|
||||
};
|
||||
|
||||
@ -471,14 +489,24 @@ const handleCreateMeeting = async (row: any) => {
|
||||
return;
|
||||
}
|
||||
|
||||
let defaultMeetingBudgetCent = 0;
|
||||
if (canSensitiveDataRead.value) {
|
||||
const fee = parseProjectFee(row?.projectFeeJson);
|
||||
const customFeeCent = (fee.customFees || []).reduce((acc: number, cur: { amountCent: number }) => acc + (Number(cur?.amountCent) || 0), 0);
|
||||
const distributableBudgetCent = (Number(row?.budgetCent) || 0) - (Number(fee.managementFeeCent) || 0) - (Number(fee.taxFeeCent) || 0) - customFeeCent;
|
||||
const defaultMeetingBudgetCent = Math.floor(distributableBudgetCent / meetingTotal);
|
||||
defaultMeetingBudgetCent = Math.floor(distributableBudgetCent / meetingTotal);
|
||||
if (defaultMeetingBudgetCent <= 0) {
|
||||
ElMessage.warning("项目可分配会议预算不足,请先检查项目预算或费用设置");
|
||||
ElMessage.warning(`项目可分配会议预算不足(剩余可用: ${distributableBudgetCent / 100}元),请先检查项目预算或费用设置`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const resp = await fetchDefaultMeetingBudgetCent(row.id);
|
||||
defaultMeetingBudgetCent = resp?.data?.defaultMeetingBudgetCent || 0;
|
||||
} catch (e) {
|
||||
console.warn("Failed to fetch default budget", e);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const meetingDate = now >= projectStart && now <= projectEnd ? now : projectStart;
|
||||
|
||||
@ -17,6 +17,7 @@ const props = defineProps<{
|
||||
getFileUrl: (ossKey: unknown) => string;
|
||||
isImageAttachment: (name: unknown, ossKey: unknown) => boolean;
|
||||
isPdfAttachment: (name: unknown, ossKey: unknown) => boolean;
|
||||
isExcelAttachment: (name: unknown, ossKey: unknown) => boolean;
|
||||
openFileUrl: (ossKey: unknown) => void;
|
||||
previewOssDocument: (
|
||||
ossKey: string,
|
||||
@ -45,7 +46,10 @@ const previewProfileFile = () => {
|
||||
if (!fileKey.value) {
|
||||
return;
|
||||
}
|
||||
if (props.isPdfAttachment(props.expertProfileView?.fileName, fileKey.value)) {
|
||||
if (
|
||||
props.isPdfAttachment(props.expertProfileView?.fileName, fileKey.value) ||
|
||||
props.isExcelAttachment(props.expertProfileView?.fileName, fileKey.value)
|
||||
) {
|
||||
void props.previewOssDocument(fileKey.value, fileName.value);
|
||||
return;
|
||||
}
|
||||
@ -114,6 +118,16 @@ const previewProfileFile = () => {
|
||||
<el-icon class="audit-profile-pdf__icon"><Document /></el-icon>
|
||||
<span class="audit-profile-pdf__label">PDF</span>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="isExcelAttachment(expertProfileView.fileName, fileKey)"
|
||||
class="audit-profile-pdf"
|
||||
type="button"
|
||||
:title="fileName"
|
||||
@click.stop="previewProfileFile"
|
||||
>
|
||||
<el-icon class="audit-profile-pdf__icon"><Document /></el-icon>
|
||||
<span class="audit-profile-pdf__label">EXCEL</span>
|
||||
</button>
|
||||
<div v-else class="audit-profile-file">FILE</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -34,6 +34,7 @@ const props = defineProps<{
|
||||
getFileUrl: (ossKey: unknown) => string;
|
||||
isImageAttachment: (name: unknown, ossKey: unknown) => boolean;
|
||||
isPdfAttachment: (name: unknown, ossKey: unknown) => boolean;
|
||||
isExcelAttachment: (name: unknown, ossKey: unknown) => boolean;
|
||||
openFileUrl: (ossKey: unknown) => void;
|
||||
previewOssDocument: (
|
||||
ossKey: string,
|
||||
@ -289,7 +290,6 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
|
||||
v-if="canReject && !isHigherReview"
|
||||
size="small"
|
||||
type="danger"
|
||||
link
|
||||
@click.stop="handleRejectMaterialItem(`onsite_summary:${selectedAuditExpertId}`, '现场说明')"
|
||||
>
|
||||
不通过
|
||||
@ -403,7 +403,6 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
|
||||
v-if="canReject && !isHigherReview"
|
||||
size="small"
|
||||
type="danger"
|
||||
link
|
||||
@click.stop="handleRejectMaterialItem(buildLaborItemKey(currentLaborRow, currentLaborRow.__auditIndex), `劳务协议-${selectedAuditExpertName || ''}${currentLaborRow.__roleLabel ? `-${currentLaborRow.__roleLabel}` : ''}`)"
|
||||
>
|
||||
不通过
|
||||
@ -439,6 +438,18 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
|
||||
<el-icon class="audit-preview-pdf__icon"><Document /></el-icon>
|
||||
<span class="audit-preview-pdf__label">PDF</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="audit-preview-pdf"
|
||||
:title="laborProtocolFileLabel(currentLaborRow)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click.stop="previewLaborProtocol(currentLaborRow)"
|
||||
@keydown.enter.prevent="previewLaborProtocol(currentLaborRow)"
|
||||
>
|
||||
<el-icon class="audit-preview-pdf__icon"><Document /></el-icon>
|
||||
<span class="audit-preview-pdf__label">DOC</span>
|
||||
</div>
|
||||
<div class="audit-attachment-panel__name" :title="laborProtocolFileLabel(currentLaborRow)">
|
||||
{{ laborProtocolFileLabel(currentLaborRow) }}
|
||||
</div>
|
||||
|
||||
@ -102,6 +102,7 @@ const props = defineProps<{
|
||||
getFileUrl: (ossKey: unknown) => string;
|
||||
isImageAttachment: (name: unknown, ossKey: unknown) => boolean;
|
||||
isPdfAttachment: (name: unknown, ossKey: unknown) => boolean;
|
||||
isExcelAttachment: (name: unknown, ossKey: unknown) => boolean;
|
||||
openFileUrl: (ossKey: unknown) => void;
|
||||
previewOssDocument: (
|
||||
ossKey: string,
|
||||
@ -666,11 +667,11 @@ watch(
|
||||
</div>
|
||||
|
||||
<div class="audit-full-material-section">
|
||||
<div class="audit-full-material-section__head">
|
||||
<!-- <div class="audit-full-material-section__head">
|
||||
<div>
|
||||
<div class="audit-full-material-section__title">完整资料</div>
|
||||
</div>
|
||||
<div class="audit-full-material-section__title"></div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<el-tabs
|
||||
v-model="materialModule"
|
||||
@ -738,6 +739,7 @@ watch(
|
||||
:get-file-url="getFileUrl"
|
||||
:is-image-attachment="isImageAttachment"
|
||||
:is-pdf-attachment="isPdfAttachment"
|
||||
:is-excel-attachment="isExcelAttachment"
|
||||
:open-file-url="openFileUrl"
|
||||
:preview-oss-document="previewOssDocument"
|
||||
:handle-reject-material-item="handleRejectMaterialItem"
|
||||
@ -757,6 +759,7 @@ watch(
|
||||
:get-file-url="getFileUrl"
|
||||
:is-image-attachment="isImageAttachment"
|
||||
:is-pdf-attachment="isPdfAttachment"
|
||||
:is-excel-attachment="isExcelAttachment"
|
||||
:open-file-url="openFileUrl"
|
||||
:preview-oss-document="previewOssDocument"
|
||||
:handle-reject-material-item="handleRejectMaterialItem"
|
||||
@ -784,6 +787,7 @@ watch(
|
||||
:get-file-url="getFileUrl"
|
||||
:is-image-attachment="isImageAttachment"
|
||||
:is-pdf-attachment="isPdfAttachment"
|
||||
:is-excel-attachment="isExcelAttachment"
|
||||
:open-file-url="openFileUrl"
|
||||
:preview-oss-document="previewOssDocument"
|
||||
:build-photo-item-key="buildPhotoItemKey"
|
||||
@ -882,7 +886,6 @@ watch(
|
||||
v-if="canReject"
|
||||
size="small"
|
||||
type="danger"
|
||||
link
|
||||
@click.stop="handleRejectMaterialItem(buildMeetingInvoiceFieldItemKey(section.sectionCode, file.fieldKey), `${section.sectionTitle}-${file.label}`)"
|
||||
>
|
||||
不通过
|
||||
@ -919,7 +922,6 @@ watch(
|
||||
v-if="canReject && !isHigherReview"
|
||||
size="small"
|
||||
type="danger"
|
||||
link
|
||||
@click.stop="handleRejectMaterialItem(buildMeetingInvoiceAmountItemKey(section.sectionCode), `${section.sectionTitle}-总费用金额`)"
|
||||
>
|
||||
不通过
|
||||
|
||||
@ -9,6 +9,7 @@ type ReviewDocItem = {
|
||||
reviewKey: string | string[];
|
||||
rejectKey: string;
|
||||
rejectLabel: string;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
@ -26,6 +27,7 @@ const props = defineProps<{
|
||||
getFileUrl: (ossKey: unknown) => string;
|
||||
isImageAttachment: (name: unknown, ossKey: unknown) => boolean;
|
||||
isPdfAttachment: (name: unknown, ossKey: unknown) => boolean;
|
||||
isExcelAttachment: (name: unknown, ossKey: unknown) => boolean;
|
||||
openFileUrl: (ossKey: unknown) => void;
|
||||
previewOssDocument: (
|
||||
ossKey: string,
|
||||
@ -44,16 +46,26 @@ const fileDisplayName = (name: unknown, ossKey: unknown) => {
|
||||
return tail || "附件";
|
||||
};
|
||||
|
||||
|
||||
const openLink = (link?: string) => {
|
||||
if (link) {
|
||||
const url = link.startsWith('http') ? link : `https://${link}`;
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const previewFile = (name: unknown, ossKey: unknown) => {
|
||||
const key = String(ossKey ?? "").trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
if (props.isPdfAttachment(name, key)) {
|
||||
const key = String(ossKey || "").trim();
|
||||
if (!key) return;
|
||||
if (
|
||||
props.isImageAttachment(name, key) ||
|
||||
props.isPdfAttachment(name, key) ||
|
||||
props.isExcelAttachment(name, key)
|
||||
) {
|
||||
void props.previewOssDocument(key, fileDisplayName(name, key));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
props.openFileUrl(key);
|
||||
}
|
||||
};
|
||||
|
||||
const buildAgendaReviewKey = (item: any, index: number) => {
|
||||
@ -101,7 +113,8 @@ const invitationItems = computed<ReviewDocItem[]>(() =>
|
||||
),
|
||||
);
|
||||
|
||||
const sections = computed(() => [
|
||||
const sections = computed(() => {
|
||||
const baseSections = [
|
||||
{
|
||||
key: "agenda",
|
||||
title: "会议日程",
|
||||
@ -150,7 +163,29 @@ const sections = computed(() => [
|
||||
description: "支持多份材料,按单份逐项审核。",
|
||||
items: invitationItems.value,
|
||||
},
|
||||
]);
|
||||
];
|
||||
|
||||
if (props.docView?.videoType || props.docView?.videoOssKey || props.docView?.videoLink) {
|
||||
baseSections.push({
|
||||
key: "meetingVideo",
|
||||
title: "会议视频",
|
||||
description: "核对线上会议视频内容或网盘链接。",
|
||||
items: [
|
||||
{
|
||||
id: "meetingVideo",
|
||||
name: props.docView?.videoType === 'link' ? '网盘链接' : fileDisplayName(props.docView?.videoName, props.docView?.videoOssKey),
|
||||
ossKey: props.docView?.videoType === 'link' ? '' : String(props.docView?.videoOssKey ?? "").trim(),
|
||||
link: props.docView?.videoType === 'link' ? String(props.docView?.videoLink ?? "").trim() : '',
|
||||
reviewKey: "meetingVideo",
|
||||
rejectKey: "meetingVideo",
|
||||
rejectLabel: "会议视频",
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
return baseSections;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -180,7 +215,11 @@ const sections = computed(() => [
|
||||
@click="handleChangeAnchorClick(item.reviewKey, $event)"
|
||||
>
|
||||
<div class="audit-doc-card__preview">
|
||||
<div v-if="!item.ossKey" class="audit-doc-card__empty">未上传</div>
|
||||
<div v-if="item.link" class="audit-doc-card__empty" style="flex-direction: column; gap: 8px;">
|
||||
<span style="font-size: 24px;">🔗</span>
|
||||
<span style="color: #409eff; cursor: pointer; font-size: 13px;" @click.stop="openLink(item.link)">打开网盘链接</span>
|
||||
</div>
|
||||
<div v-else-if="!item.ossKey" class="audit-doc-card__empty">未上传</div>
|
||||
<el-image
|
||||
v-else-if="
|
||||
isImageAttachment(item.name, item.ossKey) &&
|
||||
@ -202,6 +241,16 @@ const sections = computed(() => [
|
||||
<el-icon class="audit-doc-card__pdf-icon"><Document /></el-icon>
|
||||
<span class="audit-doc-card__pdf-label">PDF</span>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="isExcelAttachment(item.name, item.ossKey)"
|
||||
class="audit-doc-card__pdf"
|
||||
type="button"
|
||||
:title="item.name"
|
||||
@click.stop="previewFile(item.name, item.ossKey)"
|
||||
>
|
||||
<el-icon class="audit-doc-card__pdf-icon"><Document /></el-icon>
|
||||
<span class="audit-doc-card__pdf-label">EXCEL</span>
|
||||
</button>
|
||||
<el-button
|
||||
v-else
|
||||
size="small"
|
||||
@ -230,8 +279,8 @@ const sections = computed(() => [
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="!item.ossKey"
|
||||
@click.stop="previewFile(item.name, item.ossKey)"
|
||||
:disabled="!item.ossKey && !item.link"
|
||||
@click.stop="item.link ? openLink(item.link) : previewFile(item.name, item.ossKey)"
|
||||
>
|
||||
查看
|
||||
</el-button>
|
||||
|
||||
@ -5,19 +5,24 @@ const visible = defineModel<boolean>({ required: true });
|
||||
|
||||
const props = defineProps<{
|
||||
docPreviewDialogImageUrl: string;
|
||||
/** pdf 使用 iframe,图片使用 img */
|
||||
previewKind?: "image" | "pdf";
|
||||
docPreviewExcelHtml?: string;
|
||||
/** pdf 使用 iframe,图片使用 img,excel 使用 table HTML */
|
||||
previewKind?: "image" | "pdf" | "excel";
|
||||
}>();
|
||||
|
||||
const kind = computed(() => props.previewKind || "image");
|
||||
const dialogTitle = computed(() => (kind.value === "pdf" ? "PDF 预览" : "图片预览"));
|
||||
const dialogTitle = computed(() => {
|
||||
if (kind.value === "pdf") return "PDF 预览";
|
||||
if (kind.value === "excel") return "Excel 预览";
|
||||
return "图片预览";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
width="60%"
|
||||
width="80%"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
>
|
||||
@ -27,6 +32,7 @@ const dialogTitle = computed(() => (kind.value === "pdf" ? "PDF 预览" : "图
|
||||
title="PDF 预览"
|
||||
class="doc-preview-pdf-frame"
|
||||
/>
|
||||
<div v-else-if="kind === 'excel'" class="doc-preview-excel-container" v-html="docPreviewExcelHtml"></div>
|
||||
<div v-else-if="docPreviewDialogImageUrl" class="doc-preview-image-container">
|
||||
<img
|
||||
:src="docPreviewDialogImageUrl"
|
||||
@ -58,4 +64,22 @@ const dialogTitle = computed(() => (kind.value === "pdf" ? "PDF 预览" : "图
|
||||
max-height: 75vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.doc-preview-excel-container {
|
||||
width: 100%;
|
||||
height: 75vh;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.doc-preview-excel-container :deep(table) {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
.doc-preview-excel-container :deep(th),
|
||||
.doc-preview-excel-container :deep(td) {
|
||||
border: 1px solid #dcdfe6;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -37,10 +37,12 @@ const props = withDefaults(defineProps<{
|
||||
title?: string;
|
||||
maxLaborRatio?: RatioLimit;
|
||||
maxCateringRatio?: RatioLimit;
|
||||
lastRejectReason?: string;
|
||||
}>(), {
|
||||
title: "编辑会议",
|
||||
maxLaborRatio: null,
|
||||
maxCateringRatio: null,
|
||||
lastRejectReason: "",
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -109,6 +111,11 @@ watch(locationPath, (value) => {
|
||||
return;
|
||||
}
|
||||
meetingForm.value.location = normalizeLocationText(Array.isArray(value) ? value : []);
|
||||
if (formRef.value) {
|
||||
setTimeout(() => {
|
||||
formRef.value?.clearValidate("location");
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
@ -187,6 +194,14 @@ const handleSave = async () => {
|
||||
|
||||
<template>
|
||||
<el-drawer v-model="visible" :title="props.title" :size="DRAWER_SIZE.xl" destroy-on-close>
|
||||
<el-alert
|
||||
v-if="props.lastRejectReason"
|
||||
:title="`[审核被驳回] 驳回原因:${props.lastRejectReason}`"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-bottom: 12px;"
|
||||
/>
|
||||
<el-form ref="formRef" :model="meetingForm" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item prop="projectName" label="项目名称">
|
||||
<span>{{ meetingForm.projectName || "-" }}</span>
|
||||
|
||||
@ -72,7 +72,22 @@ const summaryStatusText = (rowId: number, statusFormatter: (row: any, column: an
|
||||
<el-table-column label="预算(元)" width="100" >
|
||||
<template #default="{ row }">{{ toYuan(row.budgetCent) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="auditStatus" label="审核状态" width="140" :formatter="statusFormatter" />
|
||||
<el-table-column prop="auditStatus" label="审核状态" width="140">
|
||||
<template #default="{ row, column }">
|
||||
<el-tooltip
|
||||
v-if="(normalizeText(row.auditStatus) === 'REJECTED' || (normalizeText(row.auditStatus) === 'PENDING' && row.lastRejectReason)) && row.lastRejectReason"
|
||||
effect="dark"
|
||||
:content="`驳回原因:${row.lastRejectReason}`"
|
||||
placement="top"
|
||||
:show-after="200"
|
||||
>
|
||||
<el-tag :type="normalizeText(row.auditStatus) === 'REJECTED' ? 'danger' : 'warning'">
|
||||
{{ normalizeText(row.auditStatus) === 'PENDING' ? '已退回' : statusFormatter(row, column, row.auditStatus) }}
|
||||
</el-tag>
|
||||
</el-tooltip>
|
||||
<span v-else>{{ statusFormatter(row, column, row.auditStatus) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- <el-table-column prop="currentAuditNode" label="当前审核节点" width="160" :formatter="auditNodeFormatter" />
|
||||
<el-table-column prop="currentAuditorUserId" label="当前审核节点" width="160" :formatter="auditorNameFormatter" /> -->
|
||||
<el-table-column label="操作" width="320">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { DRAWER_SIZE, LABEL_WIDTH } from "../../../constants/ui";
|
||||
import { computed, ref, nextTick, watch } from "vue";
|
||||
import { Plus, Picture } from "@element-plus/icons-vue";
|
||||
@ -101,6 +101,8 @@ const selectedModuleCode = defineModel<string>("selectedModuleCode", { required:
|
||||
const expertSubModule = defineModel<string>("expertSubModule", { required: true });
|
||||
const selectedExpertLaborPreTaxAmountYuan = defineModel<number>("selectedExpertLaborPreTaxAmountYuan", { required: true });
|
||||
const selectedExpertLaborAfterTaxAmountYuan = defineModel<number>("selectedExpertLaborAfterTaxAmountYuan", { required: true });
|
||||
const meetingVideoType = defineModel<"file" | "link">("meetingVideoType", { default: "file" });
|
||||
const meetingVideoLink = defineModel<string>("meetingVideoLink", { default: "" });
|
||||
|
||||
const props = defineProps<{
|
||||
materialDialogTitle: string;
|
||||
@ -147,6 +149,11 @@ const props = defineProps<{
|
||||
handleThemePhotoPictureChange: (uploadFile: any, uploadFiles: any, maxSizeMb?: number) => void;
|
||||
handleThemePhotoPicturePreview: UploadProps["onPreview"];
|
||||
handleThemePhotoPictureRemove: UploadProps["onRemove"];
|
||||
meetingVideoUploadFileList: any[];
|
||||
beforeMeetingVideoUpload: (rawFile: any, maxSizeMb?: number) => boolean | Promise<any>;
|
||||
handleMeetingVideoPictureChange: (uploadFile: any, uploadFiles: any, maxSizeMb?: number) => void;
|
||||
handleMeetingVideoPictureRemove: UploadProps["onRemove"];
|
||||
meetingForm?: string;
|
||||
invitationUploadFileList: any[];
|
||||
beforeInvitationUpload: (rawFile: any, maxSizeMb?: number) => boolean | Promise<any>;
|
||||
handleInvitationPictureChange: (uploadFile: any, uploadFiles: any, maxSizeMb?: number) => void;
|
||||
@ -206,6 +213,7 @@ const props = defineProps<{
|
||||
checkFormChanged?: () => boolean;
|
||||
doSaveMaterial?: () => Promise<boolean>;
|
||||
jumpToIssueModify?: (issue: any) => void;
|
||||
lastRejectReason?: string;
|
||||
}>();
|
||||
|
||||
const protocolIsPdf = computed(() =>
|
||||
@ -365,6 +373,7 @@ const isMaterialFieldDisabled = (itemKey: string | string[]) =>
|
||||
const canEditReviewedItem = (itemKey: string | string[]) => !isMaterialFieldDisabled(itemKey);
|
||||
const canEditAgenda = computed(() => canEditReviewedItem("agenda"));
|
||||
const canEditSignInSheet = computed(() => canEditReviewedItem("signInSheet"));
|
||||
const canEditMeetingVideo = computed(() => canEditReviewedItem("meetingVideo"));
|
||||
const canEditThemePhoto = computed(() => canEditReviewedItem("themePhoto"));
|
||||
const canEditInvitation = computed(() => !props.materialReadonly);
|
||||
const canEditExpertProfile = computed(() =>
|
||||
@ -613,7 +622,16 @@ watch(
|
||||
|
||||
<template>
|
||||
<el-drawer v-model="materialDialogVisible" size="80vw" :with-header="false" :before-close="handleBeforeCloseDrawer" destroy-on-close class="custom-material-drawer">
|
||||
<div class="material-layout">
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<el-alert
|
||||
v-if="props.lastRejectReason"
|
||||
:title="`[审核被驳回] 驳回原因:${props.lastRejectReason}`"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="flex-shrink: 0;"
|
||||
/>
|
||||
<div class="material-layout" style="flex: 1; height: 0;">
|
||||
<div class="material-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-title">{{ materialDialogTitle }}</div>
|
||||
@ -904,7 +922,6 @@ watch(
|
||||
</div>
|
||||
<div class="material-module-card" v-else-if="selectedModuleCode === 'WRITE_OFF_DOCS'">
|
||||
<el-form label-position="left" :label-width="LABEL_WIDTH.lg">
|
||||
<el-alert title="核销材料按会议实际上传,不再套用模板" type="info" :closable="false" class="mb-md" />
|
||||
<el-form-item label="上传会议日程">
|
||||
<div class="flex gap-md w-full">
|
||||
<div>
|
||||
@ -983,7 +1000,7 @@ watch(
|
||||
:on-change="(f, l) => handleUploadChangeWithCompression(f, l, (uf: any, ufs: any) => handleSignInPictureChange(uf, ufs, 2))"
|
||||
:on-preview="handleSignInPicturePreview"
|
||||
:on-remove="handleSignInPictureRemove"
|
||||
accept=".jpg,.jpeg,.png,.webp,.gif,.bmp,.pdf"
|
||||
accept=".jpg,.jpeg,.png,.webp,.gif,.bmp,.pdf,.xls,.xlsx"
|
||||
>
|
||||
<template #file="{ file }">
|
||||
<MaterialPictureCardFileItem
|
||||
@ -1096,6 +1113,52 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="上传会议视频" v-if="meetingForm === '线上' || meetingForm === '线上+线下'">
|
||||
<div class="flex-col gap-md w-full">
|
||||
<el-radio-group v-model="meetingVideoType" :disabled="!canEditMeetingVideo">
|
||||
<el-radio value="file">上传视频文件</el-radio>
|
||||
<el-radio value="link">填写网盘链接</el-radio>
|
||||
</el-radio-group>
|
||||
|
||||
<div v-if="meetingVideoType === 'file'" class="mt-sm">
|
||||
<el-upload
|
||||
:class="{ 'expert-profile-upload--limit': meetingVideoUploadFileList.length >= 1 }"
|
||||
:file-list="meetingVideoUploadFileList"
|
||||
list-type="picture-card"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:disabled="!canEditMeetingVideo"
|
||||
:before-upload="(f) => beforeMeetingVideoUpload(f, 500)"
|
||||
:on-change="(f, l) => handleUploadChangeWithCompression(f, l, (uf: any, ufs: any) => handleMeetingVideoPictureChange(uf, ufs, 500))"
|
||||
:on-remove="handleMeetingVideoPictureRemove"
|
||||
accept=".mp4,.mov,.avi,.mkv"
|
||||
>
|
||||
<template #file="{ file }">
|
||||
<MaterialPictureCardFileItem
|
||||
:file="file"
|
||||
:disabled="!canEditMaterial || !canEditMeetingVideo"
|
||||
@remove-file="runMaterialUploadRemove(handleMeetingVideoPictureRemove, $event)"
|
||||
/>
|
||||
</template>
|
||||
<el-icon v-if="canEditMeetingVideo && meetingVideoUploadFileList.length < 1"><Plus /></el-icon>
|
||||
</el-upload>
|
||||
<div class="text-secondary mt-xs" style="font-size: 12px">支持 mp4/mov/avi/mkv 格式,大小不超过 500MB</div>
|
||||
</div>
|
||||
|
||||
<div v-if="meetingVideoType === 'link'" class="mt-sm">
|
||||
<el-input
|
||||
v-model="meetingVideoLink"
|
||||
placeholder="请输入百度网盘或外部视频链接"
|
||||
:disabled="!canEditMeetingVideo"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full mt-sm">
|
||||
<el-tag v-if="toReviewResultText('meetingVideo')" size="small" :type="toReviewTagType('meetingVideo')">{{ toReviewResultText('meetingVideo') }}</el-tag>
|
||||
<span v-if="toReviewReasonText('meetingVideo')" class="ml-sm-text-danger">不通过原因:{{ toReviewReasonText("meetingVideo") }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="material-module-card" v-else-if="selectedModuleCode === 'EXPERT_PROFILE'">
|
||||
@ -1320,8 +1383,8 @@ watch(
|
||||
<!-- LABOR_PROTOCOL sub-module -->
|
||||
<template v-else-if="expertSubModule === 'LABOR_PROTOCOL'">
|
||||
<div class="section-card-flat mt-md" v-bind="issueFocusAttrs(currentLaborReviewKey)">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-row :gutter="20" style="row-gap: 16px;">
|
||||
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="12">
|
||||
<div class="attachment-panel">
|
||||
<div class="attachment-header">
|
||||
<span class="attachment-title">劳务协议附件</span>
|
||||
@ -1335,7 +1398,7 @@ watch(
|
||||
|
||||
<div class="attachment-body">
|
||||
<div v-if="laborInfoRestrictedByAgreementOcr" class="attachment-restricted-state">
|
||||
当前劳务信息由“上传劳务费协议”识别导入,不允许查看和修改劳务协议附件。
|
||||
劳务费协议附件已上传
|
||||
</div>
|
||||
<div v-else-if="laborProtocolUploadFileList.length || selectedExpertLabor.protocolOssKey" class="file-preview-card">
|
||||
<el-image
|
||||
@ -1398,14 +1461,14 @@ watch(
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="12">
|
||||
<div class="attachment-panel" style="height: 100%;">
|
||||
<div class="attachment-header">
|
||||
<span class="attachment-title">劳务金额信息</span>
|
||||
</div>
|
||||
<div class="attachment-body" style="justify-content: flex-start; padding: 20px;">
|
||||
<div v-if="laborInfoRestrictedByAgreementOcr" class="attachment-restricted-state attachment-restricted-state--compact">
|
||||
当前劳务信息由“上传劳务费协议”识别导入,不允许查看和修改劳务金额信息。
|
||||
劳务金额信息已填写
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="amount-input-group">
|
||||
@ -1495,7 +1558,6 @@ watch(
|
||||
|
||||
<div class="material-module-card" v-else-if="selectedModuleCode === 'MEETING_INVOICE'">
|
||||
<el-form label-position="left" :label-width="LABEL_WIDTH.lg">
|
||||
<el-alert title="会议发票按分项上传,单个上传位文件数量以分项配置为准,单文件不超2MB" type="info" :closable="false" class="mb-md" />
|
||||
<div
|
||||
v-for="(section, sectionIndex) in meetingInvoiceSectionDefs"
|
||||
:key="section.code"
|
||||
@ -1688,6 +1750,7 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
|
||||
@ -103,7 +103,7 @@
|
||||
<el-form-item label="允许项目超支">
|
||||
<el-switch v-model="projectForm.allowProjectOverBudget" />
|
||||
</el-form-item>
|
||||
<el-form-item label="超支阈值">
|
||||
<el-form-item v-if="projectForm.allowProjectOverBudget" label="超支阈值">
|
||||
<el-input-number v-model="projectForm.overBudgetThresholdRatio" :min="0" :max="1" :step="0.01" class="project-number-input" />
|
||||
</el-form-item>
|
||||
<el-form-item label="发票信息">
|
||||
@ -172,7 +172,7 @@ const defaultForm = () => ({
|
||||
laborAgreementSignType: 1 as 1 | 2,
|
||||
allowMeetingOverBudget: false,
|
||||
allowProjectOverBudget: false,
|
||||
overBudgetThresholdRatio: 0.1,
|
||||
overBudgetThresholdRatio: 0,
|
||||
invoiceInfo: "",
|
||||
hostOwnerUsers: "",
|
||||
hostExecutorUsers: "",
|
||||
|
||||
BIN
patch1.txt
Normal file
BIN
patch1.txt
Normal file
Binary file not shown.
BIN
patch2.txt
Normal file
BIN
patch2.txt
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user