From 48c64176e5d046d1c1a421911568eb037e4e6835 Mon Sep 17 00:00:00 2001 From: En Date: Mon, 23 Mar 2026 14:13:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B6=85=E7=AE=A1=E7=AB=AF=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E7=A7=9F=E6=88=B7=E9=87=8D=E7=BD=AE=E5=AF=86=E7=A0=81?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端实现: - TenantService 添加 resetPasswordAndReturnTemp 方法 - TenantServiceImpl 实现重置密码逻辑,生成 8 位随机临时密码 - AdminTenantController 完善 resetTenantPassword 接口,返回临时密码 前端实现: - TenantListView 添加重置密码模态框组件 - 采用与教师端一致的 UI 样式 - 使用超管端 Indigo 紫色主题色 (#6366F1) - 支持密码一键复制功能 API 端点: - POST /api/v1/admin/tenants/{id}/reset-password Co-Authored-By: Claude Opus 4.6 --- reading-platform-frontend/src/api/admin.ts | 1 + .../views/admin/tenants/TenantListView.vue | 196 ++++++++++++++++-- .../admin/AdminTenantController.java | 6 +- .../platform/service/TenantService.java | 5 + .../service/impl/TenantServiceImpl.java | 106 ++++++++-- 5 files changed, 281 insertions(+), 33 deletions(-) diff --git a/reading-platform-frontend/src/api/admin.ts b/reading-platform-frontend/src/api/admin.ts index 7b444ed..20a0ac6 100644 --- a/reading-platform-frontend/src/api/admin.ts +++ b/reading-platform-frontend/src/api/admin.ts @@ -91,6 +91,7 @@ export interface UpdateTenantDto { startDate?: string; expireDate?: string; status?: string; + forceRemove?: boolean; } export interface UpdateTenantQuotaDto { diff --git a/reading-platform-frontend/src/views/admin/tenants/TenantListView.vue b/reading-platform-frontend/src/views/admin/tenants/TenantListView.vue index dddcde0..b61fecf 100644 --- a/reading-platform-frontend/src/views/admin/tenants/TenantListView.vue +++ b/reading-platform-frontend/src/views/admin/tenants/TenantListView.vue @@ -102,7 +102,6 @@ 调整配额 重置密码 - 暂停服务 @@ -207,6 +206,38 @@ + + +
+ + 以下套餐下有排课计划,移除后将无法继续为该套餐下的课程排课: +
+
+
+
{{ item.collectionName }}
+
+ 排课数量:{{ item.scheduleCount }} 个 +
+
+
+
+ 已存在的排课计划不受影响,但将无法新增该套餐下课程包的排课 +
+
+ + + + + +
+
+ +

确定要重置 {{ currentTenant?.name }} 的密码吗?

+
+
+

新密码:

+
+ {{ newPassword }} +
+
+
+
@@ -284,6 +337,9 @@ import { PlusOutlined, SearchOutlined, DownOutlined, + ExclamationCircleOutlined, + KeyOutlined, + WarningOutlined, } from '@ant-design/icons-vue'; import { message, Modal } from 'ant-design-vue'; import type { TableProps, FormInstance } from 'ant-design-vue'; @@ -356,6 +412,10 @@ const modalLoading = ref(false); const isEdit = ref(false); const formRef = ref(); const editingId = ref(null); +// 强制移除确认相关 +const forceRemoveModalVisible = ref(false); +const forceRemoveWarnings = ref>([]); +const pendingFormData = ref(null); const formData = reactive({ name: '', @@ -425,6 +485,11 @@ const detailData = ref(null); // 套餐列表 const packageList = ref([]); +// 重置密码相关 +const resetPasswordVisible = ref(false); +const resetting = ref(false); +const newPassword = ref(''); + // 禁用过去的日期(有效期不能选今天之前的日期) const disabledPastDate = (current: dayjs.Dayjs) => { return current && current < dayjs().startOf('day'); @@ -560,6 +625,25 @@ const handleModalOk = async () => { modalVisible.value = false; loadData(); } catch (error: any) { + // 处理错误码 3102 - 套餐下有排课计划 + if (error.response?.data?.code === 3102) { + const warnings = error.response.data.data as Array<{ + collectionId: number; + collectionName: string; + scheduleCount: number; + }>; + // 保存当前表单数据 + pendingFormData.value = { + ...formData, + startDate, + expireDate, + }; + // 显示强制移除确认弹窗 + forceRemoveWarnings.value = warnings; + forceRemoveModalVisible.value = true; + modalLoading.value = false; + return; + } message.error(error.response?.data?.message || '操作失败'); } finally { modalLoading.value = false; @@ -572,6 +656,35 @@ const handleModalCancel = () => { formRef.value?.resetFields(); }; +// 强制移除确认弹窗 - 确认 +const handleForceRemoveConfirm = async () => { + if (!pendingFormData.value || !editingId.value) { + return; + } + + modalLoading.value = true; + try { + // 传递 forceRemove: true 重新调用更新接口 + await updateTenant(editingId.value, { ...pendingFormData.value, forceRemove: true } as UpdateTenantDto); + message.success('更新成功'); + modalVisible.value = false; + forceRemoveModalVisible.value = false; + pendingFormData.value = null; + loadData(); + } catch (error: any) { + message.error(error.response?.data?.message || '操作失败'); + } finally { + modalLoading.value = false; + } +}; + +// 强制移除确认弹窗 - 取消 +const handleForceRemoveCancel = () => { + forceRemoveModalVisible.value = false; + pendingFormData.value = null; + forceRemoveWarnings.value = []; +}; + // 查看详情 const handleViewDetail = async (record: Tenant) => { drawerVisible.value = true; @@ -616,23 +729,25 @@ const handleQuotaOk = async () => { // 重置密码 const handleResetPassword = (record: Tenant) => { - Modal.confirm({ - title: '确认重置密码', - content: `确定要重置 "${record.name}" 的密码吗?`, - async onOk() { - try { - const res = await resetTenantPassword(record.id); - Modal.success({ - title: '密码已重置', - content: `新密码: ${res.tempPassword}`, - }); - } catch (error: any) { - message.error(error.response?.data?.message || '重置失败'); - } - }, - }); + currentTenant.value = record; + newPassword.value = ''; + resetPasswordVisible.value = true; }; +const confirmResetPassword = async () => { + if (!currentTenant.value) return; + + resetting.value = true; + try { + const result = await resetTenantPassword(currentTenant.value.id); + newPassword.value = result.tempPassword; + message.success('密码重置成功'); + } catch (error: any) { + message.error(error.response?.data?.message || '重置密码失败'); + } finally { + resetting.value = false; + } +}; // 更新状态 const handleUpdateStatus = (record: Tenant, status: string) => { const statusText = status === 'ACTIVE' ? '恢复' : '暂停'; @@ -717,4 +832,53 @@ onMounted(() => { padding: 24px; border-radius: 4px; } + +/* 重置密码弹窗样式 */ +.reset-password-content { + text-align: center; +} + +.reset-warning { + padding: 20px; + background: #EEF2FF; + border-radius: 12px; + margin-bottom: 20px; +} + +.warning-icon { + font-size: 32px; + color: #6366F1; + display: block; + margin-bottom: 8px; +} + +.reset-warning p { + margin: 0; + color: #636E72; +} + +.new-password-box p { + margin-bottom: 8px; + color: #636E72; +} + +.password-display { + padding: 16px; + background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%); + border-radius: 12px; + font-size: 24px; + font-weight: bold; + color: white; +} + +/* Modal title styling */ +.modal-title { + display: flex; + align-items: center; + gap: 8px; +} + +.modal-title-icon { + color: #6366F1; +} diff --git a/reading-platform-java/src/main/java/com/reading/platform/controller/admin/AdminTenantController.java b/reading-platform-java/src/main/java/com/reading/platform/controller/admin/AdminTenantController.java index 9c16c0a..646a200 100644 --- a/reading-platform-java/src/main/java/com/reading/platform/controller/admin/AdminTenantController.java +++ b/reading-platform-java/src/main/java/com/reading/platform/controller/admin/AdminTenantController.java @@ -122,9 +122,9 @@ public class AdminTenantController { @Operation(summary = "重置租户密码") @PostMapping("/{id}/reset-password") - public Result resetTenantPassword(@PathVariable Long id) { - // TODO: 实现重置租户密码逻辑 - return Result.success(); + public Result> resetTenantPassword(@PathVariable Long id) { + String tempPassword = tenantService.resetPasswordAndReturnTemp(id); + return Result.success(Map.of("tempPassword", tempPassword)); } /** diff --git a/reading-platform-java/src/main/java/com/reading/platform/service/TenantService.java b/reading-platform-java/src/main/java/com/reading/platform/service/TenantService.java index b9a44a7..b3007aa 100644 --- a/reading-platform-java/src/main/java/com/reading/platform/service/TenantService.java +++ b/reading-platform-java/src/main/java/com/reading/platform/service/TenantService.java @@ -53,4 +53,9 @@ public interface TenantService extends com.baomidou.mybatisplus.extension.servic */ void updateTenantStatus(Long id, String status); + /** + * 重置租户密码并返回临时密码 + */ + String resetPasswordAndReturnTemp(Long id); + } diff --git a/reading-platform-java/src/main/java/com/reading/platform/service/impl/TenantServiceImpl.java b/reading-platform-java/src/main/java/com/reading/platform/service/impl/TenantServiceImpl.java index 7f7969d..a899db9 100644 --- a/reading-platform-java/src/main/java/com/reading/platform/service/impl/TenantServiceImpl.java +++ b/reading-platform-java/src/main/java/com/reading/platform/service/impl/TenantServiceImpl.java @@ -14,10 +14,14 @@ import com.reading.platform.entity.Student; import com.reading.platform.entity.Teacher; import com.reading.platform.entity.Tenant; import com.reading.platform.entity.TenantPackage; +import com.reading.platform.entity.CourseCollectionPackage; +import com.reading.platform.entity.SchedulePlan; import com.reading.platform.mapper.StudentMapper; import com.reading.platform.mapper.TeacherMapper; import com.reading.platform.mapper.TenantMapper; import com.reading.platform.mapper.TenantPackageMapper; +import com.reading.platform.mapper.SchedulePlanMapper; +import com.reading.platform.mapper.CourseCollectionPackageMapper; import com.reading.platform.mapper.CourseCollectionMapper; import com.reading.platform.entity.CourseCollection; import com.reading.platform.service.CoursePackageService; @@ -30,9 +34,13 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; /** @@ -51,6 +59,8 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic private final TeacherMapper teacherMapper; private final StudentMapper studentMapper; private final PasswordEncoder passwordEncoder; + private final SchedulePlanMapper schedulePlanMapper; + private final CourseCollectionPackageMapper collectionPackageMapper; @Override @Transactional @@ -174,25 +184,81 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic if (request.getCollectionIds() != null) { LocalDate endDate = request.getExpireDate() != null ? request.getExpireDate() : tenant.getExpireDate(); - // 1. 删除不在新列表中的关联记录 - tenantPackageMapper.delete( - new LambdaQueryWrapper() - .eq(TenantPackage::getTenantId, id) - .notIn(TenantPackage::getCollectionId, request.getCollectionIds()) - ); - - // 2. 获取现有的关联集合 ID + // 1. 获取现有的关联集合 ID(ACTIVE 状态) List existingPackages = tenantPackageMapper.selectList( new LambdaQueryWrapper() .eq(TenantPackage::getTenantId, id) + .eq(TenantPackage::getStatus, TenantPackageStatus.ACTIVE) ); Set existingCollectionIds = existingPackages.stream() .map(TenantPackage::getCollectionId) .collect(Collectors.toSet()); - // 3. 创建新的关联记录 + // 2. 计算被移除的套餐 ID(在 existingCollectionIds 中但不在 request.getCollectionIds() 中) + Set removedCollectionIds = existingCollectionIds.stream() + .filter(collectionId -> !request.getCollectionIds().contains(collectionId)) + .collect(Collectors.toSet()); + + // 3. 检查被移除的套餐下是否有排课计划(用于提示用户) + if (!removedCollectionIds.isEmpty() && (request.getForceRemove() == null || !request.getForceRemove())) { + Map warnings = new HashMap<>(); + for (Long collectionId : removedCollectionIds) { + // 查询该套餐下所有课程包 + List packageIds = collectionPackageMapper.selectList( + new LambdaQueryWrapper() + .eq(CourseCollectionPackage::getCollectionId, collectionId) + ).stream().map(CourseCollectionPackage::getPackageId).collect(Collectors.toList()); + + if (!packageIds.isEmpty()) { + // 查询该套餐下课程包的排课数量 + long count = schedulePlanMapper.selectCount( + new LambdaQueryWrapper() + .eq(SchedulePlan::getTenantId, id) + .in(SchedulePlan::getCoursePackageId, packageIds) + ); + if (count > 0) { + // 获取套餐名称 + CourseCollection collection = collectionMapper.selectById(collectionId); + Map packageWarning = new HashMap<>(); + packageWarning.put("collectionId", collectionId); + packageWarning.put("collectionName", collection != null ? collection.getName() : "未知套餐"); + packageWarning.put("scheduleCount", count); + warnings.put("package_" + collectionId, packageWarning); + } + } + } + + // 如果有警告信息,抛出异常让前端显示确认弹窗 + if (!warnings.isEmpty()) { + List> warningList = new ArrayList<>(); + for (Object warning : warnings.values()) { + warningList.add((Map) warning); + } + throw new BusinessException(ErrorCode.REMOVE_PACKAGE_HAS_SCHEDULES, + "该套餐下有排课计划,请确认是否强制移除", warningList); + } + } + + // 4. 将被移除的套餐关联状态改为 EXPIRED(不物理删除) + for (Long collectionId : removedCollectionIds) { + TenantPackage tenantPackage = existingPackages.stream() + .filter(tp -> tp.getCollectionId().equals(collectionId)) + .findFirst() + .orElse(null); + if (tenantPackage != null) { + tenantPackage.setStatus(TenantPackageStatus.EXPIRED); + tenantPackage.setUpdatedAt(LocalDateTime.now()); + tenantPackageMapper.updateById(tenantPackage); + log.info("租户套餐关联已过期,tenantId={}, collectionId={}", id, collectionId); + } + } + + // 5. 获取更新后的关联集合 ID(用于创建新关联) + Set currentCollectionIds = existingCollectionIds; + + // 6. 创建新的关联记录 for (Long collectionId : request.getCollectionIds()) { - if (!existingCollectionIds.contains(collectionId)) { + if (!currentCollectionIds.contains(collectionId)) { CourseCollection collection = collectionMapper.selectById(collectionId); if (collection == null) { log.warn("课程套餐不存在,collectionId: {}", collectionId); @@ -202,21 +268,21 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic TenantPackage tp = new TenantPackage(); tp.setTenantId(id); tp.setCollectionId(collectionId); - tp.setStartDate(java.time.LocalDate.now()); + tp.setStartDate(LocalDate.now()); tp.setEndDate(endDate); tp.setStatus(TenantPackageStatus.ACTIVE); tp.setPricePaid(collection.getDiscountPrice() != null ? collection.getDiscountPrice() : collection.getPrice()); - tp.setCreatedAt(java.time.LocalDateTime.now()); + tp.setCreatedAt(LocalDateTime.now()); tenantPackageMapper.insert(tp); log.info("创建租户课程套餐关联,tenantId={}, collectionId={}", id, collectionId); } } - // 4. 更新现有关联的结束日期 + // 7. 更新现有关联的结束日期 for (TenantPackage tp : existingPackages) { if (request.getCollectionIds().contains(tp.getCollectionId())) { tp.setEndDate(endDate); - tp.setUpdatedAt(java.time.LocalDateTime.now()); + tp.setUpdatedAt(LocalDateTime.now()); tenantPackageMapper.updateById(tp); } } @@ -404,4 +470,16 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic .build(); } + @Override + @Transactional + public String resetPasswordAndReturnTemp(Long id) { + log.info("开始重置租户密码并返回临时密码,ID: {}", id); + Tenant tenant = getTenantById(id); + String tempPassword = UUID.randomUUID().toString().replace("-", "").substring(0, 8); + tenant.setPassword(passwordEncoder.encode(tempPassword)); + baseMapper.updateById(tenant); + log.info("租户密码重置成功,ID: {}", id); + return tempPassword; + } + }