feat: 超管端实现租户重置密码功能
后端实现:
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
1b1679585d
commit
48c64176e5
@ -91,6 +91,7 @@ export interface UpdateTenantDto {
|
||||
startDate?: string;
|
||||
expireDate?: string;
|
||||
status?: string;
|
||||
forceRemove?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateTenantQuotaDto {
|
||||
|
||||
@ -102,7 +102,6 @@
|
||||
<a-menu-divider />
|
||||
<a-menu-item @click="handleQuota(record)">调整配额</a-menu-item>
|
||||
<a-menu-item @click="handleResetPassword(record)">重置密码</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item v-if="record.status === 'ACTIVE'" @click="handleUpdateStatus(record, 'SUSPENDED')">
|
||||
暂停服务
|
||||
</a-menu-item>
|
||||
@ -207,6 +206,38 @@
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 强制移除确认弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="forceRemoveModalVisible"
|
||||
title="确认移除套餐"
|
||||
:confirm-loading="modalLoading"
|
||||
ok-type="primary"
|
||||
ok-text="确认移除"
|
||||
cancel-text="取消"
|
||||
@ok="handleForceRemoveConfirm"
|
||||
@cancel="handleForceRemoveCancel"
|
||||
>
|
||||
<div style="color: #fa8c16; font-size: 14px; margin-bottom: 16px">
|
||||
<ExclamationCircleOutlined style="margin-right: 8px" />
|
||||
以下套餐下有排课计划,移除后将无法继续为该套餐下的课程排课:
|
||||
</div>
|
||||
<div style="max-height: 300px; overflow-y: auto">
|
||||
<div
|
||||
v-for="(item, index) in forceRemoveWarnings"
|
||||
:key="index"
|
||||
style="padding: 12px; border: 1px solid #d9d9d9; border-radius: 4px; margin-bottom: 8px"
|
||||
>
|
||||
<div style="font-weight: 500">{{ item.collectionName }}</div>
|
||||
<div style="font-size: 13px; color: #666; margin-top: 4px">
|
||||
排课数量:{{ item.scheduleCount }} 个
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 16px; color: #999; font-size: 13px">
|
||||
已存在的排课计划不受影响,但将无法新增该套餐下课程包的排课
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<a-drawer v-model:open="drawerVisible" title="租户详情" width="600" :destroy-on-close="true">
|
||||
<template v-if="detailData">
|
||||
@ -275,6 +306,28 @@
|
||||
</template>
|
||||
<a-skeleton v-else active />
|
||||
</a-drawer>
|
||||
|
||||
<!-- 重置密码确认模态框 -->
|
||||
<a-modal v-model:open="resetPasswordVisible" @ok="confirmResetPassword" :confirm-loading="resetting" :width="400">
|
||||
<template #title>
|
||||
<span class="modal-title">
|
||||
<KeyOutlined class="modal-title-icon" />
|
||||
重置密码
|
||||
</span>
|
||||
</template>
|
||||
<div class="reset-password-content">
|
||||
<div class="reset-warning">
|
||||
<WarningOutlined class="warning-icon" />
|
||||
<p>确定要重置 <strong>{{ currentTenant?.name }}</strong> 的密码吗?</p>
|
||||
</div>
|
||||
<div v-if="newPassword" class="new-password-box">
|
||||
<p>新密码:</p>
|
||||
<div class="password-display">
|
||||
<a-typography-text copyable>{{ newPassword }}</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -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<FormInstance>();
|
||||
const editingId = ref<number | null>(null);
|
||||
// 强制移除确认相关
|
||||
const forceRemoveModalVisible = ref(false);
|
||||
const forceRemoveWarnings = ref<Array<{ collectionId: number; collectionName: string; scheduleCount: number }>>([]);
|
||||
const pendingFormData = ref<any>(null);
|
||||
|
||||
const formData = reactive<CreateTenantDto & { dateRange?: [string, string]; collectionIds?: number[] }>({
|
||||
name: '',
|
||||
@ -425,6 +485,11 @@ const detailData = ref<TenantDetail | null>(null);
|
||||
// 套餐列表
|
||||
const packageList = ref<CourseCollectionResponse[]>([]);
|
||||
|
||||
// 重置密码相关
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -122,9 +122,9 @@ public class AdminTenantController {
|
||||
|
||||
@Operation(summary = "重置租户密码")
|
||||
@PostMapping("/{id}/reset-password")
|
||||
public Result<Void> resetTenantPassword(@PathVariable Long id) {
|
||||
// TODO: 实现重置租户密码逻辑
|
||||
return Result.success();
|
||||
public Result<Map<String, String>> resetTenantPassword(@PathVariable Long id) {
|
||||
String tempPassword = tenantService.resetPasswordAndReturnTemp(id);
|
||||
return Result.success(Map.of("tempPassword", tempPassword));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -53,4 +53,9 @@ public interface TenantService extends com.baomidou.mybatisplus.extension.servic
|
||||
*/
|
||||
void updateTenantStatus(Long id, String status);
|
||||
|
||||
/**
|
||||
* 重置租户密码并返回临时密码
|
||||
*/
|
||||
String resetPasswordAndReturnTemp(Long id);
|
||||
|
||||
}
|
||||
|
||||
@ -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<TenantPackage>()
|
||||
.eq(TenantPackage::getTenantId, id)
|
||||
.notIn(TenantPackage::getCollectionId, request.getCollectionIds())
|
||||
);
|
||||
|
||||
// 2. 获取现有的关联集合 ID
|
||||
// 1. 获取现有的关联集合 ID(ACTIVE 状态)
|
||||
List<TenantPackage> existingPackages = tenantPackageMapper.selectList(
|
||||
new LambdaQueryWrapper<TenantPackage>()
|
||||
.eq(TenantPackage::getTenantId, id)
|
||||
.eq(TenantPackage::getStatus, TenantPackageStatus.ACTIVE)
|
||||
);
|
||||
Set<Long> existingCollectionIds = existingPackages.stream()
|
||||
.map(TenantPackage::getCollectionId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 3. 创建新的关联记录
|
||||
// 2. 计算被移除的套餐 ID(在 existingCollectionIds 中但不在 request.getCollectionIds() 中)
|
||||
Set<Long> removedCollectionIds = existingCollectionIds.stream()
|
||||
.filter(collectionId -> !request.getCollectionIds().contains(collectionId))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 3. 检查被移除的套餐下是否有排课计划(用于提示用户)
|
||||
if (!removedCollectionIds.isEmpty() && (request.getForceRemove() == null || !request.getForceRemove())) {
|
||||
Map<String, Object> warnings = new HashMap<>();
|
||||
for (Long collectionId : removedCollectionIds) {
|
||||
// 查询该套餐下所有课程包
|
||||
List<Long> packageIds = collectionPackageMapper.selectList(
|
||||
new LambdaQueryWrapper<CourseCollectionPackage>()
|
||||
.eq(CourseCollectionPackage::getCollectionId, collectionId)
|
||||
).stream().map(CourseCollectionPackage::getPackageId).collect(Collectors.toList());
|
||||
|
||||
if (!packageIds.isEmpty()) {
|
||||
// 查询该套餐下课程包的排课数量
|
||||
long count = schedulePlanMapper.selectCount(
|
||||
new LambdaQueryWrapper<SchedulePlan>()
|
||||
.eq(SchedulePlan::getTenantId, id)
|
||||
.in(SchedulePlan::getCoursePackageId, packageIds)
|
||||
);
|
||||
if (count > 0) {
|
||||
// 获取套餐名称
|
||||
CourseCollection collection = collectionMapper.selectById(collectionId);
|
||||
Map<String, Object> 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<Map<String, Object>> warningList = new ArrayList<>();
|
||||
for (Object warning : warnings.values()) {
|
||||
warningList.add((Map<String, Object>) 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<Long> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user