This commit is contained in:
haomingming 2026-06-04 10:42:23 +08:00
parent db10401b13
commit 52fd2e7560
34 changed files with 845 additions and 162 deletions

View File

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

View File

@ -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=? " +

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -264,6 +264,10 @@ public class Project {
return budgetCent;
}
public void setBudgetCent(long budgetCent) {
this.budgetCent = budgetCent;
}
public int getMeetingTotal() {
return meetingTotal;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, "&quot;")
.replace(/'/g, "&#39;");
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);

View File

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

View File

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

View File

@ -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(),

View File

@ -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: "跟随系统",

View File

@ -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,7 +57,19 @@
</el-table-column>
</el-table>
<ProjectEditDrawer
<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"
:source-row="projectDrawerSourceRow"
@ -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;

View File

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

View File

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

View File

@ -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}-总费用金额`)"
>
不通过

View File

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

View File

@ -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图片使用 imgexcel 使用 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>

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

BIN
patch2.txt Normal file

Binary file not shown.