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

View File

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

View File

@ -12,6 +12,11 @@ import com.writeoff.module.expert.model.ExpertBankCardInfo;
import com.writeoff.module.expert.model.ExpertInfo; import com.writeoff.module.expert.model.ExpertInfo;
import com.writeoff.module.system.service.DataPermissionService; import com.writeoff.module.system.service.DataPermissionService;
import com.writeoff.security.AuthContext; 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.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -114,12 +119,31 @@ public class ExpertService {
StringBuilder whereClause = new StringBuilder( StringBuilder whereClause = new StringBuilder(
"WHERE e.tenant_id=" + PLATFORM_TENANT_ID + " AND e.is_deleted=0" "WHERE e.tenant_id=" + PLATFORM_TENANT_ID + " AND e.is_deleted=0"
); );
List<Object> countArgs = new ArrayList<>();
if (keyword != null && !keyword.trim().isEmpty()) { if (keyword != null && !keyword.trim().isEmpty()) {
String kw = keyword.trim().replace("'", "''"); String kw = keyword.trim().replace("'", "''");
whereClause.append(" AND (e.expert_name LIKE '%").append(kw).append("%' OR e.id_no LIKE '%").append(kw).append("%')"); 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( Integer total = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM expert e " + whereClause, "SELECT COUNT(1) FROM expert e " + whereClause,
Integer.class Integer.class
@ -138,7 +162,6 @@ public class ExpertService {
sql.append(whereClause); sql.append(whereClause);
sql.append(" ORDER BY e.id DESC LIMIT ").append(safeSize).append(" OFFSET ").append(offset); 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<ExpertInfo> list = jdbcTemplate.query(sql.toString(), EXPERT_ROW_MAPPER);
list = filterByExpertScope(list);
List<ExpertInfo> maskedList = new java.util.ArrayList<ExpertInfo>(list.size()); List<ExpertInfo> maskedList = new java.util.ArrayList<ExpertInfo>(list.size());
for (ExpertInfo item : list) { for (ExpertInfo item : list) {
maskedList.add(maskSensitiveFields(item)); maskedList.add(maskSensitiveFields(item));
@ -195,11 +218,13 @@ public class ExpertService {
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public ExpertInfo create(CreateExpertRequest request) { public ExpertInfo create(CreateExpertRequest request) {
String idNo = request.getIdNo() == null ? "" : request.getIdNo().trim().toUpperCase();
request.setIdNo(idNo);
Integer count = jdbcTemplate.queryForObject( Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id_no=? AND is_deleted=0", "SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id_no=? AND is_deleted=0",
Integer.class, Integer.class,
PLATFORM_TENANT_ID, PLATFORM_TENANT_ID,
request.getIdNo() idNo
); );
if (count != null && count > 0) { if (count != null && count > 0) {
throw new BusinessException(10001, "身份证号已存在"); throw new BusinessException(10001, "身份证号已存在");
@ -617,11 +642,76 @@ public class ExpertService {
name name
); );
if (byName.isEmpty()) { 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); 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) { private void validateImportExpert(CreateExpertRequest request, Set<String> batchIdNos, Set<String> batchPhones) {
if (request == null) { if (request == null) {
throw new BusinessException(10001, "导入行不能为空"); throw new BusinessException(10001, "导入行不能为空");

View File

@ -11,6 +11,11 @@ import com.writeoff.module.file.service.OssService;
import com.writeoff.module.expert.model.ExpertBankCardInfo; import com.writeoff.module.expert.model.ExpertBankCardInfo;
import com.writeoff.module.expert.model.ExpertInfo; import com.writeoff.module.expert.model.ExpertInfo;
import com.writeoff.security.AuthContext; 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.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -188,11 +193,13 @@ public class PlatformExpertService {
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public ExpertInfo create(CreateExpertRequest request) { public ExpertInfo create(CreateExpertRequest request) {
String idNo = request.getIdNo() == null ? "" : request.getIdNo().trim().toUpperCase();
request.setIdNo(idNo);
Integer count = jdbcTemplate.queryForObject( Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id_no=? AND is_deleted=0", "SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id_no=? AND is_deleted=0",
Integer.class, Integer.class,
PLATFORM_TENANT_ID, PLATFORM_TENANT_ID,
request.getIdNo() idNo
); );
if (count != null && count > 0) { if (count != null && count > 0) {
throw new BusinessException(10001, "身份证号已存在"); throw new BusinessException(10001, "身份证号已存在");
@ -614,11 +621,76 @@ public class PlatformExpertService {
name name
); );
if (byName.isEmpty()) { 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); 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) { private DictionaryItem resolveOptionalDictionaryItem(String dictType, String dictCode, String dictName, String label) {
boolean hasCode = dictCode != null && !dictCode.trim().isEmpty(); boolean hasCode = dictCode != null && !dictCode.trim().isEmpty();
boolean hasName = dictName != null && !dictName.trim().isEmpty(); boolean hasName = dictName != null && !dictName.trim().isEmpty();

View File

@ -172,7 +172,7 @@ public class MeetingController {
} }
@PutMapping("/{id}") @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, public ApiResponse<Meeting> update(@PathVariable("id") Long id,
@RequestBody @Valid CreateMeetingRequest request) { @RequestBody @Valid CreateMeetingRequest request) {
return ApiResponse.success(meetingService.update(id, request)); return ApiResponse.success(meetingService.update(id, request));

View File

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

View File

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

View File

@ -144,8 +144,8 @@ public class MeetingSummaryExportService {
projectName, projectName,
queryTenantName(tenantId) queryTenantName(tenantId)
); );
String meetingCategory = stringValue(meeting.get("meeting_category")); String meetingCategory = resolveDictName("MEETING_CATEGORY", stringValue(meeting.get("meeting_category")));
String location = stringValue(meeting.get("location")); String location = resolveDictName("MEETING_LOCATION", stringValue(meeting.get("location")));
String startTime = stringValue(meeting.get("start_time")); String startTime = stringValue(meeting.get("start_time"));
String endTime = stringValue(meeting.get("end_time")); String endTime = stringValue(meeting.get("end_time"));
String guestCountText = formatNumber(basicInfo.get("guestCount")); 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) { private List<Long> parseIdList(Object value) {
if (!(value instanceof List)) { if (!(value instanceof List)) {
return Collections.emptyList(); return Collections.emptyList();

View File

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

View File

@ -22,6 +22,7 @@ import com.writeoff.module.system.model.UserRoleHistory;
import com.writeoff.security.AuthContext; import com.writeoff.security.AuthContext;
import com.writeoff.security.PasswordCodecService; import com.writeoff.security.PasswordCodecService;
import com.writeoff.security.PasswordPolicyService; import com.writeoff.security.PasswordPolicyService;
import com.writeoff.security.PasswordSetupService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
@ -55,7 +56,9 @@ public class SystemUserService {
private final PasswordPolicyService passwordPolicyService; private final PasswordPolicyService passwordPolicyService;
private final PasswordCodecService passwordCodecService; private final PasswordCodecService passwordCodecService;
private final TransactionTemplate transactionTemplate; private final TransactionTemplate transactionTemplate;
private final PasswordSetupService passwordSetupService;
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
private final String frontendBaseUrl;
private static final RowMapper<SystemUser> USER_ROW_MAPPER = (rs, n) -> new SystemUser( private static final RowMapper<SystemUser> USER_ROW_MAPPER = (rs, n) -> new SystemUser(
rs.getLong("id"), rs.getLong("id"),
@ -97,13 +100,17 @@ public class SystemUserService {
NotificationDispatchService notificationDispatchService, NotificationDispatchService notificationDispatchService,
PasswordPolicyService passwordPolicyService, PasswordPolicyService passwordPolicyService,
PasswordCodecService passwordCodecService, 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.jdbcTemplate = jdbcTemplate;
this.dataPermissionService = dataPermissionService; this.dataPermissionService = dataPermissionService;
this.notificationDispatchService = notificationDispatchService; this.notificationDispatchService = notificationDispatchService;
this.passwordPolicyService = passwordPolicyService; this.passwordPolicyService = passwordPolicyService;
this.passwordCodecService = passwordCodecService; this.passwordCodecService = passwordCodecService;
this.transactionTemplate = new TransactionTemplate(transactionManager); this.transactionTemplate = new TransactionTemplate(transactionManager);
this.passwordSetupService = passwordSetupService;
this.frontendBaseUrl = frontendBaseUrl;
} }
public PageResult<SystemUser> listUsers(int pageNo, int pageSize, Boolean includeDeleted) { 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 phone = request.getPhone() == null ? "" : request.getPhone().trim();
final String email = request.getEmail() == null ? "" : request.getEmail().trim(); final String email = request.getEmail() == null ? "" : request.getEmail().trim();
final String rawPassword = request.getPassword() == null ? "" : request.getPassword().trim(); final String rawPassword = request.getPassword() == null ? "" : request.getPassword().trim();
if (rawPassword.isEmpty()) { final String finalPassword = rawPassword.isEmpty() ? ("Tmp@" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8) + "aA1") : rawPassword;
throw new BusinessException(10001, "\u5bc6\u7801\u4e0d\u80fd\u4e3a\u7a7a"); passwordPolicyService.validate(finalPassword);
}
passwordPolicyService.validate(rawPassword);
final String validFrom = request.getValidFrom() == null || request.getValidFrom().trim().isEmpty() final String validFrom = request.getValidFrom() == null || request.getValidFrom().trim().isEmpty()
? LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) ? LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
: normalizeDateTimeString(request.getValidFrom()); : normalizeDateTimeString(request.getValidFrom());
@ -191,7 +196,7 @@ public class SystemUserService {
: normalizeDateTimeString(request.getValidTo()); : normalizeDateTimeString(request.getValidTo());
return transactionTemplate.execute(status -> { return transactionTemplate.execute(status -> {
assertPhoneAvailable(phone, null); assertPhoneAvailable(phone, null);
String passwordHash = passwordCodecService.encode(rawPassword); String passwordHash = passwordCodecService.encode(finalPassword);
String tenantSwitchAccountKey = resolveTenantSwitchAccountKeyByPhone(phone, null); String tenantSwitchAccountKey = resolveTenantSwitchAccountKeyByPhone(phone, null);
KeyHolder keyHolder = new GeneratedKeyHolder(); KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> { jdbcTemplate.update(connection -> {
@ -217,7 +222,8 @@ public class SystemUserService {
}, keyHolder); }, keyHolder);
Long id = keyHolder.getKey() == null ? null : keyHolder.getKey().longValue(); Long id = keyHolder.getKey() == null ? null : keyHolder.getKey().longValue();
autoAssignExecutorRoleWhenCreatorIsProjectExecutor(id); 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, "", ""); 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.setTimestamp(idx++, validTo == null ? Timestamp.valueOf(LocalDateTime.of(2099, 12, 31, 23, 59, 59)) : validTo);
ps.setLong(idx++, operator); ps.setLong(idx++, operator);
if (request.getPassword() != null && !request.getPassword().trim().isEmpty()) { if (request.getPassword() != null && !request.getPassword().trim().isEmpty()) {
passwordPolicyService.validate(request.getPassword()); passwordPolicyService.validate(request.getPassword().trim());
ps.setString(idx++, passwordCodecService.encode(request.getPassword())); ps.setString(idx++, passwordCodecService.encode(request.getPassword().trim()));
} }
ps.setLong(idx++, tenantId()); ps.setLong(idx++, tenantId());
ps.setLong(idx, userId); 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); assertUserExists(userId);
passwordPolicyService.validate(request.getNewPassword());
jdbcTemplate.update( SystemUser user = jdbcTemplate.queryForObject(
"UPDATE sys_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", "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",
passwordCodecService.encode(request.getNewPassword()), (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 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) { public void changeMyPassword(Long userId, String oldPassword, String newPassword) {
@ -707,8 +753,8 @@ public class SystemUserService {
if (item.getUserName() == null || item.getUserName().trim().isEmpty()) { if (item.getUserName() == null || item.getUserName().trim().isEmpty()) {
throw new BusinessException(10001, "用户名不能为空"); throw new BusinessException(10001, "用户名不能为空");
} }
if (item.getPassword() == null || item.getPassword().trim().isEmpty()) { if (item.getPassword() != null && !item.getPassword().trim().isEmpty()) {
throw new BusinessException(10001, "密码不能为空"); passwordPolicyService.validate(item.getPassword().trim());
} }
ImportValidationUtils.validatePhone(item.getPhone()); ImportValidationUtils.validatePhone(item.getPhone());
ImportValidationUtils.validateRequiredEmail(item.getEmail()); ImportValidationUtils.validateRequiredEmail(item.getEmail());
@ -717,7 +763,6 @@ public class SystemUserService {
if (!batchPhones.add(phone)) { if (!batchPhones.add(phone)) {
throw new BusinessException(10001, "批次内手机号重复"); throw new BusinessException(10001, "批次内手机号重复");
} }
passwordPolicyService.validate(item.getPassword().trim());
String roleCode = ImportValidationUtils.trim(item.getRoleCode()); String roleCode = ImportValidationUtils.trim(item.getRoleCode());
if (roleCode.isEmpty()) { if (roleCode.isEmpty()) {
return null; return null;
@ -750,7 +795,7 @@ public class SystemUserService {
: ex.getMessage(); : 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) { if (userId == null || userId <= 0) {
return; return;
} }
@ -761,18 +806,26 @@ public class SystemUserService {
); );
String tenantCode = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_code")); 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 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>(); Map<String, Object> variables = new LinkedHashMap<String, Object>();
variables.put("userId", userId); variables.put("userId", userId);
variables.put("targetUserId", userId); variables.put("targetUserId", userId);
variables.put("userName", userName); variables.put("userName", userName);
variables.put("phone", phone); variables.put("phone", phone);
variables.put("email", email); variables.put("email", email);
variables.put("password", rawPassword);
variables.put("validFrom", validFrom); variables.put("validFrom", validFrom);
variables.put("validTo", validTo); variables.put("validTo", displayValidTo);
variables.put("tenantCode", tenantCode); variables.put("tenantCode", tenantCode);
variables.put("tenantName", tenantName); 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(); DispatchNotificationRequest request = new DispatchNotificationRequest();
request.setIdempotencyKey("user-created-" + tenantId() + "-" + userId); request.setIdempotencyKey("user-created-" + tenantId() + "-" + userId);
request.setEventCode("USER_CREATED"); request.setEventCode("USER_CREATED");

View File

@ -270,7 +270,8 @@ public class TenantService {
); );
String setupLink = passwordSetupService.issueTenantAdminSetupLink(tenantId, uid, safeUserId()); 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>(); java.util.Map<String, Object> data = new java.util.LinkedHashMap<String, Object>();
data.put("tenantId", tenantId); data.put("tenantId", tenantId);
@ -278,6 +279,7 @@ public class TenantService {
data.put("roleId", roleId); data.put("roleId", roleId);
data.put("roleCode", roleCode); data.put("roleCode", roleCode);
data.put("action", action); data.put("action", action);
data.put("setupLink", setupLink);
return data; return data;
} }

View File

@ -39,11 +39,23 @@ public class TemplateController {
@RequestParam(value = "status", required = false) String status, @RequestParam(value = "status", required = false) String status,
@RequestParam(value = "scopeType", required = false) String scopeType, @RequestParam(value = "scopeType", required = false) String scopeType,
@RequestParam(value = "bizScene", required = false) String bizScene, @RequestParam(value = "bizScene", required = false) String bizScene,
@RequestParam(value = "watermarkEnabled", required = false) Boolean watermarkEnabled,
@RequestParam(value = "effectiveStatus", required = false) String effectiveStatus, @RequestParam(value = "effectiveStatus", required = false) String effectiveStatus,
@RequestParam(value = "pageNo", defaultValue = "1") int pageNo, @RequestParam(value = "pageNo", defaultValue = "1") int pageNo,
@RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { @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") @GetMapping("/published-options")
@ -98,6 +110,12 @@ public class TemplateController {
return ApiResponse.success(templateService.create(request)); 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") @PostMapping("/upload-sign")
@RequirePermission(value = "template.create", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_UPLOAD_SIGN") @RequirePermission(value = "template.create", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_UPLOAD_SIGN")
public ApiResponse<Map<String, Object>> uploadSign(@RequestBody @Valid TemplateUploadSignRequest request) { public ApiResponse<Map<String, Object>> uploadSign(@RequestBody @Valid TemplateUploadSignRequest request) {
@ -105,7 +123,7 @@ public class TemplateController {
} }
@GetMapping("/{id}/versions") @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) { public ApiResponse<List<TemplateVersionInfo>> versions(@PathVariable("id") Long id) {
return ApiResponse.success(templateService.versions(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"))); 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") @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, public ApiResponse<Map<String, Object>> versionDiff(@PathVariable("id") Long id,
@RequestParam(value = "leftVersionNo", required = false) Integer leftVersionNo, @RequestParam(value = "leftVersionNo", required = false) Integer leftVersionNo,
@RequestParam(value = "rightVersionNo", required = false) Integer rightVersionNo) { @RequestParam(value = "rightVersionNo", required = false) Integer rightVersionNo) {
@ -172,7 +184,6 @@ public class TemplateController {
@RequestParam(value = "userId", required = false) Long userId, @RequestParam(value = "userId", required = false) Long userId,
@RequestParam(value = "userKeyword", required = false) String userKeyword, @RequestParam(value = "userKeyword", required = false) String userKeyword,
@RequestParam(value = "versionNo", required = false) Integer versionNo, @RequestParam(value = "versionNo", required = false) Integer versionNo,
@RequestParam(value = "downloadType", required = false) String downloadType,
@RequestParam(value = "ip", required = false) String ip, @RequestParam(value = "ip", required = false) String ip,
@RequestParam(value = "downloadedFrom", required = false) String downloadedFrom, @RequestParam(value = "downloadedFrom", required = false) String downloadedFrom,
@RequestParam(value = "downloadedTo", required = false) String downloadedTo, @RequestParam(value = "downloadedTo", required = false) String downloadedTo,
@ -184,7 +195,6 @@ public class TemplateController {
userId, userId,
userKeyword, userKeyword,
versionNo, versionNo,
downloadType,
ip, ip,
downloadedFrom, downloadedFrom,
downloadedTo, downloadedTo,

View File

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

View File

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

View File

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

View File

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

View File

@ -59,7 +59,6 @@ public class TemplateService {
rs.getString("current_object_key"), rs.getString("current_object_key"),
rs.getString("effective_from"), rs.getString("effective_from"),
rs.getString("effective_to"), rs.getString("effective_to"),
rs.getInt("watermark_enabled") == 1,
rs.getInt("download_rate_limit_per_hour"), rs.getInt("download_rate_limit_per_hour"),
rs.getString("created_at"), rs.getString("created_at"),
rs.getString("updated_at") rs.getString("updated_at")
@ -86,7 +85,6 @@ public class TemplateService {
rs.getString("user_phone"), rs.getString("user_phone"),
rs.getString("object_key"), rs.getString("object_key"),
rs.getString("download_type"), rs.getString("download_type"),
rs.getString("watermark_text"),
rs.getObject("project_id") == null ? null : rs.getLong("project_id"), rs.getObject("project_id") == null ? null : rs.getLong("project_id"),
rs.getObject("meeting_id") == null ? null : rs.getLong("meeting_id"), rs.getObject("meeting_id") == null ? null : rs.getLong("meeting_id"),
rs.getString("ip"), rs.getString("ip"),
@ -114,7 +112,6 @@ public class TemplateService {
String status, String status,
String scopeType, String scopeType,
String bizScene, String bizScene,
Boolean watermarkEnabled,
String effectiveStatus, String effectiveStatus,
int pageNo, int pageNo,
int pageSize) { int pageSize) {
@ -150,10 +147,7 @@ public class TemplateService {
whereSql.append(" AND t.biz_scene=?"); whereSql.append(" AND t.biz_scene=?");
whereArgs.add(normalizedBizScene); whereArgs.add(normalizedBizScene);
} }
if (watermarkEnabled != null) {
whereSql.append(" AND t.watermark_enabled=?");
whereArgs.add(Boolean.TRUE.equals(watermarkEnabled) ? 1 : 0);
}
appendEffectiveStatusFilter(whereSql, normalizedEffectiveStatus); appendEffectiveStatusFilter(whereSql, normalizedEffectiveStatus);
Integer total = jdbcTemplate.queryForObject( Integer total = jdbcTemplate.queryForObject(
@ -176,6 +170,16 @@ public class TemplateService {
return new PageResult<>(list, totalCount, safePage, safeSize); 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) { public List<TemplateInfo> listPublishedOptions(String bizScene) {
String normalizedBizScene = normalizeOptionalBizScene(bizScene); String normalizedBizScene = normalizeOptionalBizScene(bizScene);
StringBuilder sql = new StringBuilder(templateSelectSql()) StringBuilder sql = new StringBuilder(templateSelectSql())
@ -248,8 +252,8 @@ public class TemplateService {
KeyHolder keyHolder = new GeneratedKeyHolder(); KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> { jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement( 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) " + "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'), ?, ?, ?, ?)", "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 Statement.RETURN_GENERATED_KEYS
); );
ps.setLong(1, tenantId()); ps.setLong(1, tenantId());
@ -262,10 +266,9 @@ public class TemplateService {
ps.setString(8, normalizeBizScene(request.getBizScene())); ps.setString(8, normalizeBizScene(request.getBizScene()));
ps.setString(9, effectiveFrom); ps.setString(9, effectiveFrom);
ps.setString(10, effectiveTo); ps.setString(10, effectiveTo);
ps.setInt(11, Boolean.TRUE.equals(request.getWatermarkEnabled()) ? 1 : 0); ps.setInt(11, downloadRateLimitPerHour);
ps.setInt(12, downloadRateLimitPerHour); ps.setLong(12, userId);
ps.setLong(13, userId); ps.setLong(13, userId);
ps.setLong(14, userId);
return ps; return ps;
}, keyHolder); }, keyHolder);
Long templateId = keyHolder.getKey() == null ? null : keyHolder.getKey().longValue(); Long templateId = keyHolder.getKey() == null ? null : keyHolder.getKey().longValue();
@ -282,6 +285,36 @@ public class TemplateService {
return findById(validTemplateId); 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) { public List<TemplateVersionInfo> versions(Long templateId) {
assertTemplateExists(templateId); assertTemplateExists(templateId);
return jdbcTemplate.query( return jdbcTemplate.query(
@ -451,15 +484,14 @@ public class TemplateService {
assertTemplateEffectiveNow(template, "下载"); assertTemplateEffectiveNow(template, "下载");
assertDownloadRateLimit(template, userId); assertDownloadRateLimit(template, userId);
jdbcTemplate.update( 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) " + "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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
tenantId(), tenantId(),
template.getId(), template.getId(),
template.getCurrentVersionNo(), template.getCurrentVersionNo(),
userId, userId,
template.getCurrentObjectKey(), template.getCurrentObjectKey(),
"NORMAL", "NORMAL",
null,
template.getProjectId(), template.getProjectId(),
template.getMeetingId(), template.getMeetingId(),
ip, ip,
@ -473,46 +505,6 @@ public class TemplateService {
return result; 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) { public Map<String, Object> versionDiff(Long templateId, Integer leftVersionNo, Integer rightVersionNo) {
assertTemplateExists(templateId); assertTemplateExists(templateId);
@ -548,7 +540,6 @@ public class TemplateService {
Long userId, Long userId,
String userKeyword, String userKeyword,
Integer versionNo, Integer versionNo,
String downloadType,
String ip, String ip,
String downloadedFrom, String downloadedFrom,
String downloadedTo, String downloadedTo,
@ -559,7 +550,6 @@ public class TemplateService {
int offset = (safePage - 1) * safeSize; int offset = (safePage - 1) * safeSize;
String normalizedTemplateName = trimToNull(templateName); String normalizedTemplateName = trimToNull(templateName);
String normalizedUserKeyword = trimToNull(userKeyword); String normalizedUserKeyword = trimToNull(userKeyword);
String normalizedDownloadType = normalizeOptionalDownloadType(downloadType);
String normalizedIp = trimToNull(ip); String normalizedIp = trimToNull(ip);
String normalizedDownloadedFrom = trimToNull(downloadedFrom); String normalizedDownloadedFrom = trimToNull(downloadedFrom);
String normalizedDownloadedTo = trimToNull(downloadedTo); String normalizedDownloadedTo = trimToNull(downloadedTo);
@ -598,10 +588,7 @@ public class TemplateService {
whereSql.append(" AND l.version_no=?"); whereSql.append(" AND l.version_no=?");
whereArgs.add(versionNo); whereArgs.add(versionNo);
} }
if (normalizedDownloadType != null) {
whereSql.append(" AND l.download_type=?");
whereArgs.add(normalizedDownloadType);
}
if (normalizedIp != null) { if (normalizedIp != null) {
whereSql.append(" AND l.ip LIKE ?"); whereSql.append(" AND l.ip LIKE ?");
whereArgs.add("%" + normalizedIp + "%"); whereArgs.add("%" + normalizedIp + "%");
@ -628,7 +615,7 @@ public class TemplateService {
List<TemplateDownloadLogInfo> list = jdbcTemplate.query( List<TemplateDownloadLogInfo> list = jdbcTemplate.query(
"SELECT l.id, l.template_id, COALESCE(t.template_name, '') AS template_name, " + "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.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" + "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 + whereSql +
" ORDER BY l.downloaded_at DESC, l.id DESC LIMIT ? OFFSET ?", " 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, " + "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_from, '%Y-%m-%d %H:%i:%s') AS effective_from, " +
"DATE_FORMAT(t.effective_to, '%Y-%m-%d %H:%i:%s') AS effective_to, " + "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.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 " + "DATE_FORMAT(t.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " +
"FROM template t " + "FROM template t " +
@ -806,17 +793,7 @@ public class TemplateService {
return normalized; 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) { private String normalizeContentType(String contentType) {
if (contentType == null || contentType.trim().isEmpty()) { if (contentType == null || contentType.trim().isEmpty()) {
@ -936,7 +913,7 @@ public class TemplateService {
private void assertTemplateEditable(TemplateInfo template) { private void assertTemplateEditable(TemplateInfo template) {
if ("ARCHIVED".equalsIgnoreCase(template.getStatus())) { if ("ARCHIVED".equalsIgnoreCase(template.getStatus())) {
throw new BusinessException(10003, "已归档模板不允许新增版本"); throw new BusinessException(10003, "已归档模板不允许编辑或新增版本");
} }
} }

View File

@ -20,6 +20,7 @@ import java.util.Map;
@Service @Service
public class PasswordSetupService { public class PasswordSetupService {
private static final String SCENARIO_TENANT_ADMIN_SETUP = "TENANT_ADMIN_SETUP"; 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 static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
@ -71,6 +72,36 @@ public class PasswordSetupService {
return buildSetupLink(tenantCode, rawToken); 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) { public Map<String, Object> verifyTenantPasswordSetupToken(String tenantCode, String rawToken) {
Map<String, Object> tokenRecord = loadAvailableTokenRecord(tenantCode, rawToken); Map<String, Object> tokenRecord = loadAvailableTokenRecord(tenantCode, rawToken);
Map<String, Object> data = new LinkedHashMap<String, Object>(); Map<String, Object> data = new LinkedHashMap<String, Object>();
@ -112,11 +143,12 @@ public class PasswordSetupService {
jdbcTemplate.update( jdbcTemplate.update(
"UPDATE auth_password_setup_token " + "UPDATE auth_password_setup_token " +
"SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP " + "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, userId,
tenantId, tenantId,
userId, userId,
SCENARIO_TENANT_ADMIN_SETUP, SCENARIO_TENANT_ADMIN_SETUP,
SCENARIO_USER_SETUP,
tokenId tokenId
); );
@ -139,11 +171,12 @@ public class PasswordSetupService {
"FROM auth_password_setup_token tkn " + "FROM auth_password_setup_token tkn " +
"JOIN tenant t ON tkn.tenant_id=t.id " + "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 " + "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 " + "AND t.is_deleted=0 AND u.is_deleted=0 " +
"LIMIT 1", "LIMIT 1",
hashToken(normalizedToken), hashToken(normalizedToken),
SCENARIO_TENANT_ADMIN_SETUP SCENARIO_TENANT_ADMIN_SETUP,
SCENARIO_USER_SETUP
); );
if (rows.isEmpty()) { if (rows.isEmpty()) {
throw new BusinessException(10001, "设置链接无效或已过期"); throw new BusinessException(10001, "设置链接无效或已过期");
@ -181,6 +214,22 @@ public class PasswordSetupService {
return rows.get(0); 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) { private String buildSetupLink(String tenantCode, String rawToken) {
String baseUrl = normalizeBaseUrl(frontendBaseUrl); String baseUrl = normalizeBaseUrl(frontendBaseUrl);
String path = "/" + tenantCode + "/setup-password?token=" + urlEncode(rawToken); String path = "/" + tenantCode + "/setup-password?token=" + urlEncode(rawToken);

View File

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

View File

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

View File

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

View File

@ -0,0 +1,27 @@
SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission);
INSERT INTO permission (id, permission_code, permission_name, module)
SELECT @next_permission_id, 'meeting.update', '编辑会议', 'meeting'
FROM dual
WHERE NOT EXISTS (
SELECT 1
FROM permission
WHERE permission_code = 'meeting.update'
);
SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission);
INSERT INTO role_permission (id, tenant_id, role_id, permission_id)
SELECT
(@next_role_permission_id := @next_role_permission_id + 1) AS id,
rp.tenant_id,
rp.role_id,
p.id AS permission_id
FROM role_permission rp
JOIN permission source_p ON source_p.id = rp.permission_id AND source_p.permission_code = 'meeting.create'
JOIN permission p ON p.permission_code = 'meeting.update'
WHERE NOT EXISTS (
SELECT 1
FROM role_permission exist_rp
WHERE exist_rp.tenant_id = rp.tenant_id
AND exist_rp.role_id = rp.role_id
AND exist_rp.permission_id = p.id
);

File diff suppressed because it is too large Load Diff

View File

@ -6,9 +6,11 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.7.7", "axios": "^1.7.7",
"compressorjs": "^1.3.0", "compressorjs": "^1.3.0",
"element-plus": "^2.8.4", "element-plus": "^2.8.4",
@ -18,8 +20,13 @@
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.1",
"@vitejs/plugin-vue": "^5.1.4", "@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", "typescript": "^5.6.2",
"vite": "^5.4.8" "vite": "^5.4.8",
"vitest": "^4.1.8"
} }
} }

View File

@ -716,12 +716,23 @@ export const fetchTemplates = (params?: {
status?: string; status?: string;
scopeType?: string; scopeType?: string;
bizScene?: string; bizScene?: string;
watermarkEnabled?: boolean;
effectiveStatus?: string; effectiveStatus?: string;
pageNo?: number; pageNo?: number;
pageSize?: number; pageSize?: number;
}) => }) =>
http.get("/templates", { params }); 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 }) => export const fetchPublishedTemplateOptions = (params?: { bizScene?: string }) =>
http.get("/templates/published-options", { params }); http.get("/templates/published-options", { params });
export const fetchTemplateTypeOptions = () => http.get("/templates/type-options"); export const fetchTemplateTypeOptions = () => http.get("/templates/type-options");
@ -742,9 +753,21 @@ export const createTemplate = (payload: {
changeLog?: string; changeLog?: string;
effectiveFrom?: string; effectiveFrom?: string;
effectiveTo?: string; effectiveTo?: string;
watermarkEnabled?: boolean;
downloadRateLimitPerHour?: number; downloadRateLimitPerHour?: number;
}) => http.post("/templates", payload); }) => 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: { export const fetchTemplateUploadSign = (payload: {
fileName: string; fileName: string;
contentType?: 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 }) => export const rollbackTemplate = (id: number, payload: { versionNo: number; rollbackReason: string }) =>
http.post(`/templates/${id}/rollback`, payload); http.post(`/templates/${id}/rollback`, payload);
export const downloadTemplate = (id: number) => http.get(`/templates/${id}/download`); 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 }) => export const fetchTemplateVersionDiff = (id: number, params?: { leftVersionNo?: number; rightVersionNo?: number }) =>
http.get(`/templates/${id}/versions/diff`, { params }); http.get(`/templates/${id}/versions/diff`, { params });
export const fetchTemplateDownloadLogs = (params?: { export const fetchTemplateDownloadLogs = (params?: {
@ -774,7 +795,7 @@ export const fetchTemplateDownloadLogs = (params?: {
userId?: number; userId?: number;
userKeyword?: string; userKeyword?: string;
versionNo?: number; versionNo?: number;
downloadType?: "NORMAL" | "WATERMARK";
ip?: string; ip?: string;
downloadedFrom?: string; downloadedFrom?: string;
downloadedTo?: string; downloadedTo?: string;

View File

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

View File

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

View File

@ -1,11 +1,47 @@
import * as xlsx from "xlsx";
export const readTextFile = (file: File): Promise<string> => export const readTextFile = (file: File): Promise<string> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const reader = new FileReader(); 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.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[][] = []) => { export const downloadCsvTemplate = (fileName: string, headers: string[], sampleRows: string[][] = []) => {
downloadCsvRows(fileName, [headers, ...sampleRows]); downloadCsvRows(fileName, [headers, ...sampleRows]);
}; };

View File

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

View File

@ -64,7 +64,8 @@
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item command="dashboard">工作台</el-dropdown-item> <el-dropdown-item command="dashboard">工作台</el-dropdown-item>
<el-dropdown-item command="profile">个人设置</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> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
@ -159,7 +160,8 @@
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item command="dashboard">工作台</el-dropdown-item> <el-dropdown-item command="dashboard">工作台</el-dropdown-item>
<el-dropdown-item command="profile">个人设置</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> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
@ -281,7 +283,7 @@ import { useRoute, useRouter } from "vue-router";
import BreadcrumbNav from "../../components/BreadcrumbNav.vue"; import BreadcrumbNav from "../../components/BreadcrumbNav.vue";
import GlobalSearchLauncher from "../../components/GlobalSearchLauncher.vue"; import GlobalSearchLauncher from "../../components/GlobalSearchLauncher.vue";
import InAppNotificationDetailDialog from "../../components/InAppNotificationDetailDialog.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 { PERMS } from "../../constants/permissions";
import { useAppearanceStore } from "../../stores/appearance"; import { useAppearanceStore } from "../../stores/appearance";
import { useAuthStore } from "../../stores/auth"; import { useAuthStore } from "../../stores/auth";
@ -554,6 +556,19 @@ const handleUserCommand = async (command: string) => {
await router.push(profileRoute.value); await router.push(profileRoute.value);
return; 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") { if (command === "logout") {
await logoutAll(); await logoutAll();
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
<template> <template>
<PageContainer title="项目管理"> <PageContainer title="项目管理">
<QueryToolbar> <QueryToolbar>
<el-form :inline="true" @submit.prevent class="project-page-toolbar"> <el-form :inline="true" @submit.prevent class="project-page-toolbar">
@ -34,16 +34,16 @@
<span v-else>-</span> <span v-else>-</span>
</template> </template>
</el-table-column> --> </el-table-column> -->
<el-table-column prop="budgetCent" label="预算(元)" :formatter="budgetFormatter" /> <el-table-column prop="budgetCent" label="预算(元)" :formatter="budgetFormatter" width="100"/>
<el-table-column label="费用合计(元)" :formatter="projectFeeTotalFormatter" /> <el-table-column label="费用合计(元)" :formatter="projectFeeTotalFormatter" width="115"/>
<!-- <el-table-column prop="budgetExecutionRatio" label="预算执行率" :formatter="executionRatioFormatter" /> --> <!-- <el-table-column prop="budgetExecutionRatio" label="预算执行率" :formatter="executionRatioFormatter" /> -->
<el-table-column prop="meetingTotal" label="会议总期数" width="100"/> <el-table-column prop="meetingTotal" label="会议总期数" width="90"/>
<el-table-column label="项目周期" width="200"> <el-table-column label="项目周期" width="200">
<template #default="{ row }">{{ row.startDate || "-" }} ~ {{ row.endDate || "-" }}</template> <template #default="{ row }">{{ row.startDate || "-" }} ~ {{ row.endDate || "-" }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="status" label="状态" :formatter="statusFormatter" width="100"/> <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 }"> <template #default="{ row }">
<el-button v-if="canCreate" size="small" type="primary" @click="openCreateDrawer(row)">添加子项目</el-button> <el-button v-if="canCreate" size="small" type="primary" @click="openCreateDrawer(row)">添加子项目</el-button>
<el-button size="small" @click="openDetailDrawer(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="afterValue" label="变更后" />
<el-table-column prop="changeReason" label="变更原因" width="150" /> <el-table-column prop="changeReason" label="变更原因" width="150" />
<el-table-column prop="operatorUserName" label="操作人" width="140" /> <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-column prop="createdAt" label="记录时间" width="180" />
</el-table> </el-table>
</el-drawer> </el-drawer>
<el-dialog v-model="bindingVisible" title="绑定项目人员" :width="DIALOG_WIDTH.lg"> <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="项目负责人"> <el-form-item v-if="!isProjectExecutorRole" label="项目负责人">
<div class="binding-select-field"> <div class="binding-select-field">
<div v-if="ownerReadonlyBoundUsers.length" class="binding-readonly-block"> <div v-if="ownerReadonlyBoundUsers.length" class="binding-readonly-block">

View File

@ -16,16 +16,7 @@
/> />
</el-select> </el-select>
</el-form-item> </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-form-item>
<el-button v-if="canRead" type="primary" @click="handleTemplateSearch">查询</el-button> <el-button v-if="canRead" type="primary" @click="handleTemplateSearch">查询</el-button>
</el-form-item> </el-form-item>
@ -47,23 +38,19 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="bizScene" label="业务场景" width="140" :formatter="bizSceneFormatter" /> <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 prop="status" label="状态" width="110" :formatter="statusFormatter" />
<el-table-column label="生效时间" min-width="220"> <el-table-column label="生效时间" min-width="220">
<template #default="{ row }"> <template #default="{ row }">
{{ formatEffectiveTime(row) }} {{ formatEffectiveTime(row) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="水印" width="90">
<template #default="{ row }"> <!-- <el-table-column prop="downloadRateLimitPerHour" label="限流/小时" width="110" /> -->
{{ 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 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 }"> <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)"> <el-button size="small" v-if="canRead" @click="focusLogs(row)">
{{ canReadAllLogs ? "查看下载日志" : "查看我的下载日志" }} {{ canReadAllLogs ? "查看下载日志" : "查看我的下载日志" }}
</el-button> </el-button>
@ -76,15 +63,7 @@
> >
下载 下载
</el-button> </el-button>
<el-button
size="small"
type="primary"
v-if="canDownload"
:disabled="isWatermarkDownloadBlocked(row)"
@click="handleDownloadWatermark(row)"
>
水印下载
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -123,12 +102,7 @@
<el-form-item label="版本号"> <el-form-item label="版本号">
<el-input-number v-model="logQuery.versionNo" :min="1" class="w-input-sm" /> <el-input-number v-model="logQuery.versionNo" :min="1" class="w-input-sm" />
</el-form-item> </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-form-item v-if="canReadAllLogs" label="IP">
<el-input v-model="logQuery.ip" clearable placeholder="请输入 IP" class="w-input-md" /> <el-input v-model="logQuery.ip" clearable placeholder="请输入 IP" class="w-input-md" />
</el-form-item> </el-form-item>
@ -157,11 +131,7 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="versionNo" label="版本号" width="90" /> <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"> <el-table-column v-if="canReadAllLogs" label="下载人" min-width="180">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row.userName || row.userPhone"> <span v-if="row.userName || row.userPhone">
@ -170,7 +140,7 @@
<span v-else>{{ `用户#${row.userId}` }}</span> <span v-else>{{ `用户#${row.userId}` }}</span>
</template> </template>
</el-table-column> </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="objectKey" label="ObjectKey" min-width="260" show-overflow-tooltip />
<el-table-column prop="ip" label="IP" width="140" /> <el-table-column prop="ip" label="IP" width="140" />
<el-table-column prop="downloadedAt" label="下载时间" width="180" /> <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="适用范围">{{ getScopeTypeLabel(detailRow.scopeType) }}</el-descriptions-item>
<el-descriptions-item label="当前版本">V{{ detailRow.currentVersionNo || 1 }}</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="生效时间">{{ 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.downloadRateLimitPerHour ?? "-" }}</el-descriptions-item>
<el-descriptions-item label="最近更新">{{ detailRow.updatedAt || "-" }}</el-descriptions-item> <el-descriptions-item label="最近更新">{{ detailRow.updatedAt || "-" }}</el-descriptions-item>
<el-descriptions-item label="下载状态"> <el-descriptions-item label="下载状态">
@ -216,14 +186,6 @@
<el-button v-if="canDownload" type="primary" :disabled="isDownloadBlocked(detailRow)" @click="handleDownload(detailRow)"> <el-button v-if="canDownload" type="primary" :disabled="isDownloadBlocked(detailRow)" @click="handleDownload(detailRow)">
下载 下载
</el-button> </el-button>
<el-button
v-if="canDownload"
type="primary"
:disabled="isWatermarkDownloadBlocked(detailRow)"
@click="handleDownloadWatermark(detailRow)"
>
水印下载
</el-button>
</div> </div>
<el-descriptions v-if="currentVersionDetail" class="mt-md" :column="2" border> <el-descriptions v-if="currentVersionDetail" class="mt-md" :column="2" border>
@ -261,11 +223,7 @@
<el-tab-pane :label="canReadAllLogs ? '最近下载' : '我的最近下载'"> <el-tab-pane :label="canReadAllLogs ? '最近下载' : '我的最近下载'">
<el-table :data="detailDownloadRows" empty-text="暂无下载记录"> <el-table :data="detailDownloadRows" empty-text="暂无下载记录">
<el-table-column prop="downloadedAt" label="下载时间" width="180" /> <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"> <el-table-column v-if="canReadAllLogs" label="下载人" min-width="180">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row.userName || row.userPhone"> <span v-if="row.userName || row.userPhone">
@ -274,7 +232,7 @@
<span v-else>{{ `用户#${row.userId}` }}</span> <span v-else>{{ `用户#${row.userId}` }}</span>
</template> </template>
</el-table-column> </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-column prop="ip" label="IP" width="140" />
</el-table> </el-table>
</el-tab-pane> </el-tab-pane>
@ -291,11 +249,11 @@ import { ElMessage, ElMessageBox } from "element-plus";
import PageContainer from "../../components/PageContainer.vue"; import PageContainer from "../../components/PageContainer.vue";
import { import {
downloadTemplate, downloadTemplate,
downloadTemplateWatermark,
fetchTemplateDownloadLogs, fetchTemplateViewList,
fetchTemplateTypeOptions, fetchTemplateTypeOptions,
fetchTemplateDownloadLogs,
fetchTemplateVersions, fetchTemplateVersions,
fetchTemplates,
} from "../../api/modules"; } from "../../api/modules";
import { PERMS } from "../../constants/permissions"; import { PERMS } from "../../constants/permissions";
import { DRAWER_SIZE } from "../../constants/ui"; import { DRAWER_SIZE } from "../../constants/ui";
@ -323,6 +281,7 @@ const authStore = useAuthStore();
const route = useRoute(); const route = useRoute();
const canRead = computed(() => authStore.hasPermission(PERMS.template.read)); 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 canDownload = computed(() => authStore.hasPermission(PERMS.template.download));
const canReadAllLogs = computed(() => authStore.hasPermission(PERMS.template.downloadLogReadAll)); 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 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 = [ const scopeTypeOptions = [
{ label: "全部", value: "ALL" }, { label: "全部", value: "ALL" },
@ -358,16 +312,9 @@ const scopeTypeOptions = [
{ label: "会议级", value: "MEETING" }, { label: "会议级", value: "MEETING" },
]; ];
const watermarkFilterOptions = [
{ label: "开启", value: "true" },
{ label: "关闭", value: "false" },
];
const templateQuery = ref({ const templateQuery = ref({
templateName: "", templateName: "",
templateType: "" as TemplateTypeCode | "", templateType: "" as TemplateTypeCode | "",
status: "",
watermarkEnabled: "" as "" | "true" | "false",
}); });
const logQuery = ref({ const logQuery = ref({
@ -376,7 +323,6 @@ const logQuery = ref({
userId: undefined as number | undefined, userId: undefined as number | undefined,
userKeyword: "", userKeyword: "",
versionNo: undefined as number | undefined, versionNo: undefined as number | undefined,
downloadType: "" as "" | "NORMAL" | "WATERMARK",
ip: "", ip: "",
dateRange: [] as string[], dateRange: [] as string[],
}); });
@ -409,7 +355,7 @@ const getTemplateTypeLabel = (code: string) =>
const getScopeTypeLabel = (code: ScopeTypeCode | string) => const getScopeTypeLabel = (code: ScopeTypeCode | string) =>
scopeTypeOptions.find((item) => item.value === code)?.label || code || "-"; 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 statusFormatter = (_row: any, _column: any, value: unknown) => getStatusLabel(value);
const bizSceneFormatter = (_row: any, _column: any, value: unknown) => getBizSceneLabel(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 buildTemplateName = (row: any) => row?.templateName || `模板#${row?.id ?? "-"}`;
const isDownloadBlocked = (row?: any) => ["DISABLED", "ARCHIVED"].includes(String(row?.status || "").trim().toUpperCase()); 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) => { const confirmAction = async (message: string, title: string) => {
try { try {
@ -443,8 +388,7 @@ const confirmAction = async (message: string, title: string) => {
const buildTemplateParams = () => ({ const buildTemplateParams = () => ({
templateName: templateQuery.value.templateName || undefined, templateName: templateQuery.value.templateName || undefined,
templateType: templateQuery.value.templateType || undefined, templateType: templateQuery.value.templateType || undefined,
status: templateQuery.value.status || undefined, status: "PUBLISHED",
watermarkEnabled: templateQuery.value.watermarkEnabled ? templateQuery.value.watermarkEnabled === "true" : undefined,
pageNo: templatePageNo.value, pageNo: templatePageNo.value,
pageSize: templatePageSize.value, pageSize: templatePageSize.value,
}); });
@ -455,7 +399,6 @@ const buildLogParams = () => ({
userId: canReadAllLogs.value ? logQuery.value.userId : undefined, userId: canReadAllLogs.value ? logQuery.value.userId : undefined,
userKeyword: canReadAllLogs.value ? logQuery.value.userKeyword || undefined : undefined, userKeyword: canReadAllLogs.value ? logQuery.value.userKeyword || undefined : undefined,
versionNo: logQuery.value.versionNo, versionNo: logQuery.value.versionNo,
downloadType: logQuery.value.downloadType || undefined,
ip: canReadAllLogs.value ? logQuery.value.ip || undefined : undefined, ip: canReadAllLogs.value ? logQuery.value.ip || undefined : undefined,
downloadedFrom: logQuery.value.dateRange?.[0] || undefined, downloadedFrom: logQuery.value.dateRange?.[0] || undefined,
downloadedTo: logQuery.value.dateRange?.[1] || undefined, downloadedTo: logQuery.value.dateRange?.[1] || undefined,
@ -490,7 +433,7 @@ const loadTemplates = async () => {
templateTotalCount.value = 0; templateTotalCount.value = 0;
return; return;
} }
const resp = await fetchTemplates(buildTemplateParams()); const resp = await fetchTemplateViewList(buildTemplateParams());
templateRows.value = resp?.data?.list || []; templateRows.value = resp?.data?.list || [];
templateTotalCount.value = Number(resp?.data?.total || 0); templateTotalCount.value = Number(resp?.data?.total || 0);
}; };
@ -544,8 +487,6 @@ const resetTemplateQuery = async () => {
templateQuery.value = { templateQuery.value = {
templateName: "", templateName: "",
templateType: "", templateType: "",
status: "",
watermarkEnabled: "",
}; };
templatePageNo.value = 1; templatePageNo.value = 1;
templatePageSize.value = 20; templatePageSize.value = 20;
@ -559,7 +500,6 @@ const resetLogQuery = async () => {
userId: undefined, userId: undefined,
userKeyword: "", userKeyword: "",
versionNo: undefined, versionNo: undefined,
downloadType: "",
ip: "", ip: "",
dateRange: [], dateRange: [],
}; };
@ -594,28 +534,7 @@ const handleDownload = async (row: any) => {
await Promise.all([loadLogs(), refreshDetailData()]); 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 () => { onMounted(async () => {
initQueryFromRoute(); initQueryFromRoute();

View File

@ -39,11 +39,7 @@
<el-option v-for="item in effectiveStatusOptions" :key="item.value" :label="item.label" :value="item.value" /> <el-option v-for="item in effectiveStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select> </el-select>
</el-form-item> </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-form-item>
<el-button v-if="canRead" type="primary" @click="handleSearch">查询</el-button> <el-button v-if="canRead" type="primary" @click="handleSearch">查询</el-button>
</el-form-item> </el-form-item>
@ -122,9 +118,6 @@
class="w-input-md" class="w-input-md"
/> />
</el-form-item> </el-form-item>
<el-form-item label="开启水印下载">
<el-switch v-model="form.watermarkEnabled" />
</el-form-item>
<el-form-item label="下载限流/小时"> <el-form-item label="下载限流/小时">
<el-input-number v-model="form.downloadRateLimitPerHour" :min="1" :max="9999" /> <el-input-number v-model="form.downloadRateLimitPerHour" :min="1" :max="9999" />
</el-form-item> </el-form-item>
@ -194,16 +187,20 @@
{{ formatEffectiveTime(row) }} {{ formatEffectiveTime(row) }}
</template> </template>
</el-table-column> </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 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 }"> <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 <el-button
size="small" size="small"
type="success" type="success"
@ -235,6 +232,68 @@
@current-change="load" @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> <el-drawer v-model="detailVisible" title="模板管理详情" :size="DRAWER_SIZE.lg" destroy-on-close>
<template v-if="detailRow"> <template v-if="detailRow">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
@ -245,7 +304,7 @@
<el-descriptions-item label="适用范围">{{ getScopeTypeLabel(detailRow.scopeType) }}</el-descriptions-item> <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="当前版本">V{{ detailRow.currentVersionNo || 1 }}</el-descriptions-item>
<el-descriptions-item label="生效时间">{{ formatEffectiveTime(detailRow) }}</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.downloadRateLimitPerHour ?? "-" }}</el-descriptions-item>
<el-descriptions-item label="最近更新">{{ detailRow.updatedAt || "-" }}</el-descriptions-item> <el-descriptions-item label="最近更新">{{ detailRow.updatedAt || "-" }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
@ -458,6 +517,8 @@ import {
createTemplate, createTemplate,
disableTemplate, disableTemplate,
disableTemplateTypeOption, disableTemplateTypeOption,
downloadTemplate,
enableTemplateTypeOption, enableTemplateTypeOption,
fetchPublishedTemplateOptions, fetchPublishedTemplateOptions,
fetchTemplateFlowLinks, fetchTemplateFlowLinks,
@ -469,6 +530,7 @@ import {
fetchTemplates, fetchTemplates,
publishTemplate, publishTemplate,
rollbackTemplate, rollbackTemplate,
updateTemplate,
} from "../../api/modules"; } from "../../api/modules";
import { PERMS } from "../../constants/permissions"; import { PERMS } from "../../constants/permissions";
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui"; 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 authStore = useAuthStore();
const canRead = computed(() => authStore.hasPermission(PERMS.template.read)); 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 canCreate = computed(() => authStore.hasPermission(PERMS.template.create));
const canUpdate = computed(() => authStore.hasPermission(PERMS.template.update));
const canPublish = computed(() => authStore.hasPermission(PERMS.template.publish)); const canPublish = computed(() => authStore.hasPermission(PERMS.template.publish));
const canDisable = computed(() => authStore.hasPermission(PERMS.template.disable)); const canDisable = computed(() => authStore.hasPermission(PERMS.template.disable));
const canArchive = computed(() => authStore.hasPermission(PERMS.template.archive)); const canArchive = computed(() => authStore.hasPermission(PERMS.template.archive));
@ -508,6 +572,7 @@ const pageSize = ref(20);
const totalCount = ref(0); const totalCount = ref(0);
const createVisible = ref(false); const createVisible = ref(false);
const editVisible = ref(false);
const detailVisible = ref(false); const detailVisible = ref(false);
const flowBindingVisible = ref(false); const flowBindingVisible = ref(false);
const rollbackDialogVisible = ref(false); const rollbackDialogVisible = ref(false);
@ -549,10 +614,7 @@ const effectiveStatusOptions = [
{ label: "已过期", value: "EXPIRED" }, { label: "已过期", value: "EXPIRED" },
]; ];
const watermarkFilterOptions = [
{ label: "开启", value: "true" },
{ label: "关闭", value: "false" },
];
const listQuery = ref({ const listQuery = ref({
templateName: "", templateName: "",
@ -561,7 +623,20 @@ const listQuery = ref({
scopeType: "" as ScopeTypeCode | "", scopeType: "" as ScopeTypeCode | "",
bizScene: "" as BizSceneCode | "", bizScene: "" as BizSceneCode | "",
effectiveStatus: "", 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({ const form = ref({
@ -575,7 +650,6 @@ const form = ref({
changeLog: "", changeLog: "",
effectiveFrom: "", effectiveFrom: "",
effectiveTo: "", effectiveTo: "",
watermarkEnabled: false,
downloadRateLimitPerHour: 100, downloadRateLimitPerHour: 100,
}); });
@ -666,7 +740,6 @@ const buildListParams = () => ({
scopeType: listQuery.value.scopeType || undefined, scopeType: listQuery.value.scopeType || undefined,
bizScene: listQuery.value.bizScene || undefined, bizScene: listQuery.value.bizScene || undefined,
effectiveStatus: listQuery.value.effectiveStatus || undefined, effectiveStatus: listQuery.value.effectiveStatus || undefined,
watermarkEnabled: listQuery.value.watermarkEnabled ? listQuery.value.watermarkEnabled === "true" : undefined,
pageNo: pageNo.value, pageNo: pageNo.value,
pageSize: pageSize.value, pageSize: pageSize.value,
}); });
@ -717,7 +790,6 @@ const resetCreateForm = () => {
changeLog: "", changeLog: "",
effectiveFrom: "", effectiveFrom: "",
effectiveTo: "", effectiveTo: "",
watermarkEnabled: false,
downloadRateLimitPerHour: 100, downloadRateLimitPerHour: 100,
}; };
}; };
@ -728,6 +800,23 @@ const openCreateDrawer = () => {
createVisible.value = true; 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 () => { const openFlowBindingDrawer = async () => {
if (!canRead.value) { if (!canRead.value) {
return; return;
@ -884,7 +973,6 @@ const handleCreate = async () => {
changeLog: form.value.changeLog, changeLog: form.value.changeLog,
effectiveFrom: form.value.effectiveFrom || undefined, effectiveFrom: form.value.effectiveFrom || undefined,
effectiveTo: form.value.effectiveTo || undefined, effectiveTo: form.value.effectiveTo || undefined,
watermarkEnabled: form.value.watermarkEnabled,
downloadRateLimitPerHour: form.value.downloadRateLimitPerHour, downloadRateLimitPerHour: form.value.downloadRateLimitPerHour,
}); });
ElMessage.success("模板创建成功"); ElMessage.success("模板创建成功");
@ -892,6 +980,45 @@ const handleCreate = async () => {
await load(); 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) => { const enableType = async (typeCode: string) => {
await enableTemplateTypeOption(typeCode); await enableTemplateTypeOption(typeCode);
ElMessage.success(`模板类型 ${typeCode} 已启用`); ElMessage.success(`模板类型 ${typeCode} 已启用`);
@ -1116,7 +1243,6 @@ const resetListQuery = async () => {
scopeType: "", scopeType: "",
bizScene: "", bizScene: "",
effectiveStatus: "", effectiveStatus: "",
watermarkEnabled: "",
}; };
pageNo.value = 1; pageNo.value = 1;
pageSize.value = 20; pageSize.value = 20;

View File

@ -74,24 +74,17 @@
stripe stripe
:empty-text="loading ? '加载中...' : '暂无待审批任务'" :empty-text="loading ? '加载中...' : '暂无待审批任务'"
> >
<el-table-column prop="id" label="任务ID" width="80" /> <el-table-column label="会议信息" min-width="180">
<el-table-column prop="meetingId" label="关联会议ID" width="100" />
<el-table-column prop="moduleCode" label="模块" min-width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag size="small">{{ formatModuleCode(row.moduleCode) }}</el-tag> {{ meetingNameMap[row.meetingId] || `会议#${row.meetingId}` }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="nodeStatus" label="状态" width="100"> <el-table-column prop="node" label="审批节点" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag <el-tag size="small">{{ formatAuditNode(row.node) }}</el-tag>
size="small"
:type="row.nodeStatus === 'PENDING' ? 'warning' : 'info'"
>
{{ row.nodeStatus === 'PENDING' ? '待处理' : row.nodeStatus }}
</el-tag>
</template> </template>
</el-table-column> </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"> <el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
@ -111,10 +104,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted } from "vue"; import { computed, ref, onMounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { fetchAuditTasks, fetchDashboardStats } from "../../api/modules"; import { fetchAuditTasks, fetchDashboardStats, fetchMeetings } from "../../api/modules";
import { PERMS } from "../../constants/permissions"; import { PERMS } from "../../constants/permissions";
import { useAuthStore } from "../../stores/auth"; import { useAuthStore } from "../../stores/auth";
import { toZhModuleCode } from "../../utils/status"; import { toZhAuditNode } from "../../utils/status";
const router = useRouter(); const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore();
@ -127,8 +120,9 @@ const myPendingTasks = ref<any[]>([]);
const pendingAuditCount = ref(0); const pendingAuditCount = ref(0);
const activeMeetingCount = ref(0); const activeMeetingCount = ref(0);
const pendingFinanceCount = 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) => { const goToAudit = (meetingId?: number) => {
if (meetingId) { if (meetingId) {
@ -139,17 +133,19 @@ const goToAudit = (meetingId?: number) => {
}; };
const loadMyTasks = async () => { const loadMyTasks = async () => {
if (!authStore.hasPermission(PERMS.audit.read)) {
return;
}
loading.value = true; loading.value = true;
try { 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 || []; const list = resp?.data?.list || [];
// PENDING // 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 myPendingTasks.value = pendings.slice(0, 10); // Top 10
pendingAuditCount.value = pendings.length; pendingAuditCount.value = pendings.length;
//
if (pendings.length > 0) {
await loadNameMaps();
}
} catch (e) { } catch (e) {
// ignore // ignore
} finally { } 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> </script>
<style scoped> <style scoped>

View File

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

View File

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

View File

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

View File

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

View File

@ -33,7 +33,7 @@ defineEmits<{
<el-descriptions-item label="会议形式">{{ currentMeetingDetail.meetingForm || "-" }}</el-descriptions-item> <el-descriptions-item label="会议形式">{{ currentMeetingDetail.meetingForm || "-" }}</el-descriptions-item>
<el-descriptions-item label="会议地点">{{ currentMeetingDetail.location || "-" }}</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="会议时间">{{ 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.laborRatio) }}</el-descriptions-item>
<el-descriptions-item label="餐费占比">{{ formatPercent(currentMeetingDetail.cateringRatio) }}</el-descriptions-item> <el-descriptions-item label="餐费占比">{{ formatPercent(currentMeetingDetail.cateringRatio) }}</el-descriptions-item>
<el-descriptions-item label="会议状态">{{ toZhStatus(currentMeetingDetail.status) }}</el-descriptions-item> <el-descriptions-item label="会议状态">{{ toZhStatus(currentMeetingDetail.status) }}</el-descriptions-item>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <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 }); const visible = defineModel<boolean>({ required: true });
@ -16,6 +17,90 @@ const dialogTitle = computed(() => {
if (kind.value === "excel") return "Excel 预览"; if (kind.value === "excel") return "Excel 预览";
return "图片预览"; 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> </script>
<template> <template>
@ -23,6 +108,7 @@ const dialogTitle = computed(() => {
v-model="visible" v-model="visible"
:title="dialogTitle" :title="dialogTitle"
width="80%" width="80%"
top="5vh"
append-to-body append-to-body
destroy-on-close destroy-on-close
> >
@ -33,12 +119,28 @@ const dialogTitle = computed(() => {
class="doc-preview-pdf-frame" class="doc-preview-pdf-frame"
/> />
<div v-else-if="kind === 'excel'" class="doc-preview-excel-container" v-html="docPreviewExcelHtml"></div> <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">
<img <div class="image-toolbar">
:src="docPreviewDialogImageUrl" <el-button-group>
alt="preview" <el-button :icon="ZoomIn" @click="handleZoomIn" title="放大" />
class="doc-preview-image" <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> </div>
</el-dialog> </el-dialog>
</template> </template>
@ -46,10 +148,21 @@ const dialogTitle = computed(() => {
<style scoped> <style scoped>
.doc-preview-pdf-frame { .doc-preview-pdf-frame {
width: 100%; width: 100%;
height: 75vh; height: 80vh;
border: none; border: none;
display: block; 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 { .doc-preview-image-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -58,15 +171,20 @@ const dialogTitle = computed(() => {
background: #f8fafc; background: #f8fafc;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
min-height: 60vh;
max-height: 80vh;
} }
.doc-preview-image { .doc-preview-image {
max-width: 100%; max-width: 100%;
max-height: 75vh; max-height: 75vh;
object-fit: contain; object-fit: contain;
transform-origin: center center;
user-select: none;
-webkit-user-drag: none;
} }
.doc-preview-excel-container { .doc-preview-excel-container {
width: 100%; width: 100%;
height: 75vh; height: 80vh;
overflow: auto; overflow: auto;
background: #fff; background: #fff;
border: 1px solid #dcdfe6; border: 1px solid #dcdfe6;

View File

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

View File

@ -12,6 +12,7 @@ const props = defineProps<{
finishedAt?: string; finishedAt?: string;
}>; }>;
canCreate: boolean; canCreate: boolean;
canEdit?: boolean;
canSubmit: boolean; canSubmit: boolean;
canSubmitMeeting: (row: any) => boolean; canSubmitMeeting: (row: any) => boolean;
canWithdraw: boolean; canWithdraw: boolean;
@ -90,12 +91,12 @@ const summaryStatusText = (rowId: number, statusFormatter: (row: any, column: an
</el-table-column> </el-table-column>
<!-- <el-table-column prop="currentAuditNode" label="当前审核节点" width="160" :formatter="auditNodeFormatter" /> <!-- <el-table-column prop="currentAuditNode" label="当前审核节点" width="160" :formatter="auditNodeFormatter" />
<el-table-column prop="currentAuditorUserId" label="当前审核节点" width="160" :formatter="auditorNameFormatter" /> --> <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 }"> <template #default="{ row }">
<div class="action-buttons"> <div class="action-buttons">
<el-button size="small" @click="$emit('openDetail', row)">详情</el-button> <el-button size="small" @click="$emit('openDetail', row)">详情</el-button>
<el-button <el-button
v-if="canCreate" v-if="canEdit"
size="small" size="small"
type="primary" type="primary"
:disabled="isMeetingAuditApproved(row)" :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 v-if="canMaterialExport" divided @click="$emit('generateSummary', row.id)">
生成总结 生成总结
</el-dropdown-item> </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> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>

View File

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

View File

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

View File

@ -106,9 +106,9 @@
<el-form-item v-if="projectForm.allowProjectOverBudget" label="超支阈值"> <el-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-input-number v-model="projectForm.overBudgetThresholdRatio" :min="0" :max="1" :step="0.01" class="project-number-input" />
</el-form-item> </el-form-item>
<el-form-item label="发票信息"> <!-- <el-form-item label="发票信息">
<el-input v-model="projectForm.invoiceInfo" /> <el-input v-model="projectForm.invoiceInfo" />
</el-form-item> </el-form-item> -->
<el-form-item label="主办单位负责人"> <el-form-item label="主办单位负责人">
<el-input :model-value="projectForm.hostOwnerUsers || '系统自动取TENANT_ADMIN'" disabled /> <el-input :model-value="projectForm.hostOwnerUsers || '系统自动取TENANT_ADMIN'" disabled />
</el-form-item> </el-form-item>

View File

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