This commit is contained in:
haomingming 2026-06-16 10:32:04 +08:00
parent 52fd2e7560
commit e0b089fbf8
53 changed files with 3674 additions and 577 deletions

View File

@ -62,6 +62,11 @@
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
</dependency>
<dependency>
<groupId>com.belerweb</groupId>
<artifactId>pinyin4j</artifactId>
<version>2.5.1</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>

View File

@ -12,6 +12,11 @@ import com.writeoff.module.expert.model.ExpertBankCardInfo;
import com.writeoff.module.expert.model.ExpertInfo;
import com.writeoff.module.system.service.DataPermissionService;
import com.writeoff.security.AuthContext;
import net.sourceforge.pinyin4j.PinyinHelper;
import net.sourceforge.pinyin4j.format.HanyuPinyinCaseType;
import net.sourceforge.pinyin4j.format.HanyuPinyinOutputFormat;
import net.sourceforge.pinyin4j.format.HanyuPinyinToneType;
import net.sourceforge.pinyin4j.format.HanyuPinyinVCharType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
@ -114,12 +119,31 @@ public class ExpertService {
StringBuilder whereClause = new StringBuilder(
"WHERE e.tenant_id=" + PLATFORM_TENANT_ID + " AND e.is_deleted=0"
);
List<Object> countArgs = new ArrayList<>();
if (keyword != null && !keyword.trim().isEmpty()) {
String kw = keyword.trim().replace("'", "''");
whereClause.append(" AND (e.expert_name LIKE '%").append(kw).append("%' OR e.id_no LIKE '%").append(kw).append("%')");
}
com.writeoff.module.system.service.DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope();
if (!scope.isExpertAll()) {
boolean hasIdScope = !scope.getExpertIds().isEmpty();
boolean hasOwnerScope = scope.isExpertOwnerOnly();
if (!hasIdScope && !hasOwnerScope) {
return new PageResult<ExpertInfo>(new ArrayList<>(), 0, safePage, safeSize);
}
whereClause.append(" AND (1=0");
if (hasIdScope) {
whereClause.append(" OR e.id IN (").append(scope.getExpertIds().stream().map(String::valueOf).collect(Collectors.joining(","))).append(")");
}
if (hasOwnerScope) {
Long userId = AuthContext.userId();
if (userId != null) {
whereClause.append(" OR e.created_by=").append(userId);
}
}
whereClause.append(")");
}
Integer total = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM expert e " + whereClause,
Integer.class
@ -138,7 +162,6 @@ public class ExpertService {
sql.append(whereClause);
sql.append(" ORDER BY e.id DESC LIMIT ").append(safeSize).append(" OFFSET ").append(offset);
List<ExpertInfo> list = jdbcTemplate.query(sql.toString(), EXPERT_ROW_MAPPER);
list = filterByExpertScope(list);
List<ExpertInfo> maskedList = new java.util.ArrayList<ExpertInfo>(list.size());
for (ExpertInfo item : list) {
maskedList.add(maskSensitiveFields(item));
@ -195,11 +218,13 @@ public class ExpertService {
@Transactional(rollbackFor = Exception.class)
public ExpertInfo create(CreateExpertRequest request) {
String idNo = request.getIdNo() == null ? "" : request.getIdNo().trim().toUpperCase();
request.setIdNo(idNo);
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id_no=? AND is_deleted=0",
Integer.class,
PLATFORM_TENANT_ID,
request.getIdNo()
idNo
);
if (count != null && count > 0) {
throw new BusinessException(10001, "身份证号已存在");
@ -617,11 +642,76 @@ public class ExpertService {
name
);
if (byName.isEmpty()) {
throw new BusinessException(10001, label + "不在平台字典内,请先在平台字典中维护");
String pinyinCode = getPinyin(name);
if (pinyinCode.length() > 40) {
pinyinCode = pinyinCode.substring(0, 40);
}
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM platform_dictionary_item WHERE dict_type=? AND dict_code=?",
Integer.class,
dictType,
pinyinCode
);
if (count != null && count > 0) {
int i = 1;
while(true) {
String newCode = pinyinCode + "_" + i;
if (newCode.length() > 50) {
newCode = newCode.substring(newCode.length() - 50);
}
Integer c = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM platform_dictionary_item WHERE dict_type=? AND dict_code=?",
Integer.class, dictType, newCode
);
if (c == null || c == 0) {
pinyinCode = newCode;
break;
}
i++;
}
}
Integer maxSort = jdbcTemplate.queryForObject(
"SELECT MAX(sort_no) FROM platform_dictionary_item WHERE dict_type=?",
Integer.class,
dictType
);
int nextSort = (maxSort == null ? 0 : maxSort) + 10;
jdbcTemplate.update(
"INSERT INTO platform_dictionary_item (dict_type, dict_code, dict_name, sort_no, status, remark, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, 'ENABLED', 'Auto-imported', ?, ?)",
dictType, pinyinCode, name, nextSort, safeUserId(), safeUserId()
);
return new DictionaryItem(pinyinCode, name);
}
return byName.get(0);
}
private String getPinyin(String src) {
if (src == null || src.trim().isEmpty()) {
return "";
}
StringBuilder result = new StringBuilder();
HanyuPinyinOutputFormat format = new HanyuPinyinOutputFormat();
format.setCaseType(HanyuPinyinCaseType.UPPERCASE);
format.setToneType(HanyuPinyinToneType.WITHOUT_TONE);
format.setVCharType(HanyuPinyinVCharType.WITH_V);
for (char c : src.trim().toCharArray()) {
if (Character.toString(c).matches("[\\u4E00-\\u9FA5]+")) {
try {
String[] pinyins = PinyinHelper.toHanyuPinyinStringArray(c, format);
if (pinyins != null && pinyins.length > 0) {
result.append(pinyins[0]);
}
} catch (Exception e) {
result.append(c);
}
} else {
result.append(Character.toUpperCase(c));
}
}
return result.toString();
}
private void validateImportExpert(CreateExpertRequest request, Set<String> batchIdNos, Set<String> batchPhones) {
if (request == null) {
throw new BusinessException(10001, "导入行不能为空");

View File

@ -11,6 +11,11 @@ import com.writeoff.module.file.service.OssService;
import com.writeoff.module.expert.model.ExpertBankCardInfo;
import com.writeoff.module.expert.model.ExpertInfo;
import com.writeoff.security.AuthContext;
import net.sourceforge.pinyin4j.PinyinHelper;
import net.sourceforge.pinyin4j.format.HanyuPinyinCaseType;
import net.sourceforge.pinyin4j.format.HanyuPinyinOutputFormat;
import net.sourceforge.pinyin4j.format.HanyuPinyinToneType;
import net.sourceforge.pinyin4j.format.HanyuPinyinVCharType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
@ -188,11 +193,13 @@ public class PlatformExpertService {
@Transactional(rollbackFor = Exception.class)
public ExpertInfo create(CreateExpertRequest request) {
String idNo = request.getIdNo() == null ? "" : request.getIdNo().trim().toUpperCase();
request.setIdNo(idNo);
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id_no=? AND is_deleted=0",
Integer.class,
PLATFORM_TENANT_ID,
request.getIdNo()
idNo
);
if (count != null && count > 0) {
throw new BusinessException(10001, "身份证号已存在");
@ -614,11 +621,76 @@ public class PlatformExpertService {
name
);
if (byName.isEmpty()) {
throw new BusinessException(10001, label + "不在平台字典内,请先在平台字典中维护");
String pinyinCode = getPinyin(name);
if (pinyinCode.length() > 40) {
pinyinCode = pinyinCode.substring(0, 40);
}
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM platform_dictionary_item WHERE dict_type=? AND dict_code=?",
Integer.class,
dictType,
pinyinCode
);
if (count != null && count > 0) {
int i = 1;
while(true) {
String newCode = pinyinCode + "_" + i;
if (newCode.length() > 50) {
newCode = newCode.substring(newCode.length() - 50);
}
Integer c = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM platform_dictionary_item WHERE dict_type=? AND dict_code=?",
Integer.class, dictType, newCode
);
if (c == null || c == 0) {
pinyinCode = newCode;
break;
}
i++;
}
}
Integer maxSort = jdbcTemplate.queryForObject(
"SELECT MAX(sort_no) FROM platform_dictionary_item WHERE dict_type=?",
Integer.class,
dictType
);
int nextSort = (maxSort == null ? 0 : maxSort) + 10;
jdbcTemplate.update(
"INSERT INTO platform_dictionary_item (dict_type, dict_code, dict_name, sort_no, status, remark, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, 'ENABLED', 'Auto-imported', ?, ?)",
dictType, pinyinCode, name, nextSort, safeUserId(), safeUserId()
);
return new DictionaryItem(pinyinCode, name);
}
return byName.get(0);
}
private String getPinyin(String src) {
if (src == null || src.trim().isEmpty()) {
return "";
}
StringBuilder result = new StringBuilder();
HanyuPinyinOutputFormat format = new HanyuPinyinOutputFormat();
format.setCaseType(HanyuPinyinCaseType.UPPERCASE);
format.setToneType(HanyuPinyinToneType.WITHOUT_TONE);
format.setVCharType(HanyuPinyinVCharType.WITH_V);
for (char c : src.trim().toCharArray()) {
if (Character.toString(c).matches("[\\u4E00-\\u9FA5]+")) {
try {
String[] pinyins = PinyinHelper.toHanyuPinyinStringArray(c, format);
if (pinyins != null && pinyins.length > 0) {
result.append(pinyins[0]);
}
} catch (Exception e) {
result.append(c);
}
} else {
result.append(Character.toUpperCase(c));
}
}
return result.toString();
}
private DictionaryItem resolveOptionalDictionaryItem(String dictType, String dictCode, String dictName, String label) {
boolean hasCode = dictCode != null && !dictCode.trim().isEmpty();
boolean hasName = dictName != null && !dictName.trim().isEmpty();

View File

@ -172,7 +172,7 @@ public class MeetingController {
}
@PutMapping("/{id}")
@RequirePermission(value = "meeting.create", dataScope = DataScopeType.MEETING, auditAction = "MEETING_UPDATE")
@RequirePermission(value = "meeting.update", dataScope = DataScopeType.MEETING, auditAction = "MEETING_UPDATE")
public ApiResponse<Meeting> update(@PathVariable("id") Long id,
@RequestBody @Valid CreateMeetingRequest request) {
return ApiResponse.success(meetingService.update(id, request));

View File

@ -2050,6 +2050,8 @@ public class MeetingMaterialService {
requireMeetingInvoiceAttachmentList(map.get("attachments"));
}
}
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw new BusinessException(10001, "资料内容格式错误或缺少必填字段");
}
@ -3170,7 +3172,7 @@ public class MeetingMaterialService {
}
Collection<?> list = (Collection<?>) value;
if (list.isEmpty()) {
throw new BusinessException(10001, "提交失败,缺少必填字段: invoices");
return;
}
for (Object item : list) {
if (!(item instanceof Map)) {

View File

@ -230,8 +230,12 @@ public class MeetingService {
double cateringRatio = request.getCateringRatio() == null ? project.getCateringFeeRatio() : normalizeRatio(request.getCateringRatio(), "餐费占比");
assertMeetingRatiosWithinProject(project, laborRatio, cateringRatio);
long defaultBudgetCent = calculateDefaultMeetingBudgetCent(project);
if (defaultBudgetCent <= 0L) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "默认会议预算必须大于 0");
long initialBudgetCent = request.getBudgetCent() != null ? request.getBudgetCent() : defaultBudgetCent;
if (!project.isAllowMeetingOverBudget() && initialBudgetCent > defaultBudgetCent) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "当前项目不允许会议预算超出默认分配额度");
}
if (initialBudgetCent <= 0L) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议预算必须大于 0");
}
String now = nowIsoSeconds();
Meeting meeting = new Meeting(
@ -243,7 +247,7 @@ public class MeetingService {
request.getLocation(),
request.getStartTime(),
request.getEndTime(),
defaultBudgetCent,
initialBudgetCent,
laborRatio,
cateringRatio,
MeetingStatus.NOT_STARTED,
@ -401,6 +405,15 @@ public class MeetingService {
double laborRatio = request.getLaborRatio() == null ? existing.getLaborRatio() : normalizeRatio(request.getLaborRatio(), "劳务占比");
double cateringRatio = request.getCateringRatio() == null ? existing.getCateringRatio() : normalizeRatio(request.getCateringRatio(), "餐费占比");
assertMeetingRatiosWithinProject(project, laborRatio, cateringRatio);
long newBudgetCent = request.getBudgetCent() != null ? request.getBudgetCent() : existing.getBudgetCent();
if (!project.isAllowMeetingOverBudget() && newBudgetCent > existing.getBudgetCent()) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "当前项目不允许调高会议预算");
}
if (newBudgetCent <= 0L) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议预算必须大于 0");
}
Meeting updated = new Meeting(
existing.getId(),
existing.getProjectId(),
@ -410,7 +423,7 @@ public class MeetingService {
request.getLocation(),
request.getStartTime(),
request.getEndTime(),
request.getBudgetCent(),
newBudgetCent,
laborRatio,
cateringRatio,
existing.getStatus(),

View File

@ -144,8 +144,8 @@ public class MeetingSummaryExportService {
projectName,
queryTenantName(tenantId)
);
String meetingCategory = stringValue(meeting.get("meeting_category"));
String location = stringValue(meeting.get("location"));
String meetingCategory = resolveDictName("MEETING_CATEGORY", stringValue(meeting.get("meeting_category")));
String location = resolveDictName("MEETING_LOCATION", stringValue(meeting.get("location")));
String startTime = stringValue(meeting.get("start_time"));
String endTime = stringValue(meeting.get("end_time"));
String guestCountText = formatNumber(basicInfo.get("guestCount"));
@ -240,6 +240,26 @@ public class MeetingSummaryExportService {
}
}
private String resolveDictName(String dictType, String dictCode) {
if (dictCode == null || dictCode.trim().isEmpty()) {
return dictCode;
}
try {
List<String> names = jdbcTemplate.queryForList(
"SELECT dict_name FROM platform_dictionary_item WHERE dict_type=? AND dict_code=? AND is_deleted=0 LIMIT 1",
String.class,
dictType,
dictCode
);
if (!names.isEmpty() && names.get(0) != null) {
return names.get(0);
}
} catch (Exception ex) {
// ignore
}
return dictCode;
}
private List<Long> parseIdList(Object value) {
if (!(value instanceof List)) {
return Collections.emptyList();

View File

@ -93,9 +93,9 @@ public class UserController {
@PostMapping("/{id}/reset-password")
@RequirePermission(value = "user.password.reset", dataScope = DataScopeType.TENANT, auditAction = "USER_RESET_PASSWORD")
public ApiResponse<String> resetPassword(@PathVariable("id") Long id, @RequestBody @Valid ResetPasswordRequest request) {
systemUserService.resetPassword(id, request);
return ApiResponse.success("OK");
public ApiResponse<String> resetPassword(@PathVariable("id") Long id) {
systemUserService.resetPassword(id);
return ApiResponse.success("密码已重置");
}
@GetMapping("/{id}/role-history")

View File

@ -22,6 +22,7 @@ import com.writeoff.module.system.model.UserRoleHistory;
import com.writeoff.security.AuthContext;
import com.writeoff.security.PasswordCodecService;
import com.writeoff.security.PasswordPolicyService;
import com.writeoff.security.PasswordSetupService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
@ -55,7 +56,9 @@ public class SystemUserService {
private final PasswordPolicyService passwordPolicyService;
private final PasswordCodecService passwordCodecService;
private final TransactionTemplate transactionTemplate;
private final PasswordSetupService passwordSetupService;
private final ObjectMapper objectMapper = new ObjectMapper();
private final String frontendBaseUrl;
private static final RowMapper<SystemUser> USER_ROW_MAPPER = (rs, n) -> new SystemUser(
rs.getLong("id"),
@ -97,13 +100,17 @@ public class SystemUserService {
NotificationDispatchService notificationDispatchService,
PasswordPolicyService passwordPolicyService,
PasswordCodecService passwordCodecService,
PlatformTransactionManager transactionManager) {
PlatformTransactionManager transactionManager,
PasswordSetupService passwordSetupService,
@org.springframework.beans.factory.annotation.Value("${app.frontend-base-url:http://localhost:5173}") String frontendBaseUrl) {
this.jdbcTemplate = jdbcTemplate;
this.dataPermissionService = dataPermissionService;
this.notificationDispatchService = notificationDispatchService;
this.passwordPolicyService = passwordPolicyService;
this.passwordCodecService = passwordCodecService;
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.passwordSetupService = passwordSetupService;
this.frontendBaseUrl = frontendBaseUrl;
}
public PageResult<SystemUser> listUsers(int pageNo, int pageSize, Boolean includeDeleted) {
@ -179,10 +186,8 @@ public class SystemUserService {
final String phone = request.getPhone() == null ? "" : request.getPhone().trim();
final String email = request.getEmail() == null ? "" : request.getEmail().trim();
final String rawPassword = request.getPassword() == null ? "" : request.getPassword().trim();
if (rawPassword.isEmpty()) {
throw new BusinessException(10001, "\u5bc6\u7801\u4e0d\u80fd\u4e3a\u7a7a");
}
passwordPolicyService.validate(rawPassword);
final String finalPassword = rawPassword.isEmpty() ? ("Tmp@" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8) + "aA1") : rawPassword;
passwordPolicyService.validate(finalPassword);
final String validFrom = request.getValidFrom() == null || request.getValidFrom().trim().isEmpty()
? LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
: normalizeDateTimeString(request.getValidFrom());
@ -191,7 +196,7 @@ public class SystemUserService {
: normalizeDateTimeString(request.getValidTo());
return transactionTemplate.execute(status -> {
assertPhoneAvailable(phone, null);
String passwordHash = passwordCodecService.encode(rawPassword);
String passwordHash = passwordCodecService.encode(finalPassword);
String tenantSwitchAccountKey = resolveTenantSwitchAccountKeyByPhone(phone, null);
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
@ -217,7 +222,8 @@ public class SystemUserService {
}, keyHolder);
Long id = keyHolder.getKey() == null ? null : keyHolder.getKey().longValue();
autoAssignExecutorRoleWhenCreatorIsProjectExecutor(id);
sendUserCreatedMail(id, userName, phone, email, validFrom, validTo);
String setupLink = passwordSetupService.issueUserSetupLink(tenantId(), id, safeUserId());
sendUserCreatedMail(id, userName, phone, email, finalPassword, validFrom, validTo, setupLink);
return new SystemUser(id, userName, phone, email, "ENABLED", validFrom, validTo, "", "");
});
}
@ -248,8 +254,8 @@ public class SystemUserService {
ps.setTimestamp(idx++, validTo == null ? Timestamp.valueOf(LocalDateTime.of(2099, 12, 31, 23, 59, 59)) : validTo);
ps.setLong(idx++, operator);
if (request.getPassword() != null && !request.getPassword().trim().isEmpty()) {
passwordPolicyService.validate(request.getPassword());
ps.setString(idx++, passwordCodecService.encode(request.getPassword()));
passwordPolicyService.validate(request.getPassword().trim());
ps.setString(idx++, passwordCodecService.encode(request.getPassword().trim()));
}
ps.setLong(idx++, tenantId());
ps.setLong(idx, userId);
@ -413,14 +419,54 @@ public class SystemUserService {
);
}
public void resetPassword(Long userId, ResetPasswordRequest request) {
public void resetPassword(Long userId) {
assertUserExists(userId);
passwordPolicyService.validate(request.getNewPassword());
jdbcTemplate.update(
"UPDATE sys_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
passwordCodecService.encode(request.getNewPassword()),
SystemUser user = jdbcTemplate.queryForObject(
"SELECT id, user_name, phone, email, status, valid_from, valid_to FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1",
(rs, rowNum) -> new SystemUser(
rs.getLong("id"),
rs.getString("user_name"),
rs.getString("phone"),
rs.getString("email"),
rs.getString("status"),
rs.getString("valid_from"),
rs.getString("valid_to")
),
tenantId(),
userId
);
String setupLink = passwordSetupService.issueUserSetupLink(tenantId(), userId, safeUserId());
List<Map<String, Object>> tenants = jdbcTemplate.queryForList(
"SELECT tenant_code, tenant_name FROM tenant WHERE id=? LIMIT 1",
tenantId()
);
String tenantCode = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_code"));
String tenantName = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_name"));
Map<String, Object> variables = new LinkedHashMap<String, Object>();
variables.put("userId", userId);
variables.put("targetUserId", userId);
variables.put("userName", user.getUserName());
variables.put("phone", user.getPhone());
variables.put("email", user.getEmail());
variables.put("tenantCode", tenantCode);
variables.put("tenantName", tenantName);
variables.put("setupLink", setupLink);
DispatchNotificationRequest dispatchRequest = new DispatchNotificationRequest();
dispatchRequest.setIdempotencyKey("user-password-reset-" + tenantId() + "-" + userId + "-" + System.currentTimeMillis());
dispatchRequest.setEventCode("USER_PASSWORD_RESET");
dispatchRequest.setBizType("USER");
dispatchRequest.setBizId("user-" + userId);
try {
dispatchRequest.setVariablesJson(objectMapper.writeValueAsString(variables));
} catch (Exception e) {
log.error("Failed to write variables json", e);
}
notificationDispatchService.dispatch(dispatchRequest);
}
public void changeMyPassword(Long userId, String oldPassword, String newPassword) {
@ -707,8 +753,8 @@ public class SystemUserService {
if (item.getUserName() == null || item.getUserName().trim().isEmpty()) {
throw new BusinessException(10001, "用户名不能为空");
}
if (item.getPassword() == null || item.getPassword().trim().isEmpty()) {
throw new BusinessException(10001, "密码不能为空");
if (item.getPassword() != null && !item.getPassword().trim().isEmpty()) {
passwordPolicyService.validate(item.getPassword().trim());
}
ImportValidationUtils.validatePhone(item.getPhone());
ImportValidationUtils.validateRequiredEmail(item.getEmail());
@ -717,7 +763,6 @@ public class SystemUserService {
if (!batchPhones.add(phone)) {
throw new BusinessException(10001, "批次内手机号重复");
}
passwordPolicyService.validate(item.getPassword().trim());
String roleCode = ImportValidationUtils.trim(item.getRoleCode());
if (roleCode.isEmpty()) {
return null;
@ -750,7 +795,7 @@ public class SystemUserService {
: ex.getMessage();
}
private void sendUserCreatedMail(Long userId, String userName, String phone, String email, String validFrom, String validTo) {
private void sendUserCreatedMail(Long userId, String userName, String phone, String email, String rawPassword, String validFrom, String validTo, String setupLink) {
if (userId == null || userId <= 0) {
return;
}
@ -761,18 +806,26 @@ public class SystemUserService {
);
String tenantCode = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_code"));
String tenantName = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_name"));
String loginPath = "/" + tenantCode + "/login";
String baseUrl = frontendBaseUrl == null ? "" : frontendBaseUrl;
while (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
String loginPath = baseUrl.isEmpty() ? "/" + tenantCode + "/login" : baseUrl + "/" + tenantCode + "/login";
String displayValidTo = (validTo != null && validTo.contains("2099-12-31")) ? "长期有效" : validTo;
Map<String, Object> variables = new LinkedHashMap<String, Object>();
variables.put("userId", userId);
variables.put("targetUserId", userId);
variables.put("userName", userName);
variables.put("phone", phone);
variables.put("email", email);
variables.put("password", rawPassword);
variables.put("validFrom", validFrom);
variables.put("validTo", validTo);
variables.put("validTo", displayValidTo);
variables.put("tenantCode", tenantCode);
variables.put("tenantName", tenantName);
variables.put("loginPath", loginPath);
variables.put("loginPath", setupLink != null ? setupLink : loginPath);
variables.put("setupLink", setupLink != null ? setupLink : "");
DispatchNotificationRequest request = new DispatchNotificationRequest();
request.setIdempotencyKey("user-created-" + tenantId() + "-" + userId);
request.setEventCode("USER_CREATED");

View File

@ -270,7 +270,8 @@ public class TenantService {
);
String setupLink = passwordSetupService.issueTenantAdminSetupLink(tenantId, uid, safeUserId());
sendTenantAdminMail(tenantId, request, action, setupLink);
// 移除了发送邮件功能
// sendTenantAdminMail(tenantId, request, action, setupLink);
java.util.Map<String, Object> data = new java.util.LinkedHashMap<String, Object>();
data.put("tenantId", tenantId);
@ -278,6 +279,7 @@ public class TenantService {
data.put("roleId", roleId);
data.put("roleCode", roleCode);
data.put("action", action);
data.put("setupLink", setupLink);
return data;
}

View File

@ -39,11 +39,23 @@ public class TemplateController {
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "scopeType", required = false) String scopeType,
@RequestParam(value = "bizScene", required = false) String bizScene,
@RequestParam(value = "watermarkEnabled", required = false) Boolean watermarkEnabled,
@RequestParam(value = "effectiveStatus", required = false) String effectiveStatus,
@RequestParam(value = "pageNo", defaultValue = "1") int pageNo,
@RequestParam(value = "pageSize", defaultValue = "20") int pageSize) {
return ApiResponse.success(templateService.list(templateName, templateType, status, scopeType, bizScene, watermarkEnabled, effectiveStatus, pageNo, pageSize));
return ApiResponse.success(templateService.list(templateName, templateType, status, scopeType, bizScene, effectiveStatus, pageNo, pageSize));
}
@GetMapping("/view-list")
@RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_VIEW_LIST")
public ApiResponse<PageResult<TemplateInfo>> viewList(
@RequestParam(value = "templateName", required = false) String templateName,
@RequestParam(value = "templateType", required = false) String templateType,
@RequestParam(value = "scopeType", required = false) String scopeType,
@RequestParam(value = "bizScene", required = false) String bizScene,
@RequestParam(value = "effectiveStatus", required = false) String effectiveStatus,
@RequestParam(value = "pageNo", defaultValue = "1") int pageNo,
@RequestParam(value = "pageSize", defaultValue = "20") int pageSize) {
return ApiResponse.success(templateService.listView(templateName, templateType, scopeType, bizScene, effectiveStatus, pageNo, pageSize));
}
@GetMapping("/published-options")
@ -98,6 +110,12 @@ public class TemplateController {
return ApiResponse.success(templateService.create(request));
}
@PostMapping("/{id}/update")
@RequirePermission(value = "template.update", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_UPDATE")
public ApiResponse<TemplateInfo> update(@PathVariable("id") Long id, @RequestBody @Valid com.writeoff.module.template.dto.UpdateTemplateRequest request) {
return ApiResponse.success(templateService.update(id, request));
}
@PostMapping("/upload-sign")
@RequirePermission(value = "template.create", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_UPLOAD_SIGN")
public ApiResponse<Map<String, Object>> uploadSign(@RequestBody @Valid TemplateUploadSignRequest request) {
@ -105,7 +123,7 @@ public class TemplateController {
}
@GetMapping("/{id}/versions")
@RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_VERSIONS")
@RequirePermission(value = "template.detail.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_VERSIONS")
public ApiResponse<List<TemplateVersionInfo>> versions(@PathVariable("id") Long id) {
return ApiResponse.success(templateService.versions(id));
}
@ -148,16 +166,10 @@ public class TemplateController {
return ApiResponse.success(templateService.download(id, request.getRemoteAddr(), request.getHeader("User-Agent")));
}
@GetMapping("/{id}/download-watermark")
@RequirePermission(value = "template.download", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_DOWNLOAD_WATERMARK")
public ApiResponse<Map<String, Object>> downloadWatermark(@PathVariable("id") Long id,
@RequestParam(value = "watermarkText", required = false) String watermarkText,
HttpServletRequest request) {
return ApiResponse.success(templateService.downloadWatermark(id, watermarkText, request.getRemoteAddr(), request.getHeader("User-Agent")));
}
@GetMapping("/{id}/versions/diff")
@RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_VERSION_DIFF")
@RequirePermission(value = "template.detail.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_VERSION_DIFF")
public ApiResponse<Map<String, Object>> versionDiff(@PathVariable("id") Long id,
@RequestParam(value = "leftVersionNo", required = false) Integer leftVersionNo,
@RequestParam(value = "rightVersionNo", required = false) Integer rightVersionNo) {
@ -172,7 +184,6 @@ public class TemplateController {
@RequestParam(value = "userId", required = false) Long userId,
@RequestParam(value = "userKeyword", required = false) String userKeyword,
@RequestParam(value = "versionNo", required = false) Integer versionNo,
@RequestParam(value = "downloadType", required = false) String downloadType,
@RequestParam(value = "ip", required = false) String ip,
@RequestParam(value = "downloadedFrom", required = false) String downloadedFrom,
@RequestParam(value = "downloadedTo", required = false) String downloadedTo,
@ -184,7 +195,6 @@ public class TemplateController {
userId,
userKeyword,
versionNo,
downloadType,
ip,
downloadedFrom,
downloadedTo,

View File

@ -18,7 +18,6 @@ public class CreateTemplateRequest {
private String changeLog;
private String effectiveFrom;
private String effectiveTo;
private Boolean watermarkEnabled;
private Integer downloadRateLimitPerHour;
public String getTemplateName() {
@ -109,13 +108,6 @@ public class CreateTemplateRequest {
this.effectiveTo = effectiveTo;
}
public Boolean getWatermarkEnabled() {
return watermarkEnabled;
}
public void setWatermarkEnabled(Boolean watermarkEnabled) {
this.watermarkEnabled = watermarkEnabled;
}
public Integer getDownloadRateLimitPerHour() {
return downloadRateLimitPerHour;

View File

@ -0,0 +1,100 @@
package com.writeoff.module.template.dto;
import javax.validation.constraints.NotBlank;
public class UpdateTemplateRequest {
@NotBlank(message = "模板名称不能为空")
private String templateName;
@NotBlank(message = "模板类型不能为空")
private String templateType;
@NotBlank(message = "适用范围不能为空")
private String scopeType;
private Long projectId;
private Long meetingId;
private Long scopeId;
private String bizScene;
private String effectiveFrom;
private String effectiveTo;
private Integer downloadRateLimitPerHour;
public String getTemplateName() {
return templateName;
}
public void setTemplateName(String templateName) {
this.templateName = templateName;
}
public String getTemplateType() {
return templateType;
}
public void setTemplateType(String templateType) {
this.templateType = templateType;
}
public String getScopeType() {
return scopeType;
}
public void setScopeType(String scopeType) {
this.scopeType = scopeType;
}
public Long getProjectId() {
return projectId;
}
public void setProjectId(Long projectId) {
this.projectId = projectId;
}
public Long getMeetingId() {
return meetingId;
}
public void setMeetingId(Long meetingId) {
this.meetingId = meetingId;
}
public Long getScopeId() {
return scopeId;
}
public void setScopeId(Long scopeId) {
this.scopeId = scopeId;
}
public String getBizScene() {
return bizScene;
}
public void setBizScene(String bizScene) {
this.bizScene = bizScene;
}
public String getEffectiveFrom() {
return effectiveFrom;
}
public void setEffectiveFrom(String effectiveFrom) {
this.effectiveFrom = effectiveFrom;
}
public String getEffectiveTo() {
return effectiveTo;
}
public void setEffectiveTo(String effectiveTo) {
this.effectiveTo = effectiveTo;
}
public Integer getDownloadRateLimitPerHour() {
return downloadRateLimitPerHour;
}
public void setDownloadRateLimitPerHour(Integer downloadRateLimitPerHour) {
this.downloadRateLimitPerHour = downloadRateLimitPerHour;
}
}

View File

@ -10,7 +10,6 @@ public class TemplateDownloadLogInfo {
private String userPhone;
private String objectKey;
private String downloadType;
private String watermarkText;
private Long projectId;
private Long meetingId;
private String ip;
@ -18,7 +17,7 @@ public class TemplateDownloadLogInfo {
private String downloadedAt;
public TemplateDownloadLogInfo(Long id, Long templateId, String templateName, Integer versionNo, Long userId, String userName, String userPhone,
String objectKey, String downloadType, String watermarkText, Long projectId, Long meetingId,
String objectKey, String downloadType, Long projectId, Long meetingId,
String ip, String userAgent, String downloadedAt) {
this.id = id;
this.templateId = templateId;
@ -29,7 +28,6 @@ public class TemplateDownloadLogInfo {
this.userPhone = userPhone;
this.objectKey = objectKey;
this.downloadType = downloadType;
this.watermarkText = watermarkText;
this.projectId = projectId;
this.meetingId = meetingId;
this.ip = ip;
@ -73,9 +71,7 @@ public class TemplateDownloadLogInfo {
return downloadType;
}
public String getWatermarkText() {
return watermarkText;
}
public Long getProjectId() {
return projectId;

View File

@ -14,13 +14,12 @@ public class TemplateInfo {
private String currentObjectKey;
private String effectiveFrom;
private String effectiveTo;
private Boolean watermarkEnabled;
private Integer downloadRateLimitPerHour;
private String createdAt;
private String updatedAt;
public TemplateInfo(Long id, String templateName, String templateType, String scopeType, Long projectId, Long meetingId, Long scopeId, String bizScene,
String status, Integer currentVersionNo, String currentObjectKey, String effectiveFrom, String effectiveTo, Boolean watermarkEnabled, Integer downloadRateLimitPerHour,
String status, Integer currentVersionNo, String currentObjectKey, String effectiveFrom, String effectiveTo, Integer downloadRateLimitPerHour,
String createdAt, String updatedAt) {
this.id = id;
this.templateName = templateName;
@ -35,7 +34,6 @@ public class TemplateInfo {
this.currentObjectKey = currentObjectKey;
this.effectiveFrom = effectiveFrom;
this.effectiveTo = effectiveTo;
this.watermarkEnabled = watermarkEnabled;
this.downloadRateLimitPerHour = downloadRateLimitPerHour;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
@ -93,9 +91,6 @@ public class TemplateInfo {
return effectiveTo;
}
public Boolean getWatermarkEnabled() {
return watermarkEnabled;
}
public Integer getDownloadRateLimitPerHour() {
return downloadRateLimitPerHour;

View File

@ -59,7 +59,6 @@ public class TemplateService {
rs.getString("current_object_key"),
rs.getString("effective_from"),
rs.getString("effective_to"),
rs.getInt("watermark_enabled") == 1,
rs.getInt("download_rate_limit_per_hour"),
rs.getString("created_at"),
rs.getString("updated_at")
@ -86,7 +85,6 @@ public class TemplateService {
rs.getString("user_phone"),
rs.getString("object_key"),
rs.getString("download_type"),
rs.getString("watermark_text"),
rs.getObject("project_id") == null ? null : rs.getLong("project_id"),
rs.getObject("meeting_id") == null ? null : rs.getLong("meeting_id"),
rs.getString("ip"),
@ -114,7 +112,6 @@ public class TemplateService {
String status,
String scopeType,
String bizScene,
Boolean watermarkEnabled,
String effectiveStatus,
int pageNo,
int pageSize) {
@ -150,10 +147,7 @@ public class TemplateService {
whereSql.append(" AND t.biz_scene=?");
whereArgs.add(normalizedBizScene);
}
if (watermarkEnabled != null) {
whereSql.append(" AND t.watermark_enabled=?");
whereArgs.add(Boolean.TRUE.equals(watermarkEnabled) ? 1 : 0);
}
appendEffectiveStatusFilter(whereSql, normalizedEffectiveStatus);
Integer total = jdbcTemplate.queryForObject(
@ -176,6 +170,16 @@ public class TemplateService {
return new PageResult<>(list, totalCount, safePage, safeSize);
}
public PageResult<TemplateInfo> listView(String templateName,
String templateType,
String scopeType,
String bizScene,
String effectiveStatus,
int pageNo,
int pageSize) {
return list(templateName, templateType, "PUBLISHED", scopeType, bizScene, effectiveStatus, pageNo, pageSize);
}
public List<TemplateInfo> listPublishedOptions(String bizScene) {
String normalizedBizScene = normalizeOptionalBizScene(bizScene);
StringBuilder sql = new StringBuilder(templateSelectSql())
@ -248,8 +252,8 @@ public class TemplateService {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO template (tenant_id, template_name, template_type, scope_type, project_id, meeting_id, scope_id, biz_scene, status, current_version_no, effective_from, effective_to, watermark_enabled, download_rate_limit_per_hour, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'DRAFT', 1, STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), ?, ?, ?, ?)",
"INSERT INTO template (tenant_id, template_name, template_type, scope_type, project_id, meeting_id, scope_id, biz_scene, status, current_version_no, effective_from, effective_to, download_rate_limit_per_hour, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'DRAFT', 1, STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
);
ps.setLong(1, tenantId());
@ -262,10 +266,9 @@ public class TemplateService {
ps.setString(8, normalizeBizScene(request.getBizScene()));
ps.setString(9, effectiveFrom);
ps.setString(10, effectiveTo);
ps.setInt(11, Boolean.TRUE.equals(request.getWatermarkEnabled()) ? 1 : 0);
ps.setInt(12, downloadRateLimitPerHour);
ps.setInt(11, downloadRateLimitPerHour);
ps.setLong(12, userId);
ps.setLong(13, userId);
ps.setLong(14, userId);
return ps;
}, keyHolder);
Long templateId = keyHolder.getKey() == null ? null : keyHolder.getKey().longValue();
@ -282,6 +285,36 @@ public class TemplateService {
return findById(validTemplateId);
}
@Transactional(rollbackFor = Exception.class)
public TemplateInfo update(Long templateId, com.writeoff.module.template.dto.UpdateTemplateRequest request) {
TemplateInfo template = findById(templateId);
assertTemplateEditable(template);
String scopeType = normalizeScope(request.getScopeType());
String templateType = normalizeTemplateType(request.getTemplateType());
String effectiveFrom = normalizeOptionalDateTime(request.getEffectiveFrom(), "effectiveFrom");
String effectiveTo = normalizeOptionalDateTime(request.getEffectiveTo(), "effectiveTo");
Integer downloadRateLimitPerHour = normalizeDownloadRateLimit(request.getDownloadRateLimitPerHour());
assertEffectiveRangeValid(effectiveFrom, effectiveTo);
assertTypeOptionEnabled(templateType);
jdbcTemplate.update(
"UPDATE template SET template_name=?, template_type=?, scope_type=?, project_id=?, meeting_id=?, scope_id=?, biz_scene=?, effective_from=STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), effective_to=STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), download_rate_limit_per_hour=?, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?",
request.getTemplateName(),
templateType,
scopeType,
request.getProjectId(),
request.getMeetingId(),
request.getScopeId(),
normalizeBizScene(request.getBizScene()),
effectiveFrom,
effectiveTo,
downloadRateLimitPerHour,
safeUserId(),
tenantId(),
templateId
);
return findById(templateId);
}
public List<TemplateVersionInfo> versions(Long templateId) {
assertTemplateExists(templateId);
return jdbcTemplate.query(
@ -451,15 +484,14 @@ public class TemplateService {
assertTemplateEffectiveNow(template, "下载");
assertDownloadRateLimit(template, userId);
jdbcTemplate.update(
"INSERT INTO template_download_log (tenant_id, template_id, version_no, user_id, object_key, download_type, watermark_text, project_id, meeting_id, ip, user_agent) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO template_download_log (tenant_id, template_id, version_no, user_id, object_key, download_type, project_id, meeting_id, ip, user_agent) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
tenantId(),
template.getId(),
template.getCurrentVersionNo(),
userId,
template.getCurrentObjectKey(),
"NORMAL",
null,
template.getProjectId(),
template.getMeetingId(),
ip,
@ -473,46 +505,6 @@ public class TemplateService {
return result;
}
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> downloadWatermark(Long templateId, String watermarkText, String ip, String userAgent) {
TemplateInfo template = findById(templateId);
if ("DISABLED".equalsIgnoreCase(template.getStatus()) || "ARCHIVED".equalsIgnoreCase(template.getStatus())) {
throw new BusinessException(10003, "模板已停用,无法下载");
}
if (template.getCurrentObjectKey() == null || template.getCurrentObjectKey().trim().isEmpty()) {
throw new BusinessException(10003, "模板当前版本文件不存在");
}
String signedUrl = ossService.generateDownloadUrl(template.getCurrentObjectKey());
Long userId = safeUserId();
assertTemplateDownloadAllowed(template);
if (!template.getWatermarkEnabled()) {
throw new BusinessException(10003, "模板未启用水印下载");
}
assertTemplateEffectiveNow(template, "水印下载");
assertDownloadRateLimit(template, userId);
jdbcTemplate.update(
"INSERT INTO template_download_log (tenant_id, template_id, version_no, user_id, object_key, download_type, watermark_text, project_id, meeting_id, ip, user_agent) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
tenantId(),
template.getId(),
template.getCurrentVersionNo(),
userId,
template.getCurrentObjectKey(),
"WATERMARK",
watermarkText == null ? null : watermarkText.trim(),
template.getProjectId(),
template.getMeetingId(),
ip,
userAgent
);
Map<String, Object> result = new LinkedHashMap<String, Object>();
result.put("templateId", template.getId());
result.put("versionNo", template.getCurrentVersionNo());
result.put("objectKey", template.getCurrentObjectKey());
result.put("signedUrl", signedUrl);
result.put("watermarkText", watermarkText == null ? "" : watermarkText.trim());
return result;
}
public Map<String, Object> versionDiff(Long templateId, Integer leftVersionNo, Integer rightVersionNo) {
assertTemplateExists(templateId);
@ -548,7 +540,6 @@ public class TemplateService {
Long userId,
String userKeyword,
Integer versionNo,
String downloadType,
String ip,
String downloadedFrom,
String downloadedTo,
@ -559,7 +550,6 @@ public class TemplateService {
int offset = (safePage - 1) * safeSize;
String normalizedTemplateName = trimToNull(templateName);
String normalizedUserKeyword = trimToNull(userKeyword);
String normalizedDownloadType = normalizeOptionalDownloadType(downloadType);
String normalizedIp = trimToNull(ip);
String normalizedDownloadedFrom = trimToNull(downloadedFrom);
String normalizedDownloadedTo = trimToNull(downloadedTo);
@ -598,10 +588,7 @@ public class TemplateService {
whereSql.append(" AND l.version_no=?");
whereArgs.add(versionNo);
}
if (normalizedDownloadType != null) {
whereSql.append(" AND l.download_type=?");
whereArgs.add(normalizedDownloadType);
}
if (normalizedIp != null) {
whereSql.append(" AND l.ip LIKE ?");
whereArgs.add("%" + normalizedIp + "%");
@ -628,7 +615,7 @@ public class TemplateService {
List<TemplateDownloadLogInfo> list = jdbcTemplate.query(
"SELECT l.id, l.template_id, COALESCE(t.template_name, '') AS template_name, " +
"l.version_no, l.user_id, COALESCE(u.user_name, '') AS user_name, COALESCE(u.phone, '') AS user_phone, " +
"l.object_key, l.download_type, COALESCE(l.watermark_text, '') AS watermark_text, " +
"l.object_key, l.download_type, " +
"l.project_id, l.meeting_id, l.ip, l.user_agent, DATE_FORMAT(l.downloaded_at, '%Y-%m-%d %H:%i:%s') AS downloaded_at" +
whereSql +
" ORDER BY l.downloaded_at DESC, l.id DESC LIMIT ? OFFSET ?",
@ -709,7 +696,7 @@ public class TemplateService {
"t.current_version_no, tv.object_key AS current_object_key, " +
"DATE_FORMAT(t.effective_from, '%Y-%m-%d %H:%i:%s') AS effective_from, " +
"DATE_FORMAT(t.effective_to, '%Y-%m-%d %H:%i:%s') AS effective_to, " +
"t.watermark_enabled, t.download_rate_limit_per_hour, " +
"t.download_rate_limit_per_hour, " +
"DATE_FORMAT(t.created_at, '%Y-%m-%d %H:%i:%s') AS created_at, " +
"DATE_FORMAT(t.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " +
"FROM template t " +
@ -806,17 +793,7 @@ public class TemplateService {
return normalized;
}
private String normalizeOptionalDownloadType(String downloadType) {
String value = trimToNull(downloadType);
if (value == null) {
return null;
}
String normalized = value.toUpperCase();
if (!"NORMAL".equals(normalized) && !"WATERMARK".equals(normalized)) {
throw new BusinessException(10003, "downloadType仅支持NORMAL/WATERMARK");
}
return normalized;
}
private String normalizeContentType(String contentType) {
if (contentType == null || contentType.trim().isEmpty()) {
@ -936,7 +913,7 @@ public class TemplateService {
private void assertTemplateEditable(TemplateInfo template) {
if ("ARCHIVED".equalsIgnoreCase(template.getStatus())) {
throw new BusinessException(10003, "已归档模板不允许新增版本");
throw new BusinessException(10003, "已归档模板不允许编辑或新增版本");
}
}

View File

@ -20,6 +20,7 @@ import java.util.Map;
@Service
public class PasswordSetupService {
private static final String SCENARIO_TENANT_ADMIN_SETUP = "TENANT_ADMIN_SETUP";
private static final String SCENARIO_USER_SETUP = "USER_SETUP";
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final JdbcTemplate jdbcTemplate;
@ -71,6 +72,36 @@ public class PasswordSetupService {
return buildSetupLink(tenantCode, rawToken);
}
@Transactional
public String issueUserSetupLink(Long tenantId, Long userId, Long operatorUserId) {
Map<String, Object> user = loadSystemUser(tenantId, userId);
String tenantCode = String.valueOf(user.get("tenant_code"));
jdbcTemplate.update(
"UPDATE auth_password_setup_token " +
"SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP " +
"WHERE tenant_id=? AND user_id=? AND scenario=? AND is_deleted=0 AND used_at IS NULL",
safeOperator(operatorUserId),
tenantId,
userId,
SCENARIO_USER_SETUP
);
String rawToken = generateToken();
LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(Math.max(passwordSetupExpireMinutes, 10L));
jdbcTemplate.update(
"INSERT INTO auth_password_setup_token (tenant_id, user_id, scenario, token_hash, expires_at, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?)",
tenantId,
userId,
SCENARIO_USER_SETUP,
hashToken(rawToken),
Timestamp.valueOf(expiresAt),
safeOperator(operatorUserId),
safeOperator(operatorUserId)
);
return buildSetupLink(tenantCode, rawToken);
}
public Map<String, Object> verifyTenantPasswordSetupToken(String tenantCode, String rawToken) {
Map<String, Object> tokenRecord = loadAvailableTokenRecord(tenantCode, rawToken);
Map<String, Object> data = new LinkedHashMap<String, Object>();
@ -112,11 +143,12 @@ public class PasswordSetupService {
jdbcTemplate.update(
"UPDATE auth_password_setup_token " +
"SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP " +
"WHERE tenant_id=? AND user_id=? AND scenario=? AND id<>? AND is_deleted=0 AND used_at IS NULL",
"WHERE tenant_id=? AND user_id=? AND scenario IN (?, ?) AND id<>? AND is_deleted=0 AND used_at IS NULL",
userId,
tenantId,
userId,
SCENARIO_TENANT_ADMIN_SETUP,
SCENARIO_USER_SETUP,
tokenId
);
@ -139,11 +171,12 @@ public class PasswordSetupService {
"FROM auth_password_setup_token tkn " +
"JOIN tenant t ON tkn.tenant_id=t.id " +
"JOIN sys_user u ON tkn.user_id=u.id AND tkn.tenant_id=u.tenant_id " +
"WHERE tkn.token_hash=? AND tkn.scenario=? AND tkn.is_deleted=0 " +
"WHERE tkn.token_hash=? AND tkn.scenario IN (?, ?) AND tkn.is_deleted=0 " +
"AND t.is_deleted=0 AND u.is_deleted=0 " +
"LIMIT 1",
hashToken(normalizedToken),
SCENARIO_TENANT_ADMIN_SETUP
SCENARIO_TENANT_ADMIN_SETUP,
SCENARIO_USER_SETUP
);
if (rows.isEmpty()) {
throw new BusinessException(10001, "设置链接无效或已过期");
@ -181,6 +214,22 @@ public class PasswordSetupService {
return rows.get(0);
}
private Map<String, Object> loadSystemUser(Long tenantId, Long userId) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT u.id, u.tenant_id, t.tenant_code " +
"FROM sys_user u " +
"JOIN tenant t ON u.tenant_id=t.id " +
"WHERE u.tenant_id=? AND u.id=? AND u.is_deleted=0 AND t.is_deleted=0 " +
"LIMIT 1",
tenantId,
userId
);
if (rows.isEmpty()) {
throw new BusinessException(10003, "用户不存在");
}
return rows.get(0);
}
private String buildSetupLink(String tenantCode, String rawToken) {
String baseUrl = normalizeBaseUrl(frontendBaseUrl);
String path = "/" + tenantCode + "/setup-password?token=" + urlEncode(rawToken);

View File

@ -0,0 +1,27 @@
-- 添加模板编辑权限
INSERT INTO permission (id, permission_code, permission_name, module)
SELECT t.next_id, 'template.update', '编辑模板', 'template'
FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t
WHERE NOT EXISTS (
SELECT 1 FROM permission WHERE permission_code = 'template.update'
);
-- 授予角色编辑模板权限(通常跟随 template.create 一起授权)
-- 找出所有拥有 template.create 权限的角色,给他们也加上 template.update 权限
SET @next_rp_id = (SELECT IFNULL(MAX(id), 0) FROM role_permission);
INSERT INTO role_permission (id, tenant_id, role_id, permission_id)
SELECT
(@next_rp_id := @next_rp_id + 1) AS id,
rp.tenant_id,
rp.role_id,
p.id
FROM role_permission rp
JOIN permission source_p ON source_p.id = rp.permission_id AND source_p.permission_code = 'template.create'
JOIN permission p ON p.permission_code = 'template.update'
WHERE NOT EXISTS (
SELECT 1 FROM role_permission existing_rp
WHERE existing_rp.tenant_id = rp.tenant_id
AND existing_rp.role_id = rp.role_id
AND existing_rp.permission_id = p.id
);

View File

@ -0,0 +1,51 @@
-- 添加查看详情和水印下载权限
INSERT INTO permission (id, permission_code, permission_name, module)
SELECT t.next_id, 'template.detail.read', '查看模板详情', 'template'
FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t
WHERE NOT EXISTS (
SELECT 1 FROM permission WHERE permission_code = 'template.detail.read'
);
INSERT INTO permission (id, permission_code, permission_name, module)
SELECT t.next_id, 'template.watermark.download', '水印下载模板', 'template'
FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t
WHERE NOT EXISTS (
SELECT 1 FROM permission WHERE permission_code = 'template.watermark.download'
);
-- 授予角色新权限(继承现有权限)
SET @next_rp_id = (SELECT IFNULL(MAX(id), 0) FROM role_permission);
-- 给有 template.read 的加上 template.detail.read
INSERT INTO role_permission (id, tenant_id, role_id, permission_id)
SELECT
(@next_rp_id := @next_rp_id + 1) AS id,
rp.tenant_id,
rp.role_id,
p.id
FROM role_permission rp
JOIN permission source_p ON source_p.id = rp.permission_id AND source_p.permission_code = 'template.read'
JOIN permission p ON p.permission_code = 'template.detail.read'
WHERE NOT EXISTS (
SELECT 1 FROM role_permission existing_rp
WHERE existing_rp.tenant_id = rp.tenant_id
AND existing_rp.role_id = rp.role_id
AND existing_rp.permission_id = p.id
);
-- 给有 template.download 的加上 template.watermark.download
INSERT INTO role_permission (id, tenant_id, role_id, permission_id)
SELECT
(@next_rp_id := @next_rp_id + 1) AS id,
rp.tenant_id,
rp.role_id,
p.id
FROM role_permission rp
JOIN permission source_p ON source_p.id = rp.permission_id AND source_p.permission_code = 'template.download'
JOIN permission p ON p.permission_code = 'template.watermark.download'
WHERE NOT EXISTS (
SELECT 1 FROM role_permission existing_rp
WHERE existing_rp.tenant_id = rp.tenant_id
AND existing_rp.role_id = rp.role_id
AND existing_rp.permission_id = p.id
);

View File

@ -0,0 +1,7 @@
-- 移除水印相关字段
ALTER TABLE template DROP COLUMN watermark_enabled;
ALTER TABLE template_download_log DROP COLUMN watermark_text;
-- 删除之前新增的 template.watermark.download 权限
DELETE FROM role_permission WHERE permission_id IN (SELECT id FROM permission WHERE permission_code = 'template.watermark.download');
DELETE FROM permission WHERE permission_code = 'template.watermark.download';

View File

@ -0,0 +1,27 @@
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, 'meeting.update', '编辑会议', 'meeting'
FROM dual
WHERE NOT EXISTS (
SELECT 1
FROM permission
WHERE permission_code = 'meeting.update'
);
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,
rp.tenant_id,
rp.role_id,
p.id AS permission_id
FROM role_permission rp
JOIN permission source_p ON source_p.id = rp.permission_id AND source_p.permission_code = 'meeting.create'
JOIN permission p ON p.permission_code = 'meeting.update'
WHERE NOT EXISTS (
SELECT 1
FROM role_permission exist_rp
WHERE exist_rp.tenant_id = rp.tenant_id
AND exist_rp.role_id = rp.role_id
AND exist_rp.permission_id = p.id
);

File diff suppressed because it is too large Load Diff

View File

@ -6,9 +6,11 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.7.7",
"compressorjs": "^1.3.0",
"element-plus": "^2.8.4",
@ -18,8 +20,13 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/node": "^25.9.1",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/test-utils": "^2.4.11",
"happy-dom": "^20.10.1",
"jsdom": "^27.0.1",
"typescript": "^5.6.2",
"vite": "^5.4.8"
"vite": "^5.4.8",
"vitest": "^4.1.8"
}
}

View File

@ -716,12 +716,23 @@ export const fetchTemplates = (params?: {
status?: string;
scopeType?: string;
bizScene?: string;
watermarkEnabled?: boolean;
effectiveStatus?: string;
pageNo?: number;
pageSize?: number;
}) =>
http.get("/templates", { params });
export const fetchTemplateViewList = (params?: {
templateName?: string;
templateType?: string;
scopeType?: string;
bizScene?: string;
effectiveStatus?: string;
pageNo?: number;
pageSize?: number;
}) =>
http.get("/templates/view-list", { params });
export const fetchPublishedTemplateOptions = (params?: { bizScene?: string }) =>
http.get("/templates/published-options", { params });
export const fetchTemplateTypeOptions = () => http.get("/templates/type-options");
@ -742,9 +753,21 @@ export const createTemplate = (payload: {
changeLog?: string;
effectiveFrom?: string;
effectiveTo?: string;
watermarkEnabled?: boolean;
downloadRateLimitPerHour?: number;
}) => http.post("/templates", payload);
export const updateTemplate = (id: number, payload: {
templateName: string;
templateType: string;
scopeType: "ALL" | "PROJECT" | "MEETING";
projectId?: number;
meetingId?: number;
bizScene?: "MEETING_RECOMMEND" | "AUDIT_NOTIFY" | "SETTLEMENT";
effectiveFrom?: string;
effectiveTo?: string;
downloadRateLimitPerHour?: number;
}) => http.post(`/templates/${id}/update`, payload);
export const fetchTemplateUploadSign = (payload: {
fileName: string;
contentType?: string;
@ -764,8 +787,6 @@ export const archiveTemplate = (id: number) => http.post(`/templates/${id}/archi
export const rollbackTemplate = (id: number, payload: { versionNo: number; rollbackReason: string }) =>
http.post(`/templates/${id}/rollback`, payload);
export const downloadTemplate = (id: number) => http.get(`/templates/${id}/download`);
export const downloadTemplateWatermark = (id: number, params?: { watermarkText?: string }) =>
http.get(`/templates/${id}/download-watermark`, { params });
export const fetchTemplateVersionDiff = (id: number, params?: { leftVersionNo?: number; rightVersionNo?: number }) =>
http.get(`/templates/${id}/versions/diff`, { params });
export const fetchTemplateDownloadLogs = (params?: {
@ -774,7 +795,7 @@ export const fetchTemplateDownloadLogs = (params?: {
userId?: number;
userKeyword?: string;
versionNo?: number;
downloadType?: "NORMAL" | "WATERMARK";
ip?: string;
downloadedFrom?: string;
downloadedTo?: string;

View File

@ -76,6 +76,7 @@ const handleMarkRead = () => {
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
}
.notif-detail-title {

View File

@ -13,6 +13,7 @@ export const PERMS = {
read: "meeting.read",
manage: "meeting.manage",
create: "meeting.create",
update: "meeting.update",
delete: "meeting.delete",
cancel: "meeting.cancel",
submit: "meeting.submit",
@ -83,6 +84,7 @@ export const PERMS = {
read: "template.read",
manage: "template.manage",
create: "template.create",
update: "template.update",
publish: "template.publish",
disable: "template.disable",
archive: "template.archive",
@ -90,6 +92,7 @@ export const PERMS = {
download: "template.download",
downloadLogReadAll: "template.download.log.read.all",
flowLink: "template.flow.link",
detailRead: "template.detail.read",
},
expert: {
read: "expert.read",

View File

@ -1,11 +1,47 @@
import * as xlsx from "xlsx";
export const readTextFile = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onload = () => {
const buffer = reader.result as ArrayBuffer;
try {
const decoderUtf8 = new TextDecoder("utf-8", { fatal: true });
resolve(decoderUtf8.decode(buffer));
} catch (e) {
try {
const decoderGbk = new TextDecoder("gbk");
resolve(decoderGbk.decode(buffer));
} catch (e2) {
reject(new Error("文件编码无法识别"));
}
}
};
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
reader.readAsText(file, "utf-8");
reader.readAsArrayBuffer(file);
});
export const readXlsxFile = async (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = e.target?.result;
// codepage 936 tells xlsx to use GBK for CSVs misnamed as XLS
const workbook = xlsx.read(data, { type: "array", codepage: 936 });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const csv = xlsx.utils.sheet_to_csv(worksheet);
resolve(csv);
} catch (err) {
reject(new Error("读取 Excel 文件失败"));
}
};
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
reader.readAsArrayBuffer(file);
});
};
export const downloadCsvTemplate = (fileName: string, headers: string[], sampleRows: string[][] = []) => {
downloadCsvRows(fileName, [headers, ...sampleRows]);
};

View File

@ -65,6 +65,7 @@ const STATUS_TEXT_MAP: Record<string, string> = {
AUDIT_RETURNED: "审核退回",
FINANCE_CONFIRMED: "财务已确认",
USER_CREATED: "用户创建",
USER_PASSWORD_RESET: "密码重置",
DELIVERED: "已送达",
UNREAD: "未读",
READ: "已读",

View File

@ -64,7 +64,8 @@
<el-dropdown-menu>
<el-dropdown-item command="dashboard">工作台</el-dropdown-item>
<el-dropdown-item command="profile">个人设置</el-dropdown-item>
<el-dropdown-item divided command="logout">退出全部设备</el-dropdown-item>
<el-dropdown-item command="refreshAuth">刷新权限</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@ -159,7 +160,8 @@
<el-dropdown-menu>
<el-dropdown-item command="dashboard">工作台</el-dropdown-item>
<el-dropdown-item command="profile">个人设置</el-dropdown-item>
<el-dropdown-item divided command="logout">退出全部设备</el-dropdown-item>
<el-dropdown-item command="refreshAuth">刷新权限</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@ -281,7 +283,7 @@ import { useRoute, useRouter } from "vue-router";
import BreadcrumbNav from "../../components/BreadcrumbNav.vue";
import GlobalSearchLauncher from "../../components/GlobalSearchLauncher.vue";
import InAppNotificationDetailDialog from "../../components/InAppNotificationDetailDialog.vue";
import { fetchInAppNotifications, markAllInAppNotificationsRead, markInAppNotificationRead, logoutAllAuth, switchTenant } from "../../api/modules";
import { fetchInAppNotifications, markAllInAppNotificationsRead, markInAppNotificationRead, logoutAllAuth, switchTenant, refreshAuth } from "../../api/modules";
import { PERMS } from "../../constants/permissions";
import { useAppearanceStore } from "../../stores/appearance";
import { useAuthStore } from "../../stores/auth";
@ -554,6 +556,19 @@ const handleUserCommand = async (command: string) => {
await router.push(profileRoute.value);
return;
}
if (command === "refreshAuth") {
try {
const resp = await refreshAuth();
withSuppressedAuthRefresh(() => {
authStore.saveAuthPayload(resp?.data || null);
});
await refreshLayoutState();
ElMessage.success("权限刷新成功");
} catch (e) {
// Ignore, handled by global interceptor
}
return;
}
if (command === "logout") {
await logoutAll();
}

View File

@ -29,7 +29,7 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="300">
<el-table-column label="操作" width="380" fixed="right">
<template #default="{ row }">
<el-button v-if="canManage" size="small" @click="handleCopy(row.id)">复制</el-button>
<el-button v-if="canManage" size="small" @click="handleEdit(row)">编辑节点</el-button>

View File

@ -1266,7 +1266,7 @@ const resetMaterialViews = () => {
meetingEffect: "",
};
docView.value = { agendas: [], signInName: "", signInOssKey: "", themePhotoName: "", themePhotoOssKey: "", invitations: [] };
photoView.value = { photos: [], summary: "" };
photoView.value = { photos: [], summary: "", expertSummaries: [] };
laborView.value = { details: [] };
invoiceView.value = { invoices: [], summary: "" };
expertProfileView.value = { fileName: "", ossKey: "" };
@ -2084,6 +2084,7 @@ const loadMaterial = async () => {
originIndex: index + 1,
})),
summary: onsitePhoto.summary || "",
expertSummaries: Array.isArray(onsitePhoto.expertSummaries) ? onsitePhoto.expertSummaries : [],
};
laborView.value = {
details: (Array.isArray(laborProtocol.details) ? laborProtocol.details : []).map((x: any) => {

View File

@ -198,13 +198,13 @@
<el-dialog v-model="importVisible" title="批量导入专家" :width="DIALOG_WIDTH.lg" destroy-on-close>
<div class="text-secondary mb-sm">
支持 `.csv` 或制表符文本模板字段顺序为
`expertName, idNo, phone, titleCode, hospitalCode`
支持 `.xlsx`, `.xls`, `.csv` 或制表符文本模板字段顺序为
`专家姓名, 身份证号, 手机号, 职称, 医院`
</div>
<el-upload
:auto-upload="false"
:show-file-list="false"
accept=".csv,.txt"
accept=".xlsx,.xls,.csv,.txt"
:on-change="handleImportFileChange"
>
<el-button>选择文件</el-button>
@ -221,11 +221,11 @@
<div v-if="importPreview.length" class="mt-md">
<div class="mb-sm">预览前 {{ importPreview.length }} </div>
<el-table :data="importPreview" size="small" max-height="240">
<el-table-column prop="expertName" label="姓名" />
<el-table-column prop="idNo" label="身份证" />
<el-table-column prop="expertName" label="专家姓名" />
<el-table-column prop="idNo" label="身份证" />
<el-table-column prop="phone" label="手机号" />
<el-table-column prop="titleCode" label="职称编码" />
<el-table-column prop="hospitalCode" label="医院编码" />
<el-table-column prop="title" label="职称" />
<el-table-column prop="organization" label="医院" />
</el-table>
</div>
<div v-if="lastImportResult" class="mt-md">
@ -298,7 +298,7 @@ import {
} from "../../api/modules";
import { PERMS } from "../../constants/permissions";
import { useAuthStore } from "../../stores/auth";
import { downloadCsvRows, downloadCsvTemplate, findMissingHeaders, parseDelimitedText, readTextFile } from "../../utils/batchImport";
import { downloadCsvRows, downloadCsvTemplate, findMissingHeaders, parseDelimitedText, readTextFile, readXlsxFile } from "../../utils/batchImport";
const authStore = useAuthStore();
const isPlatform = computed(() => authStore.scope === "PLATFORM");
@ -320,14 +320,14 @@ const importText = ref("");
const importVisible = ref(false);
const importLoading = ref(false);
const importFileName = ref("");
const importPreview = ref<Array<{ expertName: string; idNo: string; phone: string; titleCode: string; hospitalCode: string }>>([]);
const importPreview = ref<Array<{ expertName: string; idNo: string; phone: string; title: string; organization: string }>>([]);
const lastImportResult = ref<any | null>(null);
const lastImportedExperts = ref<Array<{
expertName: string;
idNo: string;
phone: string;
titleCode: string;
hospitalCode: string;
title: string;
organization: string;
}> | null>(null);
const titleOptions = ref<any[]>([]);
const hospitalOptions = ref<any[]>([]);
@ -456,13 +456,13 @@ const openImportDialog = () => {
const downloadExpertTemplate = () => {
downloadCsvTemplate("expert-import-template.csv", [
"expertName",
"idNo",
"phone",
"titleCode",
"hospitalCode",
"专家姓名",
"身份证号",
"手机号",
"职称",
"医院",
], [
["张教授", "110101199001011234", "13800138000", "CHIEF_PHYSICIAN", "BEIJING_HOSPITAL"],
["张教授", "110101199001011234", "13800138000", "主任医师", "北京医院"],
]);
};
@ -472,7 +472,12 @@ const handleImportFileChange = async (uploadFile: any) => {
return;
}
importFileName.value = file.name;
const nameLower = file.name.toLowerCase();
if (nameLower.endsWith(".xlsx") || nameLower.endsWith(".xls")) {
importText.value = await readXlsxFile(file);
} else {
importText.value = await readTextFile(file);
}
syncImportPreview();
};
@ -482,7 +487,7 @@ const parseExpertImportRows = (text: string) => {
return [];
}
const [header, ...dataRows] = rows;
const missingHeaders = findMissingHeaders(header, ["expertName", "idNo", "phone"]);
const missingHeaders = findMissingHeaders(header, ["专家姓名", "身份证号", "手机号"]);
if (missingHeaders.length) {
return [];
}
@ -493,19 +498,19 @@ const parseExpertImportRows = (text: string) => {
return dataRows
.filter((row) => row.some((cell) => String(cell || "").trim().length > 0))
.map((row) => ({
expertName: row[headerIndex.get("expertName") ?? 0] || "",
idNo: row[headerIndex.get("idNo") ?? 1] || "",
phone: row[headerIndex.get("phone") ?? 2] || "",
titleCode: row[headerIndex.get("titleCode") ?? 3] || "",
hospitalCode: row[headerIndex.get("hospitalCode") ?? 4] || "",
expertName: row[headerIndex.get("专家姓名") ?? 0] || "",
idNo: row[headerIndex.get("身份证号") ?? 1] || "",
phone: row[headerIndex.get("手机号") ?? 2] || "",
title: row[headerIndex.get("职称") ?? 3] || "",
organization: row[headerIndex.get("医院") ?? 4] || "",
}))
.map((item) => ({
...item,
expertName: item.expertName.trim(),
idNo: item.idNo.trim(),
phone: item.phone.trim(),
titleCode: item.titleCode.trim(),
hospitalCode: item.hospitalCode.trim(),
title: item.title.trim(),
organization: item.organization.trim(),
}));
};
@ -516,10 +521,10 @@ const syncImportPreview = () => {
const validateExpertImportHeaders = (text: string) => {
const rows = parseDelimitedText(text);
if (!rows.length) {
return { ok: false, missingHeaders: ["expertName", "idNo", "phone"] };
return { ok: false, missingHeaders: ["专家姓名", "身份证号", "手机号"] };
}
const [header] = rows;
const missingHeaders = findMissingHeaders(header, ["expertName", "idNo", "phone"]);
const missingHeaders = findMissingHeaders(header, ["专家姓名", "身份证号", "手机号"]);
return { ok: missingHeaders.length === 0, missingHeaders };
};
@ -944,11 +949,11 @@ const downloadExpertErrorSnapshot = () => {
return;
}
const rows: string[][] = [[
"expertName",
"idNo",
"phone",
"titleCode",
"hospitalCode",
"专家姓名",
"身份证号",
"手机号",
"职称",
"医院",
"__error",
]];
errors.forEach((error: any) => {
@ -961,9 +966,9 @@ const downloadExpertErrorSnapshot = () => {
item.expertName || "",
item.idNo || "",
item.phone || "",
item.titleCode || "",
item.hospitalCode || "",
String(error?.message || ""),
item.title || "",
item.organization || "",
error?.message || "未知错误",
]);
});
downloadCsvRows("expert-import-error-snapshot.csv", rows);

View File

@ -7,10 +7,10 @@
<el-table :data="rows" class="mt-md" empty-text="暂无数据">
<el-table-column prop="taskCode" label="任务编码" width="180" :formatter="taskCodeFormatter" />
<el-table-column prop="bizType" label="业务类型" width="140" :formatter="bizTypeFormatter" />
<el-table-column prop="bizId" label="业务ID" width="120" />
<el-table-column prop="fileName" label="文件名" width="180" />
<el-table-column prop="fileOssKey" label="文件OSS" />
<!-- <el-table-column prop="bizType" label="业务类型" width="140" :formatter="bizTypeFormatter" /> -->
<!-- <el-table-column prop="bizId" label="业务ID" width="120" /> -->
<el-table-column prop="fileName" label="文件名" width="350" />
<!-- <el-table-column prop="fileOssKey" label="文件OSS" /> -->
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
<el-table-column prop="retryCount" label="重试" width="80" />
<el-table-column prop="downloadCount" label="下载次数" width="90" />

View File

@ -5,6 +5,7 @@
:rows="rows"
:summary-task-map="summaryTaskMap"
:can-create="canCreate"
:can-edit="canEdit"
:can-submit="canSubmit"
:can-submit-meeting="canSubmitMeeting"
:can-withdraw="canWithdraw"
@ -392,6 +393,7 @@ const meetingPageSize = ref(20);
const meetingTotalCount = ref(0);
const authStore = useAuthStore();
const canCreate = computed(() => authStore.hasPermission(PERMS.meeting.create));
const canEdit = computed(() => authStore.hasPermission(PERMS.meeting.update));
const canSubmit = computed(() => authStore.hasPermission(PERMS.meeting.submit));
const canWithdraw = computed(() => authStore.hasPermission(PERMS.meeting.withdraw));
const canMaterialSave = computed(() => authStore.hasPermission(PERMS.meeting.materialSave));
@ -1080,6 +1082,7 @@ const materialBudgetBase = ref({
laborTotalCent: 0,
invoiceTotalCent: 0,
meetingInvoiceTotalCent: 0,
cateringTotalCent: 0,
ready: false,
});
const materialModuleLoading = ref(false);
@ -2894,7 +2897,7 @@ const handleGenerateSummary = async (meetingId: number) => {
});
scheduleSummaryTaskPolling(meetingId, taskId);
}
ElMessage.success(taskId > 0 ? `会议总结任务已创建:${taskId},当前状态为待处理` : "会议总结任务已创建");
ElMessage.success("会议总结生成任务已提交,系统正在后台处理,请稍后可在导出任务中心查看");
};
const handleCheckSummaryStatus = async (meetingId: number) => {
@ -5187,14 +5190,15 @@ const onMeetingInvoiceSectionFileChange = async (
}
await putToSignedUrl(uploadUrl, file, signResp?.data?.contentType || file.type);
const section = ensureMeetingInvoiceSection(sectionCode);
const shouldRunOcr = fieldKey === "invoiceFile" || sectionCode === "OTHER";
section.files[fieldKey].push({
fileName: file.name,
ossKey: objectKey,
ocr: fieldKey === "invoiceFile" ? { status: "idle" } : undefined,
ocr: shouldRunOcr ? { status: "idle" } : undefined,
});
await cacheDocPreviewUrl(objectKey);
ElMessage.success("会议发票附件上传成功");
if (fieldKey === "invoiceFile") {
if (shouldRunOcr) {
await runMeetingInvoiceOcr(sectionCode, fieldKey);
}
} finally {
@ -5615,6 +5619,7 @@ const resetMaterialForms = () => {
laborTotalCent: 0,
invoiceTotalCent: 0,
meetingInvoiceTotalCent: 0,
cateringTotalCent: 0,
ready: false,
};
};
@ -6157,127 +6162,7 @@ const loadMaterialModule = async (
// ignore when current material does not exist
}
if (moduleCode === "EXPERT_LIST") {
void (async () => {
const tasks: Array<Promise<void>> = [];
Object.keys(photoByExpert.value).forEach((id) => {
const expertId = Number(id || 0);
(photoByExpert.value[expertId] || []).forEach((item) => {
const nameOrKey = item.name || item.ossKey || "";
if (!item.ossKey || !isImageFile(nameOrKey)) {
return;
}
tasks.push(
(async () => {
try {
const resp = await fetchFilePresignDownload({ objectKey: item.ossKey });
if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
return;
}
item.previewUrl = resp?.data?.signedUrl || "";
} catch (_e) {
if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
return;
}
item.previewUrl = "";
}
})(),
);
});
});
if (tasks.length > 0) {
await Promise.allSettled(tasks);
}
Object.keys(laborByExpert.value).forEach((id) => {
const expertId = Number(id || 0);
const row = laborByExpert.value[expertId];
if (!row) {
return;
}
const invoiceFiles = Array.isArray(row.invoiceFiles) ? row.invoiceFiles : [];
if (invoiceFiles.length === 0 && row.invoiceOssKey) {
invoiceFiles.push({
fileName: String(row.invoiceFileName || "").trim(),
ossKey: String(row.invoiceOssKey || "").trim(),
previewUrl: "",
});
}
if (invoiceFiles.length === 0) {
row.invoicePreviewUrl = "";
return;
}
invoiceFiles.forEach((file, index) => {
const key = String(file?.ossKey || "").trim();
const nameOrKey = String(file?.fileName || key || "").trim();
if (!key || !isImageFile(nameOrKey)) {
file.previewUrl = "";
if (index === 0) {
row.invoicePreviewUrl = "";
}
return;
}
tasks.push(
(async () => {
try {
const resp = await fetchFilePresignDownload({ objectKey: key });
if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
return;
}
file.previewUrl = resp?.data?.signedUrl || "";
if (index === 0) {
row.invoicePreviewUrl = file.previewUrl;
}
} catch (_e) {
if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
return;
}
file.previewUrl = "";
if (index === 0) {
row.invoicePreviewUrl = "";
}
}
})(),
);
});
});
if (tasks.length > 0) {
await Promise.allSettled(tasks);
}
const protocolPreviewTasks: Array<Promise<void>> = [];
Object.keys(laborByExpert.value).forEach((id) => {
const expertId = Number(id || 0);
const row = laborByExpert.value[expertId];
if (!row) {
return;
}
const key = String(row.protocolOssKey || "").trim();
const nameOrKey = String(row.protocolFileName || key || "").trim();
if (!key || !isImageFile(nameOrKey)) {
row.protocolPreviewUrl = "";
return;
}
protocolPreviewTasks.push(
(async () => {
try {
const resp = await fetchFilePresignDownload({ objectKey: key });
if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
return;
}
row.protocolPreviewUrl = resp?.data?.signedUrl || "";
} catch (_e) {
if (typeof requestId === "number" && !isMaterialLoadActive(requestId, meetingId, moduleCode)) {
return;
}
row.protocolPreviewUrl = "";
}
})(),
);
});
if (protocolPreviewTasks.length > 0) {
await Promise.allSettled(protocolPreviewTasks);
}
})();
// (handleViewOss )
}
if (moduleCode === "EXPERT_PROFILE") {
const profileKey = String(expertProfileForm.value.ossKey || "").trim();
@ -6447,6 +6332,16 @@ const resolveSelectedMeetingCateringRatio = () => {
return Math.max(0, Number(currentRow?.cateringRatio || 0));
};
const resolveSelectedMeetingLaborRatio = () => {
const meetingId = Number(selectedMeetingId.value || 0);
const currentDetailId = Number(currentMeetingDetail.value?.id || 0);
if (meetingId > 0 && currentDetailId === meetingId) {
return Math.max(0, Number(currentMeetingDetail.value?.laborRatio || 0));
}
const currentRow = rows.value.find((item: any) => Number(item?.id || 0) === meetingId);
return Math.max(0, Number(currentRow?.laborRatio || 0));
};
const validateMeetingInvoiceRequired = () => {
if (selectedModuleCode.value !== "MEETING_INVOICE") {
return true;
@ -6552,6 +6447,22 @@ const calcMeetingInvoiceUsageFromContentJson = (contentJson: string) => {
return calcMeetingInvoiceUsageFromForm(normalized);
};
const calcMeetingCateringUsageFromForm = (formState: MeetingInvoiceFormState) => {
const sectionState = formState.sections?.["CATERING_DETAIL"];
if (!sectionState) return 0;
const amountYuan = Number(sectionState.totalAmountYuan);
if (!Number.isFinite(amountYuan) || amountYuan < 0) {
return 0;
}
return toCent(amountYuan);
};
const calcMeetingCateringUsageFromContentJson = (contentJson: string) => {
const parsed = safeParse(contentJson || "{}");
const normalized = normalizeMeetingInvoiceForm(parsed);
return calcMeetingCateringUsageFromForm(normalized);
};
const materialBudgetSummary = computed(() => {
const base = materialBudgetBase.value;
if (!base.ready) {
@ -6563,12 +6474,15 @@ const materialBudgetSummary = computed(() => {
usedTotalCent: 0,
remainCent: 0,
overCent: 0,
remainLaborCent: 0,
remainCateringCent: 0,
ready: false,
};
}
let laborTotalCent = base.laborTotalCent;
let invoiceTotalCent = base.invoiceTotalCent;
let meetingInvoiceTotalCent = base.meetingInvoiceTotalCent;
let cateringTotalCent = base.cateringTotalCent || 0;
if (selectedModuleCode.value === "EXPERT_LIST") {
const currentExpertUsage = calcExpertUsageFromCurrentForm();
laborTotalCent = currentExpertUsage.laborTotalCent;
@ -6576,10 +6490,20 @@ const materialBudgetSummary = computed(() => {
}
if (selectedModuleCode.value === "MEETING_INVOICE") {
meetingInvoiceTotalCent = calcMeetingInvoiceUsageFromForm(meetingInvoiceForm.value);
cateringTotalCent = calcMeetingCateringUsageFromForm(meetingInvoiceForm.value);
}
const usedTotalCent = laborTotalCent + invoiceTotalCent + meetingInvoiceTotalCent;
const remainCent = Math.max(0, base.budgetCent - usedTotalCent);
const overCent = Math.max(0, usedTotalCent - base.budgetCent);
const laborRatio = resolveSelectedMeetingLaborRatio();
const maxLaborCent = Math.floor(base.budgetCent * laborRatio);
const remainLaborCent = maxLaborCent - laborTotalCent;
const cateringRatio = resolveSelectedMeetingCateringRatio();
const maxCateringCent = Math.floor(base.budgetCent * cateringRatio);
const remainCateringCent = maxCateringCent - cateringTotalCent;
return {
budgetCent: base.budgetCent,
laborTotalCent,
@ -6588,6 +6512,8 @@ const materialBudgetSummary = computed(() => {
usedTotalCent,
remainCent,
overCent,
remainLaborCent,
remainCateringCent,
ready: true,
};
});
@ -6633,6 +6559,7 @@ const refreshMaterialBudgetBase = async (meetingIdInput?: number, requestId?: nu
laborTotalCent: 0,
invoiceTotalCent: 0,
meetingInvoiceTotalCent: 0,
cateringTotalCent: 0,
ready: false,
};
materialBudgetLoading.value = false;
@ -6650,11 +6577,13 @@ const refreshMaterialBudgetBase = async (meetingIdInput?: number, requestId?: nu
}
const expertUsage = calcExpertUsageFromContentJson(expertContentJson);
const meetingInvoiceTotalCent = calcMeetingInvoiceUsageFromContentJson(meetingInvoiceContentJson);
const cateringTotalCent = calcMeetingCateringUsageFromContentJson(meetingInvoiceContentJson);
materialBudgetBase.value = {
budgetCent,
laborTotalCent: expertUsage.laborTotalCent,
invoiceTotalCent: expertUsage.invoiceTotalCent,
meetingInvoiceTotalCent,
cateringTotalCent,
ready: true,
};
} finally {
@ -6670,11 +6599,19 @@ const validateMeetingBudgetLimit = async () => {
return true;
}
const summary = materialBudgetSummary.value;
if (summary.overCent <= 0) {
return true;
}
if (summary.overCent > 0) {
ElMessage.warning(`预算校验不通过:当前会议预算${toYuan(summary.budgetCent)}元,已用${toYuan(summary.usedTotalCent)}元,超预算${toYuan(summary.overCent)}`);
return false;
}
if (summary.remainLaborCent < 0) {
ElMessage.warning(`预算校验不通过:劳务费已超出占比允许的最大额度,超额${toYuan(-summary.remainLaborCent)}`);
return false;
}
if (summary.remainCateringCent < 0) {
ElMessage.warning(`预算校验不通过:餐费已超出占比允许的最大额度,超额${toYuan(-summary.remainCateringCent)}`);
return false;
}
return true;
};
const handleSaveMaterial = async () => {

View File

@ -94,6 +94,7 @@
<el-option label="审核退回" value="AUDIT_RETURNED" />
<el-option label="财务已确认" value="FINANCE_CONFIRMED" />
<el-option label="用户创建" value="USER_CREATED" />
<el-option label="密码重置" value="USER_PASSWORD_RESET" />
<el-option label="审核通过(旧)" value="AUDIT_APPROVED" />
</el-select>
</el-form-item>
@ -153,6 +154,7 @@
<el-option label="审核退回" value="AUDIT_RETURNED" />
<el-option label="财务已确认" value="FINANCE_CONFIRMED" />
<el-option label="用户创建" value="USER_CREATED" />
<el-option label="密码重置" value="USER_PASSWORD_RESET" />
<el-option label="审核通过(旧)" value="AUDIT_APPROVED" />
</el-select>
</el-form-item>
@ -217,6 +219,7 @@
<el-option label="审核退回" value="AUDIT_RETURNED" />
<el-option label="财务已确认" value="FINANCE_CONFIRMED" />
<el-option label="用户创建" value="USER_CREATED" />
<el-option label="密码重置" value="USER_PASSWORD_RESET" />
<el-option label="审核通过(旧)" value="AUDIT_APPROVED" />
</el-select>
</el-form-item>
@ -350,9 +353,10 @@ const dispatchFormRules = ref<FormRules>({
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
const normalizeUpper = (value: unknown) => String(value || "").trim().toUpperCase();
const isUserCreatedEvent = (value: unknown) => String(value || "").trim().toUpperCase() === "USER_CREATED";
const isUserPasswordResetEvent = (value: unknown) => String(value || "").trim().toUpperCase() === "USER_PASSWORD_RESET";
const resolveReceiverTypeByEvent = (eventCode: unknown, currentReceiverType: unknown) => {
const normalizedEventCode = normalizeUpper(eventCode);
if (isUserCreatedEvent(eventCode)) {
if (isUserCreatedEvent(eventCode) || isUserPasswordResetEvent(eventCode)) {
return "TARGET_USER";
}
if (normalizedEventCode === "AUDIT_TASK_ASSIGNED") {
@ -370,7 +374,7 @@ const resolveReceiverTypeByEvent = (eventCode: unknown, currentReceiverType: unk
: String(currentReceiverType || "SUBMITTER");
};
const resolveBizTypeByEvent = (eventCode: unknown, currentBizType: unknown) => {
if (isUserCreatedEvent(eventCode)) {
if (isUserCreatedEvent(eventCode) || isUserPasswordResetEvent(eventCode)) {
return "USER";
}
return String(currentBizType || "").trim().toUpperCase() === "USER"

View File

@ -20,7 +20,7 @@
<div class="card-header">
<div>
<div class="card-title">SMTP / 邮件发送配置</div>
<div class="card-subtitle">配置完成后通知邮件和租户管理员账号邮件都会优先读取这里的网关参数</div>
<div class="card-subtitle">配置完成后通知邮件会优先读取这里的网关参数</div>
</div>
<el-tag :type="emailForm.status === 'ENABLED' ? 'success' : 'info'">{{ emailForm.status === "ENABLED" ? "已启用" : "未启用" }}</el-tag>
</div>

View File

@ -1,4 +1,4 @@
<template>
<template>
<PageContainer title="项目管理">
<QueryToolbar>
<el-form :inline="true" @submit.prevent class="project-page-toolbar">
@ -34,16 +34,16 @@
<span v-else>-</span>
</template>
</el-table-column> -->
<el-table-column prop="budgetCent" label="预算(元)" :formatter="budgetFormatter" />
<el-table-column label="费用合计(元)" :formatter="projectFeeTotalFormatter" />
<el-table-column prop="budgetCent" label="预算(元)" :formatter="budgetFormatter" width="100"/>
<el-table-column label="费用合计(元)" :formatter="projectFeeTotalFormatter" width="115"/>
<!-- <el-table-column prop="budgetExecutionRatio" label="预算执行率" :formatter="executionRatioFormatter" /> -->
<el-table-column prop="meetingTotal" label="会议总期数" width="100"/>
<el-table-column prop="meetingTotal" label="会议总期数" width="90"/>
<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="370">
<el-table-column label="操作" width="370" fixed="right">
<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>
@ -120,13 +120,13 @@
<el-table-column prop="afterValue" label="变更后" />
<el-table-column prop="changeReason" label="变更原因" width="150" />
<el-table-column prop="operatorUserName" label="操作人" width="140" />
<el-table-column prop="handoverAt" label="交接时间" width="180" />
<!-- <el-table-column prop="handoverAt" label="交接时间" width="180" /> -->
<el-table-column prop="createdAt" label="记录时间" width="180" />
</el-table>
</el-drawer>
<el-dialog v-model="bindingVisible" title="绑定项目人员" :width="DIALOG_WIDTH.lg">
<el-form label-position="left" :label-width="LABEL_WIDTH.lg">
<el-form label-position="left" :label-width="LABEL_WIDTH.lg" style="margin-top: 10px;">
<el-form-item v-if="!isProjectExecutorRole" label="项目负责人">
<div class="binding-select-field">
<div v-if="ownerReadonlyBoundUsers.length" class="binding-readonly-block">

View File

@ -16,16 +16,7 @@
/>
</el-select>
</el-form-item>
<el-form-item label="模板状态">
<el-select v-model="templateQuery.status" clearable placeholder="全部" class="w-input-md">
<el-option v-for="item in templateStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="水印下载">
<el-select v-model="templateQuery.watermarkEnabled" clearable placeholder="全部" class="w-input-md">
<el-option v-for="item in watermarkFilterOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button v-if="canRead" type="primary" @click="handleTemplateSearch">查询</el-button>
</el-form-item>
@ -47,23 +38,19 @@
</template>
</el-table-column>
<el-table-column prop="bizScene" label="业务场景" width="140" :formatter="bizSceneFormatter" />
<el-table-column prop="currentVersionNo" label="当前版本" width="100" />
<!-- <el-table-column prop="currentVersionNo" label="当前版本" width="100" /> -->
<el-table-column prop="status" label="状态" width="110" :formatter="statusFormatter" />
<el-table-column label="生效时间" min-width="220">
<template #default="{ row }">
{{ formatEffectiveTime(row) }}
</template>
</el-table-column>
<el-table-column label="水印" width="90">
<template #default="{ row }">
{{ row.watermarkEnabled ? "开启" : "关闭" }}
</template>
</el-table-column>
<el-table-column prop="downloadRateLimitPerHour" label="限流/小时" width="110" />
<!-- <el-table-column prop="downloadRateLimitPerHour" label="限流/小时" width="110" /> -->
<el-table-column prop="updatedAt" label="最近更新" width="180" />
<el-table-column label="操作" min-width="280" fixed="right">
<el-table-column label="操作" min-width="300" fixed="right">
<template #default="{ row }">
<el-button size="small" v-if="canRead" @click="openDetailDrawer(row)">查看详情</el-button>
<el-button size="small" v-if="canDetailRead" @click="openDetailDrawer(row)">查看详情</el-button>
<el-button size="small" v-if="canRead" @click="focusLogs(row)">
{{ canReadAllLogs ? "查看下载日志" : "查看我的下载日志" }}
</el-button>
@ -76,15 +63,7 @@
>
下载
</el-button>
<el-button
size="small"
type="primary"
v-if="canDownload"
:disabled="isWatermarkDownloadBlocked(row)"
@click="handleDownloadWatermark(row)"
>
水印下载
</el-button>
</template>
</el-table-column>
</el-table>
@ -123,12 +102,7 @@
<el-form-item label="版本号">
<el-input-number v-model="logQuery.versionNo" :min="1" class="w-input-sm" />
</el-form-item>
<el-form-item label="下载方式">
<el-select v-model="logQuery.downloadType" clearable placeholder="全部" class="w-input-md">
<el-option label="普通下载" value="NORMAL" />
<el-option label="水印下载" value="WATERMARK" />
</el-select>
</el-form-item>
<el-form-item v-if="canReadAllLogs" label="IP">
<el-input v-model="logQuery.ip" clearable placeholder="请输入 IP" class="w-input-md" />
</el-form-item>
@ -157,11 +131,7 @@
</template>
</el-table-column>
<el-table-column prop="versionNo" label="版本号" width="90" />
<el-table-column label="下载方式" width="120">
<template #default="{ row }">
{{ getDownloadTypeLabel(row.downloadType) }}
</template>
</el-table-column>
<el-table-column v-if="canReadAllLogs" label="下载人" min-width="180">
<template #default="{ row }">
<span v-if="row.userName || row.userPhone">
@ -170,7 +140,7 @@
<span v-else>{{ `用户#${row.userId}` }}</span>
</template>
</el-table-column>
<el-table-column prop="watermarkText" label="水印文案" min-width="160" show-overflow-tooltip />
<el-table-column prop="objectKey" label="ObjectKey" min-width="260" show-overflow-tooltip />
<el-table-column prop="ip" label="IP" width="140" />
<el-table-column prop="downloadedAt" label="下载时间" width="180" />
@ -201,7 +171,7 @@
<el-descriptions-item label="适用范围">{{ getScopeTypeLabel(detailRow.scopeType) }}</el-descriptions-item>
<el-descriptions-item label="当前版本">V{{ detailRow.currentVersionNo || 1 }}</el-descriptions-item>
<el-descriptions-item label="生效时间">{{ formatEffectiveTime(detailRow) }}</el-descriptions-item>
<el-descriptions-item label="水印下载">{{ detailRow.watermarkEnabled ? "开启" : "关闭" }}</el-descriptions-item>
<el-descriptions-item label="下载限流/小时">{{ detailRow.downloadRateLimitPerHour ?? "-" }}</el-descriptions-item>
<el-descriptions-item label="最近更新">{{ detailRow.updatedAt || "-" }}</el-descriptions-item>
<el-descriptions-item label="下载状态">
@ -216,14 +186,6 @@
<el-button v-if="canDownload" type="primary" :disabled="isDownloadBlocked(detailRow)" @click="handleDownload(detailRow)">
下载
</el-button>
<el-button
v-if="canDownload"
type="primary"
:disabled="isWatermarkDownloadBlocked(detailRow)"
@click="handleDownloadWatermark(detailRow)"
>
水印下载
</el-button>
</div>
<el-descriptions v-if="currentVersionDetail" class="mt-md" :column="2" border>
@ -261,11 +223,7 @@
<el-tab-pane :label="canReadAllLogs ? '最近下载' : '我的最近下载'">
<el-table :data="detailDownloadRows" empty-text="暂无下载记录">
<el-table-column prop="downloadedAt" label="下载时间" width="180" />
<el-table-column label="下载方式" width="120">
<template #default="{ row }">
{{ getDownloadTypeLabel(row.downloadType) }}
</template>
</el-table-column>
<el-table-column v-if="canReadAllLogs" label="下载人" min-width="180">
<template #default="{ row }">
<span v-if="row.userName || row.userPhone">
@ -274,7 +232,7 @@
<span v-else>{{ `用户#${row.userId}` }}</span>
</template>
</el-table-column>
<el-table-column prop="watermarkText" label="水印文案" min-width="160" show-overflow-tooltip />
<el-table-column prop="ip" label="IP" width="140" />
</el-table>
</el-tab-pane>
@ -291,11 +249,11 @@ import { ElMessage, ElMessageBox } from "element-plus";
import PageContainer from "../../components/PageContainer.vue";
import {
downloadTemplate,
downloadTemplateWatermark,
fetchTemplateDownloadLogs,
fetchTemplateViewList,
fetchTemplateTypeOptions,
fetchTemplateDownloadLogs,
fetchTemplateVersions,
fetchTemplates,
} from "../../api/modules";
import { PERMS } from "../../constants/permissions";
import { DRAWER_SIZE } from "../../constants/ui";
@ -323,6 +281,7 @@ const authStore = useAuthStore();
const route = useRoute();
const canRead = computed(() => authStore.hasPermission(PERMS.template.read));
const canDetailRead = computed(() => authStore.hasPermission(PERMS.template.detailRead));
const canDownload = computed(() => authStore.hasPermission(PERMS.template.download));
const canReadAllLogs = computed(() => authStore.hasPermission(PERMS.template.downloadLogReadAll));
@ -345,12 +304,7 @@ const detailDownloadRows = ref<any[]>([]);
const templateTypeOptions = ref<Array<{ typeCode: TemplateTypeCode; typeName: string; status: "ENABLED" | "DISABLED" }>>([]);
const templateStatusOptions = [
{ label: "草稿", value: "DRAFT" },
{ label: "已发布", value: "PUBLISHED" },
{ label: "已停用", value: "DISABLED" },
{ label: "已归档", value: "ARCHIVED" },
];
const scopeTypeOptions = [
{ label: "全部", value: "ALL" },
@ -358,16 +312,9 @@ const scopeTypeOptions = [
{ label: "会议级", value: "MEETING" },
];
const watermarkFilterOptions = [
{ label: "开启", value: "true" },
{ label: "关闭", value: "false" },
];
const templateQuery = ref({
templateName: "",
templateType: "" as TemplateTypeCode | "",
status: "",
watermarkEnabled: "" as "" | "true" | "false",
});
const logQuery = ref({
@ -376,7 +323,6 @@ const logQuery = ref({
userId: undefined as number | undefined,
userKeyword: "",
versionNo: undefined as number | undefined,
downloadType: "" as "" | "NORMAL" | "WATERMARK",
ip: "",
dateRange: [] as string[],
});
@ -409,7 +355,7 @@ const getTemplateTypeLabel = (code: string) =>
const getScopeTypeLabel = (code: ScopeTypeCode | string) =>
scopeTypeOptions.find((item) => item.value === code)?.label || code || "-";
const getDownloadTypeLabel = (value?: unknown) => (String(value ?? "").trim() === "WATERMARK" ? "水印下载" : "普通下载");
const statusFormatter = (_row: any, _column: any, value: unknown) => getStatusLabel(value);
const bizSceneFormatter = (_row: any, _column: any, value: unknown) => getBizSceneLabel(value);
@ -425,7 +371,6 @@ const formatEffectiveTime = (row: any) => {
const buildTemplateName = (row: any) => row?.templateName || `模板#${row?.id ?? "-"}`;
const isDownloadBlocked = (row?: any) => ["DISABLED", "ARCHIVED"].includes(String(row?.status || "").trim().toUpperCase());
const isWatermarkDownloadBlocked = (row?: any) => isDownloadBlocked(row) || !row?.watermarkEnabled;
const confirmAction = async (message: string, title: string) => {
try {
@ -443,8 +388,7 @@ const confirmAction = async (message: string, title: string) => {
const buildTemplateParams = () => ({
templateName: templateQuery.value.templateName || undefined,
templateType: templateQuery.value.templateType || undefined,
status: templateQuery.value.status || undefined,
watermarkEnabled: templateQuery.value.watermarkEnabled ? templateQuery.value.watermarkEnabled === "true" : undefined,
status: "PUBLISHED",
pageNo: templatePageNo.value,
pageSize: templatePageSize.value,
});
@ -455,7 +399,6 @@ const buildLogParams = () => ({
userId: canReadAllLogs.value ? logQuery.value.userId : undefined,
userKeyword: canReadAllLogs.value ? logQuery.value.userKeyword || undefined : undefined,
versionNo: logQuery.value.versionNo,
downloadType: logQuery.value.downloadType || undefined,
ip: canReadAllLogs.value ? logQuery.value.ip || undefined : undefined,
downloadedFrom: logQuery.value.dateRange?.[0] || undefined,
downloadedTo: logQuery.value.dateRange?.[1] || undefined,
@ -490,7 +433,7 @@ const loadTemplates = async () => {
templateTotalCount.value = 0;
return;
}
const resp = await fetchTemplates(buildTemplateParams());
const resp = await fetchTemplateViewList(buildTemplateParams());
templateRows.value = resp?.data?.list || [];
templateTotalCount.value = Number(resp?.data?.total || 0);
};
@ -544,8 +487,6 @@ const resetTemplateQuery = async () => {
templateQuery.value = {
templateName: "",
templateType: "",
status: "",
watermarkEnabled: "",
};
templatePageNo.value = 1;
templatePageSize.value = 20;
@ -559,7 +500,6 @@ const resetLogQuery = async () => {
userId: undefined,
userKeyword: "",
versionNo: undefined,
downloadType: "",
ip: "",
dateRange: [],
};
@ -594,28 +534,7 @@ const handleDownload = async (row: any) => {
await Promise.all([loadLogs(), refreshDetailData()]);
};
const handleDownloadWatermark = async (row: any) => {
if (isWatermarkDownloadBlocked(row)) {
ElMessage.warning(isDownloadBlocked(row) ? "该模板已停用或归档,当前不可下载" : "该模板未开启水印下载");
return;
}
const confirmed = await confirmAction(
`确认对「${buildTemplateName(row)}」执行水印下载?系统会记录本次下载日志。`,
"水印下载",
);
if (!confirmed) {
return;
}
const resp = await downloadTemplateWatermark(row.id, { watermarkText: "仅限内部使用" });
const url = resp?.data?.signedUrl;
if (!url) {
ElMessage.error("获取水印下载链接失败");
return;
}
window.open(url, "_blank");
ElMessage.success("已生成水印下载链接并记录下载日志");
await Promise.all([loadLogs(), refreshDetailData()]);
};
onMounted(async () => {
initQueryFromRoute();

View File

@ -39,11 +39,7 @@
<el-option v-for="item in effectiveStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="水印下载">
<el-select v-model="listQuery.watermarkEnabled" clearable placeholder="全部" class="w-input-md">
<el-option v-for="item in watermarkFilterOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button v-if="canRead" type="primary" @click="handleSearch">查询</el-button>
</el-form-item>
@ -122,9 +118,6 @@
class="w-input-md"
/>
</el-form-item>
<el-form-item label="开启水印下载">
<el-switch v-model="form.watermarkEnabled" />
</el-form-item>
<el-form-item label="下载限流/小时">
<el-input-number v-model="form.downloadRateLimitPerHour" :min="1" :max="9999" />
</el-form-item>
@ -194,16 +187,20 @@
{{ formatEffectiveTime(row) }}
</template>
</el-table-column>
<el-table-column label="水印" width="90">
<template #default="{ row }">
{{ row.watermarkEnabled ? "开启" : "关闭" }}
</template>
</el-table-column>
<el-table-column prop="downloadRateLimitPerHour" label="限流/小时" width="110" />
<el-table-column prop="updatedAt" label="最近更新" width="180" />
<el-table-column label="操作" min-width="420" fixed="right">
<el-table-column label="操作" min-width="350" fixed="right">
<template #default="{ row }">
<el-button size="small" v-if="canRead" @click="openDetailDrawer(row)">版本管理</el-button>
<el-button size="small" v-if="canDetailRead" @click="openDetailDrawer(row)">版本管理</el-button>
<el-button
size="small"
type="primary"
v-if="canUpdate && row.status !== 'ARCHIVED'"
@click="openEditDrawer(row)"
>
编辑
</el-button>
<el-button
size="small"
type="success"
@ -235,6 +232,68 @@
@current-change="load"
/>
<el-drawer v-model="editVisible" title="编辑模板" :size="DRAWER_SIZE.md" destroy-on-close>
<el-form ref="editFormRef" :model="editForm" :rules="formRules" label-width="120px" class="pr-md">
<el-form-item label="模板名称" prop="templateName">
<el-input v-model="editForm.templateName" placeholder="如XX大会报名签到码" />
</el-form-item>
<el-form-item label="模板类型" prop="templateType">
<el-select v-model="editForm.templateType" class="w-input-md">
<el-option
v-for="item in templateTypeOptions.filter(i => i.status === 'ENABLED' || i.typeCode === editForm.templateType)"
:key="item.typeCode"
:label="item.typeName"
:value="item.typeCode"
/>
</el-select>
</el-form-item>
<el-form-item label="适用范围" prop="scopeType">
<el-select v-model="editForm.scopeType" class="w-input-md">
<el-option v-for="item in scopeTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item v-if="editForm.scopeType === 'PROJECT'" label="关联项目ID" prop="projectId">
<el-input-number v-model="editForm.projectId" :min="1" />
</el-form-item>
<el-form-item v-if="editForm.scopeType === 'MEETING'" label="关联会议ID" prop="meetingId">
<el-input-number v-model="editForm.meetingId" :min="1" />
</el-form-item>
<el-form-item label="业务场景" prop="bizScene">
<el-select v-model="editForm.bizScene" class="w-input-md">
<el-option
v-for="item in flowSceneOptions"
:key="item.sceneCode"
:label="item.sceneName"
:value="item.sceneCode"
/>
</el-select>
</el-form-item>
<el-form-item label="生效开始时间">
<el-date-picker
v-model="editForm.effectiveFrom"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
class="w-input-md"
/>
</el-form-item>
<el-form-item label="生效结束时间">
<el-date-picker
v-model="editForm.effectiveTo"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
class="w-input-md"
/>
</el-form-item>
<el-form-item label="下载限流/小时">
<el-input-number v-model="editForm.downloadRateLimitPerHour" :min="1" :max="9999" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button v-if="canUpdate" type="primary" @click="handleEdit">保存修改</el-button>
</template>
</el-drawer>
<el-drawer v-model="detailVisible" title="模板管理详情" :size="DRAWER_SIZE.lg" destroy-on-close>
<template v-if="detailRow">
<el-descriptions :column="2" border>
@ -245,7 +304,7 @@
<el-descriptions-item label="适用范围">{{ getScopeTypeLabel(detailRow.scopeType) }}</el-descriptions-item>
<el-descriptions-item label="当前版本">V{{ detailRow.currentVersionNo || 1 }}</el-descriptions-item>
<el-descriptions-item label="生效时间">{{ formatEffectiveTime(detailRow) }}</el-descriptions-item>
<el-descriptions-item label="水印下载">{{ detailRow.watermarkEnabled ? "开启" : "关闭" }}</el-descriptions-item>
<el-descriptions-item label="下载限流/小时">{{ detailRow.downloadRateLimitPerHour ?? "-" }}</el-descriptions-item>
<el-descriptions-item label="最近更新">{{ detailRow.updatedAt || "-" }}</el-descriptions-item>
</el-descriptions>
@ -458,6 +517,8 @@ import {
createTemplate,
disableTemplate,
disableTemplateTypeOption,
downloadTemplate,
enableTemplateTypeOption,
fetchPublishedTemplateOptions,
fetchTemplateFlowLinks,
@ -469,6 +530,7 @@ import {
fetchTemplates,
publishTemplate,
rollbackTemplate,
updateTemplate,
} from "../../api/modules";
import { PERMS } from "../../constants/permissions";
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
@ -495,7 +557,9 @@ const BIZ_SCENE_LABELS: Record<string, string> = {
const authStore = useAuthStore();
const canRead = computed(() => authStore.hasPermission(PERMS.template.read));
const canDetailRead = computed(() => authStore.hasPermission(PERMS.template.detailRead));
const canCreate = computed(() => authStore.hasPermission(PERMS.template.create));
const canUpdate = computed(() => authStore.hasPermission(PERMS.template.update));
const canPublish = computed(() => authStore.hasPermission(PERMS.template.publish));
const canDisable = computed(() => authStore.hasPermission(PERMS.template.disable));
const canArchive = computed(() => authStore.hasPermission(PERMS.template.archive));
@ -508,6 +572,7 @@ const pageSize = ref(20);
const totalCount = ref(0);
const createVisible = ref(false);
const editVisible = ref(false);
const detailVisible = ref(false);
const flowBindingVisible = ref(false);
const rollbackDialogVisible = ref(false);
@ -549,10 +614,7 @@ const effectiveStatusOptions = [
{ label: "已过期", value: "EXPIRED" },
];
const watermarkFilterOptions = [
{ label: "开启", value: "true" },
{ label: "关闭", value: "false" },
];
const listQuery = ref({
templateName: "",
@ -561,7 +623,20 @@ const listQuery = ref({
scopeType: "" as ScopeTypeCode | "",
bizScene: "" as BizSceneCode | "",
effectiveStatus: "",
watermarkEnabled: "" as "" | "true" | "false",
});
const editFormRef = ref<FormInstance>();
const editForm = ref({
id: 0,
templateName: "",
templateType: "AGENDA" as TemplateTypeCode,
scopeType: "ALL" as ScopeTypeCode,
projectId: undefined as number | undefined,
meetingId: undefined as number | undefined,
bizScene: "MEETING_RECOMMEND" as BizSceneCode,
effectiveFrom: "",
effectiveTo: "",
downloadRateLimitPerHour: 100,
});
const form = ref({
@ -575,7 +650,6 @@ const form = ref({
changeLog: "",
effectiveFrom: "",
effectiveTo: "",
watermarkEnabled: false,
downloadRateLimitPerHour: 100,
});
@ -666,7 +740,6 @@ const buildListParams = () => ({
scopeType: listQuery.value.scopeType || undefined,
bizScene: listQuery.value.bizScene || undefined,
effectiveStatus: listQuery.value.effectiveStatus || undefined,
watermarkEnabled: listQuery.value.watermarkEnabled ? listQuery.value.watermarkEnabled === "true" : undefined,
pageNo: pageNo.value,
pageSize: pageSize.value,
});
@ -717,7 +790,6 @@ const resetCreateForm = () => {
changeLog: "",
effectiveFrom: "",
effectiveTo: "",
watermarkEnabled: false,
downloadRateLimitPerHour: 100,
};
};
@ -728,6 +800,23 @@ const openCreateDrawer = () => {
createVisible.value = true;
};
const openEditDrawer = (row: any) => {
selectedTemplateId.value = row.id;
editForm.value = {
id: row.id,
templateName: row.templateName || "",
templateType: (row.templateType || "AGENDA") as TemplateTypeCode,
scopeType: (row.scopeType || "ALL") as ScopeTypeCode,
projectId: row.projectId || undefined,
meetingId: row.meetingId || undefined,
bizScene: (row.bizScene || "MEETING_RECOMMEND") as BizSceneCode,
effectiveFrom: row.effectiveFrom || "",
effectiveTo: row.effectiveTo || "",
downloadRateLimitPerHour: Number(row.downloadRateLimitPerHour || 100),
};
editVisible.value = true;
};
const openFlowBindingDrawer = async () => {
if (!canRead.value) {
return;
@ -884,7 +973,6 @@ const handleCreate = async () => {
changeLog: form.value.changeLog,
effectiveFrom: form.value.effectiveFrom || undefined,
effectiveTo: form.value.effectiveTo || undefined,
watermarkEnabled: form.value.watermarkEnabled,
downloadRateLimitPerHour: form.value.downloadRateLimitPerHour,
});
ElMessage.success("模板创建成功");
@ -892,6 +980,45 @@ const handleCreate = async () => {
await load();
};
const handleEdit = async () => {
if (!editFormRef.value) {
return;
}
const valid = await editFormRef.value.validate().catch(() => false);
if (!valid) {
return;
}
if (editForm.value.scopeType === "PROJECT" && !editForm.value.projectId) {
ElMessage.warning("项目级模板需要填写项目ID");
return;
}
if (editForm.value.scopeType === "MEETING" && !editForm.value.meetingId) {
ElMessage.warning("会议级模板需要填写会议ID");
return;
}
if (editForm.value.effectiveFrom && editForm.value.effectiveTo && editForm.value.effectiveFrom > editForm.value.effectiveTo) {
ElMessage.warning("生效开始时间不能晚于生效结束时间");
return;
}
await updateTemplate(editForm.value.id, {
templateName: editForm.value.templateName,
templateType: editForm.value.templateType,
scopeType: editForm.value.scopeType,
projectId: editForm.value.projectId,
meetingId: editForm.value.meetingId,
bizScene: editForm.value.bizScene,
effectiveFrom: editForm.value.effectiveFrom || undefined,
effectiveTo: editForm.value.effectiveTo || undefined,
downloadRateLimitPerHour: editForm.value.downloadRateLimitPerHour,
});
ElMessage.success("模板修改成功");
editVisible.value = false;
await load();
if (detailVisible.value && Number(detailRow.value?.id) === Number(editForm.value.id)) {
await refreshDetailData();
}
};
const enableType = async (typeCode: string) => {
await enableTemplateTypeOption(typeCode);
ElMessage.success(`模板类型 ${typeCode} 已启用`);
@ -1116,7 +1243,6 @@ const resetListQuery = async () => {
scopeType: "",
bizScene: "",
effectiveStatus: "",
watermarkEnabled: "",
};
pageNo.value = 1;
pageSize.value = 20;

View File

@ -74,24 +74,17 @@
stripe
:empty-text="loading ? '加载中...' : '暂无待审批任务'"
>
<el-table-column prop="id" label="任务ID" width="80" />
<el-table-column prop="meetingId" label="关联会议ID" width="100" />
<el-table-column prop="moduleCode" label="模块" min-width="120">
<el-table-column label="会议信息" min-width="180">
<template #default="{ row }">
<el-tag size="small">{{ formatModuleCode(row.moduleCode) }}</el-tag>
{{ meetingNameMap[row.meetingId] || `会议#${row.meetingId}` }}
</template>
</el-table-column>
<el-table-column prop="nodeStatus" label="状态" width="100">
<el-table-column prop="node" label="审批节点" width="120">
<template #default="{ row }">
<el-tag
size="small"
:type="row.nodeStatus === 'PENDING' ? 'warning' : 'info'"
>
{{ row.nodeStatus === 'PENDING' ? '待处理' : row.nodeStatus }}
</el-tag>
<el-tag size="small">{{ formatAuditNode(row.node) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="到达时间" width="180" />
<el-table-column prop="submittedAt" label="到达时间" width="180" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button
@ -111,10 +104,10 @@
<script setup lang="ts">
import { computed, ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { fetchAuditTasks, fetchDashboardStats } from "../../api/modules";
import { fetchAuditTasks, fetchDashboardStats, fetchMeetings } from "../../api/modules";
import { PERMS } from "../../constants/permissions";
import { useAuthStore } from "../../stores/auth";
import { toZhModuleCode } from "../../utils/status";
import { toZhAuditNode } from "../../utils/status";
const router = useRouter();
const authStore = useAuthStore();
@ -127,8 +120,9 @@ const myPendingTasks = ref<any[]>([]);
const pendingAuditCount = ref(0);
const activeMeetingCount = ref(0);
const pendingFinanceCount = ref(0);
const meetingNameMap = ref<Record<number, string>>({});
const formatModuleCode = (code: string) => toZhModuleCode(code);
const formatAuditNode = (code: string) => toZhAuditNode(code);
const goToAudit = (meetingId?: number) => {
if (meetingId) {
@ -139,17 +133,19 @@ const goToAudit = (meetingId?: number) => {
};
const loadMyTasks = async () => {
if (!authStore.hasPermission(PERMS.audit.read)) {
return;
}
loading.value = true;
try {
const resp = await fetchAuditTasks({ mine: true, pageNo: 1, pageSize: 50 });
const resp = await fetchAuditTasks({ mine: true, scope: "PENDING_MINE", pageNo: 1, pageSize: 50 });
const list = resp?.data?.list || [];
// PENDING
const pendings = list.filter((x: any) => x.nodeStatus === "PENDING" || x.nodeStatus === "CLAIMED");
const pendings = list.filter((x: any) => x.status === "PENDING" || x.status === "CLAIMED");
myPendingTasks.value = pendings.slice(0, 10); // Top 10
pendingAuditCount.value = pendings.length;
//
if (pendings.length > 0) {
await loadNameMaps();
}
} catch (e) {
// ignore
} finally {
@ -172,6 +168,25 @@ const loadDashboardStats = async () => {
//
}
};
const loadNameMaps = async () => {
try {
const meetingResp = await fetchMeetings({ pageNo: 1, pageSize: 200 });
const meetings = meetingResp?.data?.list || [];
const meetingMap: Record<number, string> = {};
meetings.forEach((item: any) => {
const id = Number(item?.id || 0);
if (id > 0) {
const projectName = String(item?.projectName || "").trim();
const topic = String(item?.topic || "").trim();
meetingMap[id] = projectName && topic ? `${projectName} / ${topic}` : topic || `会议#${id}`;
}
});
meetingNameMap.value = meetingMap;
} catch (_e) {
meetingNameMap.value = {};
}
};
</script>
<style scoped>

View File

@ -6,8 +6,8 @@
</QueryToolbar>
<el-table :data="rows" class="mt-md" empty-text="暂无数据">
<el-table-column prop="tenantCode" label="租户编码" width="220" />
<el-table-column prop="tenantName" label="租户名称" />
<el-table-column prop="tenantCode" label="租户编码" width="200" />
<el-table-column prop="tenantName" label="租户名称" width="220"/>
<el-table-column label="Logo" width="180">
<template #default="{ row }">
<img
@ -21,7 +21,7 @@
</el-table-column>
<el-table-column prop="status" label="状态" width="120" :formatter="statusFormatter" />
<el-table-column prop="createdAt" label="创建时间" width="190" />
<el-table-column label="操作" width="420">
<el-table-column label="操作" width="380">
<template #default="{ row }">
<el-button v-if="canManage" size="small" @click="openEdit(row)">编辑</el-button>
<el-button
@ -121,10 +121,10 @@
</el-dialog>
<el-dialog v-model="adminDialogVisible" title="设置租户管理员" :width="DIALOG_WIDTH.md">
<div class="mb-md text-regular">
<div class="mb-md text-regular" style="margin-top: 10px;">
当前租户{{ selectedTenant?.tenantName }}{{ selectedTenant?.tenantCode }}
</div>
<el-form ref="adminFormRef" :model="adminForm" label-position="left" :label-width="LABEL_WIDTH.md" :rules="adminFormRules">
<el-form ref="adminFormRef" :model="adminForm" label-position="left" :label-width="LABEL_WIDTH.lg" :rules="adminFormRules">
<el-form-item label="管理员姓名" prop="userName">
<el-input v-model="adminForm.userName" />
</el-form-item>
@ -141,7 +141,7 @@
type="info"
:closable="false"
show-icon
title="系统会向该邮箱发送一次性首次设密链接,不再发送明文初始密码。"
title="创建成功后将直接在页面显示一次性设密链接,请注意保存以便发给管理员。"
/>
</el-form>
<template #footer>
@ -469,12 +469,17 @@ const handleSetAdmin = async () => {
roleCode: adminForm.value.roleCode.trim() || undefined,
});
const action = String(resp?.data?.action || "");
ElMessage.success(
action === "UPDATED"
? "租户管理员更新成功,首次设密链接已发送到邮箱"
: "租户管理员创建成功,首次设密链接已发送到邮箱",
);
const setupLink = String(resp?.data?.setupLink || "");
const isUpdate = action === "UPDATED";
ElMessage.success(isUpdate ? "租户管理员更新成功" : "租户管理员创建成功");
adminDialogVisible.value = false;
if (setupLink) {
ElMessageBox.alert(
`<div style="word-break: break-all;">管理员设密链接:<br/><br/><a href="${setupLink}" target="_blank">${setupLink}</a></div>`,
"请保存并发送给管理员",
{ dangerouslyUseHTMLString: true, confirmButtonText: "确定" }
);
}
};
const handleInitBaseline = async (row: any) => {

View File

@ -7,7 +7,7 @@
<div class="setup-card">
<div class="setup-header">
<div class="setup-badge">首次设密</div>
<h1>租户管理员密码设置</h1>
<h1>账号密码设置</h1>
<p>完成一次性密码配置后即可前往登录页登录</p>
</div>
@ -29,7 +29,7 @@
<template v-else>
<div class="setup-info">
<div class="info-row">
<span>管理员</span>
<span>用户姓名</span>
<strong>{{ verifyData.userName || "-" }}</strong>
</div>
<div class="info-row">

View File

@ -134,10 +134,6 @@
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" show-password />
<PasswordStrengthBar :password="form.password" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" />
</el-form-item>
@ -307,7 +303,6 @@
import { computed, onMounted, reactive, ref, watch } from "vue";
import PageContainer from "../../components/PageContainer.vue";
import QueryToolbar from "../../components/QueryToolbar.vue";
import PasswordStrengthBar from "../../components/PasswordStrengthBar.vue";
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
import { ElMessage, ElMessageBox } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
@ -384,7 +379,6 @@ const delegationForm = ref({
const form = ref({
userName: "测试用户",
phone: "13800000000",
password: "123456",
email: "test@example.com",
validFrom: "",
validTo: "",
@ -399,7 +393,6 @@ const router = useRouter();
const formRules = computed<FormRules>(() => ({
userName: [{ required: true, message: "请输入姓名", trigger: "blur" }],
phone: [{ required: true, message: "请输入手机号", trigger: "blur" }],
password: [{ required: !isEditMode.value, message: "请输入密码", trigger: "blur" }],
}));
const delegationFormRules = reactive<FormRules>({
@ -429,7 +422,6 @@ const openCreateDrawer = () => {
form.value = {
userName: "测试用户",
phone: "13800000000",
password: "123456",
email: "test@example.com",
validFrom: "",
validTo: "",
@ -453,7 +445,6 @@ const openEditDrawer = (row: any) => {
form.value = {
userName: row.userName,
phone: row.phone,
password: "",
email: row.email,
validFrom: row.validFrom,
validTo: row.validTo,
@ -541,7 +532,7 @@ const downloadUserTemplate = () => {
"validTo",
"roleCode",
], [
["张三", "13800138000", "Abc12345!", "zhangsan@example.com", "2026-04-20 09:00:00", "2099-12-31 23:59:59", "TENANT_ADMIN"],
["张三", "13800138000", "zhangsan@example.com", "2026-04-20 09:00:00", "2099-12-31 23:59:59", "TENANT_ADMIN"],
]);
};
@ -571,7 +562,7 @@ const parseUserImportRows = (text: string) => {
return [];
}
const [header, ...dataRows] = rows;
const missingHeaders = findMissingHeaders(header, ["userName", "phone", "password"]);
const missingHeaders = findMissingHeaders(header, ["userName", "phone"]);
if (missingHeaders.length) {
return [];
}
@ -584,17 +575,14 @@ const parseUserImportRows = (text: string) => {
.map((row) => ({
userName: row[headerIndex.get("userName") ?? 0] || "",
phone: row[headerIndex.get("phone") ?? 1] || "",
password: row[headerIndex.get("password") ?? 2] || "",
email: row[headerIndex.get("email") ?? 3] || "",
validFrom: row[headerIndex.get("validFrom") ?? 4] || "",
validTo: row[headerIndex.get("validTo") ?? 5] || "",
roleCode: row[headerIndex.get("roleCode") ?? 6] || "",
email: row[headerIndex.get("email") ?? 2] || "",
validFrom: row[headerIndex.get("validFrom") ?? 3] || "",
validTo: row[headerIndex.get("validTo") ?? 4] || "",
roleCode: row[headerIndex.get("roleCode") ?? 5] || "",
}))
.map((item) => ({
...item,
userName: item.userName.trim(),
phone: item.phone.trim(),
password: item.password.trim(),
email: item.email.trim(),
validFrom: item.validFrom.trim(),
validTo: item.validTo.trim(),
@ -605,10 +593,10 @@ const parseUserImportRows = (text: string) => {
const validateUserImportHeaders = (text: string) => {
const rows = parseDelimitedText(text);
if (!rows.length) {
return { ok: false, missingHeaders: ["userName", "phone", "password"] };
return { ok: false, missingHeaders: ["userName", "phone"] };
}
const [header] = rows;
const missingHeaders = findMissingHeaders(header, ["userName", "phone", "password"]);
const missingHeaders = findMissingHeaders(header, ["userName", "phone"]);
return { ok: missingHeaders.length === 0, missingHeaders };
};
@ -649,7 +637,6 @@ const downloadUserErrorSnapshot = () => {
const rows: string[][] = [[
"userName",
"phone",
"password",
"email",
"validFrom",
"validTo",
@ -665,7 +652,6 @@ const downloadUserErrorSnapshot = () => {
rows.push([
item.userName || "",
item.phone || "",
item.password || "",
item.email || "",
item.validFrom || "",
item.validTo || "",
@ -696,9 +682,6 @@ const handleSubmitUser = async () => {
validFrom: form.value.validFrom,
validTo: form.value.validTo,
};
if (form.value.password) {
payload.password = form.value.password;
}
await updateUser(editingUserId.value, payload);
if (form.value.roleId) {
await assignUserRole({ userId: editingUserId.value, roleId: form.value.roleId });
@ -708,7 +691,6 @@ const handleSubmitUser = async () => {
const payload = {
userName: form.value.userName,
phone: form.value.phone,
password: form.value.password,
email: form.value.email,
validFrom: form.value.validFrom,
validTo: form.value.validTo,
@ -748,16 +730,13 @@ const handleDeleteUser = async (userId: number, userName: string) => {
};
const handleResetPassword = async (userId: number) => {
const dialog = await ElMessageBox.prompt("请输入新密码", "重置密码", {
confirmButtonText: "确定",
await ElMessageBox.confirm("确认要重置该用户的密码吗?系统将发送设密链接到该用户的邮箱。", "重置密码", {
type: "warning",
confirmButtonText: "确认重置",
cancelButtonText: "取消",
inputValue: "123456",
}).catch(() => null);
if (!dialog) {
return;
}
await resetUserPassword(userId, { newPassword: dialog.value });
ElMessage.success("密码已重置");
});
await resetUserPassword(userId, {});
ElMessage.success("已发送密码重置邮件");
};
const handleViewRoleHistory = async (userId: number) => {

View File

@ -470,9 +470,9 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
<div class="audit-attachment-panel__head">劳务金额</div>
<div class="audit-attachment-panel__body audit-amount-panel">
<div class="audit-amount-panel__value">
{{ formatYuan(currentLaborPreTaxAmountCent) }} / {{ formatYuan(currentLaborAfterTaxAmountCent) }}
应发{{ formatYuan(currentLaborPreTaxAmountCent) }} <br/><br/>实发{{ formatYuan(currentLaborAfterTaxAmountCent) }}
</div>
<div class="audit-amount-panel__unit"></div>
<!-- <div class="audit-amount-panel__unit"></div> -->
<div class="audit-amount-panel__remark">
{{ currentLaborRow.remark || "无备注" }}
</div>

View File

@ -33,7 +33,7 @@ defineEmits<{
<el-descriptions-item label="会议形式">{{ currentMeetingDetail.meetingForm || "-" }}</el-descriptions-item>
<el-descriptions-item label="会议地点">{{ currentMeetingDetail.location || "-" }}</el-descriptions-item>
<el-descriptions-item label="会议时间">{{ currentMeetingDetail.startTime || "-" }} ~ {{ currentMeetingDetail.endTime || "-" }}</el-descriptions-item>
<el-descriptions-item label="预算(元)">{{ toYuan(currentMeetingDetail.budgetCent) }}</el-descriptions-item>
<el-descriptions-item label="预算(元)">{{ toYuan(currentMeetingDetail.budgetCent) }}</el-descriptions-item>
<el-descriptions-item label="劳务占比">{{ formatPercent(currentMeetingDetail.laborRatio) }}</el-descriptions-item>
<el-descriptions-item label="餐费占比">{{ formatPercent(currentMeetingDetail.cateringRatio) }}</el-descriptions-item>
<el-descriptions-item label="会议状态">{{ toZhStatus(currentMeetingDetail.status) }}</el-descriptions-item>

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref, watch, onBeforeUnmount } from "vue";
import { ZoomIn, ZoomOut, RefreshLeft, RefreshRight } from '@element-plus/icons-vue'
const visible = defineModel<boolean>({ required: true });
@ -16,6 +17,90 @@ const dialogTitle = computed(() => {
if (kind.value === "excel") return "Excel 预览";
return "图片预览";
});
//
const scale = ref(1);
const rotation = ref(0);
const translateX = ref(0);
const translateY = ref(0);
//
const isDragging = ref(false);
const startX = ref(0);
const startY = ref(0);
//
watch([visible, () => props.docPreviewDialogImageUrl], ([newVisible]) => {
if (newVisible) {
scale.value = 1;
rotation.value = 0;
translateX.value = 0;
translateY.value = 0;
}
});
const handleZoomIn = () => {
scale.value += 0.2;
};
const handleZoomOut = () => {
if (scale.value > 0.2) {
scale.value -= 0.2;
}
};
const handleRotateLeft = () => {
rotation.value -= 90;
};
const handleRotateRight = () => {
rotation.value += 90;
};
//
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
// 使
const zoomStep = 0.1;
if (e.deltaY < 0) {
//
scale.value += zoomStep;
} else {
//
if (scale.value > 0.2) {
scale.value -= zoomStep;
}
}
};
//
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault();
isDragging.value = true;
startX.value = e.clientX - translateX.value;
startY.value = e.clientY - translateY.value;
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.value) return;
translateX.value = e.clientX - startX.value;
translateY.value = e.clientY - startY.value;
};
const handleMouseUp = () => {
isDragging.value = false;
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
//
onBeforeUnmount(() => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
});
</script>
<template>
@ -23,6 +108,7 @@ const dialogTitle = computed(() => {
v-model="visible"
:title="dialogTitle"
width="80%"
top="5vh"
append-to-body
destroy-on-close
>
@ -33,23 +119,50 @@ const dialogTitle = computed(() => {
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">
<div v-else-if="docPreviewDialogImageUrl" class="doc-preview-image-wrapper">
<div class="image-toolbar">
<el-button-group>
<el-button :icon="ZoomIn" @click="handleZoomIn" title="放大" />
<el-button :icon="ZoomOut" @click="handleZoomOut" title="缩小" />
<el-button :icon="RefreshLeft" @click="handleRotateLeft" title="向左旋转" />
<el-button :icon="RefreshRight" @click="handleRotateRight" title="向右旋转" />
</el-button-group>
</div>
<div class="doc-preview-image-container" @wheel="handleWheel">
<img
:src="docPreviewDialogImageUrl"
alt="preview"
class="doc-preview-image"
@mousedown="handleMouseDown"
:style="{
transform: `translate(${translateX}px, ${translateY}px) scale(${scale}) rotate(${rotation}deg)`,
transition: isDragging ? 'none' : 'transform 0.3s ease',
cursor: isDragging ? 'grabbing' : 'grab'
}"
/>
</div>
</div>
</el-dialog>
</template>
<style scoped>
.doc-preview-pdf-frame {
width: 100%;
height: 75vh;
height: 80vh;
border: none;
display: block;
}
.doc-preview-image-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: 100%;
}
.image-toolbar {
display: flex;
justify-content: center;
}
.doc-preview-image-container {
display: flex;
justify-content: center;
@ -58,15 +171,20 @@ const dialogTitle = computed(() => {
background: #f8fafc;
border-radius: 8px;
overflow: hidden;
min-height: 60vh;
max-height: 80vh;
}
.doc-preview-image {
max-width: 100%;
max-height: 75vh;
object-fit: contain;
transform-origin: center center;
user-select: none;
-webkit-user-drag: none;
}
.doc-preview-excel-container {
width: 100%;
height: 75vh;
height: 80vh;
overflow: auto;
background: #fff;
border: 1px solid #dcdfe6;

View File

@ -257,7 +257,7 @@ const handleSave = async () => {
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item label="预算(元)">
<el-form-item label="预算(元)">
<el-input-number v-model="meetingForm.budgetYuan" :min="0.01" :step="0.01" :precision="2" />
</el-form-item>
<el-form-item label="劳务占比">

View File

@ -12,6 +12,7 @@ const props = defineProps<{
finishedAt?: string;
}>;
canCreate: boolean;
canEdit?: boolean;
canSubmit: boolean;
canSubmitMeeting: (row: any) => boolean;
canWithdraw: boolean;
@ -90,12 +91,12 @@ const summaryStatusText = (rowId: number, statusFormatter: (row: any, column: an
</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">
<el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button size="small" @click="$emit('openDetail', row)">详情</el-button>
<el-button
v-if="canCreate"
v-if="canEdit"
size="small"
type="primary"
:disabled="isMeetingAuditApproved(row)"
@ -148,24 +149,6 @@ const summaryStatusText = (rowId: number, statusFormatter: (row: any, column: an
<el-dropdown-item v-if="canMaterialExport" divided @click="$emit('generateSummary', row.id)">
生成总结
</el-dropdown-item>
<el-dropdown-item v-if="canMaterialExport" @click="$emit('checkSummaryStatus', row.id)">
查看总结状态
{{ summaryTaskMap[row.id]?.taskId ? `${summaryStatusText(row.id, statusFormatter, summaryTaskMap)}` : "" }}
</el-dropdown-item>
<el-dropdown-item
v-if="canMaterialExport"
:disabled="!summaryTaskMap[row.id]?.taskId || summaryTaskMap[row.id]?.status !== 'SUCCESS'"
@click="$emit('refreshSummaryToken', row.id)"
>
刷新总结令牌
</el-dropdown-item>
<el-dropdown-item
v-if="canMaterialExport"
:disabled="summaryTaskMap[row.id]?.status !== 'SUCCESS' || !summaryTaskMap[row.id]?.taskId || !summaryTaskMap[row.id]?.token"
@click="$emit('downloadSummary', row.id)"
>
下载总结
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>

View File

@ -677,9 +677,17 @@ watch(
<div class="budget-item"><span class="text-secondary">已用额度</span> <span>¥ {{ toYuan(materialBudgetSummary.usedTotalCent) }}</span></div>
<div class="budget-divider"></div>
<div class="budget-item" :class="materialBudgetSummary.overCent > 0 ? 'text-danger fw-bold' : 'text-success fw-bold'">
<span>{{ materialBudgetSummary.overCent > 0 ? '超发预算' : '剩余额度' }}</span>
<span>{{ materialBudgetSummary.overCent > 0 ? '超发预算' : '剩余额度' }}</span>
<span>¥ {{ toYuan(materialBudgetSummary.overCent > 0 ? materialBudgetSummary.overCent : materialBudgetSummary.remainCent) }}</span>
</div>
<div class="budget-item" :class="materialBudgetSummary.remainLaborCent < 0 ? 'text-danger fw-bold' : 'text-success fw-bold'">
<span>剩余劳务费额度</span>
<span>¥ {{ toYuan(materialBudgetSummary.remainLaborCent) }}</span>
</div>
<div class="budget-item" :class="materialBudgetSummary.remainCateringCent < 0 ? 'text-danger fw-bold' : 'text-success fw-bold'">
<span>剩余餐费额度</span>
<span>¥ {{ toYuan(materialBudgetSummary.remainCateringCent) }}</span>
</div>
</div>
<div v-else-if="materialBudgetLoading" class="sidebar-budget-card sidebar-budget-card--loading">
<div class="budget-title">预算看板</div>
@ -1657,7 +1665,7 @@ watch(
:on-change="(f, l) => handleUploadChangeWithCompression(f, l, buildMeetingInvoiceUploadChangeHandler(section.code, uploadField.key))"
:on-preview="handleMeetingInvoiceSectionPicturePreview"
:on-remove="buildMeetingInvoicePictureRemoveHandler(section.code, uploadField.key)"
accept=".jpg,.jpeg,.png,.webp,.gif,.bmp,.pdf"
:accept="uploadField.key === 'settlementFile' ? '.jpg,.jpeg,.png,.webp,.gif,.bmp,.pdf,.xls,.xlsx' : '.jpg,.jpeg,.png,.webp,.gif,.bmp,.pdf'"
>
<template #file="{ file }">
<MaterialPictureCardFileItem
@ -2121,6 +2129,8 @@ watch(
flex: 1;
padding: 32px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.material-module-card {
@ -2136,6 +2146,10 @@ watch(
background: transparent;
box-shadow: none;
border: none;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Custom Segmented Control */

View File

@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import MeetingQueryToolbar from './MeetingQueryToolbar.vue';
import ElementPlus from 'element-plus';
describe('MeetingQueryToolbar.vue', () => {
it('should render form items properly', () => {
const wrapper = mount(MeetingQueryToolbar, {
global: {
plugins: [ElementPlus]
},
props: {
queryForm: {
projectName: '',
topic: '',
meetingStatus: '',
auditStatus: ''
}
}
});
expect(wrapper.text()).toContain('项目名称');
expect(wrapper.text()).toContain('会议主题');
expect(wrapper.text()).toContain('会议状态');
expect(wrapper.text()).toContain('审核状态');
});
it('should emit load event when search button is clicked', async () => {
const wrapper = mount(MeetingQueryToolbar, {
global: {
plugins: [ElementPlus]
},
props: {
queryForm: {}
}
});
const buttons = wrapper.findAll('.el-button');
const searchBtn = buttons.find(b => b.text().includes('查询'));
expect(searchBtn).toBeDefined();
await searchBtn!.trigger('click');
expect(wrapper.emitted()).toHaveProperty('load');
});
it('should emit resetQuery event when reset button is clicked', async () => {
const wrapper = mount(MeetingQueryToolbar, {
global: {
plugins: [ElementPlus]
},
props: {
queryForm: {}
}
});
const buttons = wrapper.findAll('.el-button');
const resetBtn = buttons.find(b => b.text().includes('重置'));
expect(resetBtn).toBeDefined();
await resetBtn!.trigger('click');
expect(wrapper.emitted()).toHaveProperty('resetQuery');
});
});

View File

@ -106,9 +106,9 @@
<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="发票信息">
<!-- <el-form-item label="发票信息">
<el-input v-model="projectForm.invoiceInfo" />
</el-form-item>
</el-form-item> -->
<el-form-item label="主办单位负责人">
<el-input :model-value="projectForm.hostOwnerUsers || '系统自动取TENANT_ADMIN'" disabled />
</el-form-item>

View File

@ -1,8 +1,12 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
test: {
environment: "happy-dom"
},
server: {
host: "0.0.0.0",
port: 5173,