优化
This commit is contained in:
parent
52fd2e7560
commit
e0b089fbf8
@ -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>
|
||||
|
||||
@ -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, "导入行不能为空");
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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, "已归档模板不允许编辑或新增版本");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
);
|
||||
@ -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
|
||||
);
|
||||
@ -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';
|
||||
@ -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
|
||||
);
|
||||
Binary file not shown.
2354
frontend/package-lock.json
generated
2354
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -76,6 +76,7 @@ const handleMarkRead = () => {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.notif-detail-title {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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]);
|
||||
};
|
||||
|
||||
@ -65,6 +65,7 @@ const STATUS_TEXT_MAP: Record<string, string> = {
|
||||
AUDIT_RETURNED: "审核退回",
|
||||
FINANCE_CONFIRMED: "财务已确认",
|
||||
USER_CREATED: "用户创建",
|
||||
USER_PASSWORD_RESET: "密码重置",
|
||||
DELIVERED: "已送达",
|
||||
UNREAD: "未读",
|
||||
READ: "已读",
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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="劳务占比">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user