Compare commits

...

2 Commits

Author SHA1 Message Date
En
fb4d63ec99 Merge remote-tracking branch 'origin/master' 2026-03-23 14:13:39 +08:00
En
48c64176e5 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>
2026-03-23 14:13:11 +08:00
5 changed files with 281 additions and 33 deletions

View File

@ -91,6 +91,7 @@ export interface UpdateTenantDto {
startDate?: string;
expireDate?: string;
status?: string;
forceRemove?: boolean;
}
export interface UpdateTenantQuotaDto {

View File

@ -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>

View File

@ -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));
}
/**

View File

@ -53,4 +53,9 @@ public interface TenantService extends com.baomidou.mybatisplus.extension.servic
*/
void updateTenantStatus(Long id, String status);
/**
* 重置租户密码并返回临时密码
*/
String resetPasswordAndReturnTemp(Long id);
}

View File

@ -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. 获取现有的关联集合 IDACTIVE 状态
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;
}
}