fix: 租户管理表单校验与后端字段对齐

- 前端: 学习地址必填、有效期绑定 formData.dateRange 修复校验
- 前端: admin.ts 请求体字段映射 loginAccount->code, contactPerson->contactName
- 后端: TenantCreateRequest 新增 password 字段
- 后端: TenantServiceImpl 创建租户时设置 username(code) 和 password

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-18 15:15:48 +08:00
parent 279fa79b56
commit db70b1ad47
4 changed files with 105 additions and 145 deletions

View File

@ -229,11 +229,24 @@ export const getTenants = (params: TenantQueryParams) =>
export const getTenant = (id: number) =>
http.get<TenantDetail>(`/v1/admin/tenants/${id}`);
export const createTenant = (data: CreateTenantDto) =>
http.post<Tenant & { tempPassword: string }>('/v1/admin/tenants', data);
export const createTenant = (data: CreateTenantDto) => {
// 映射前端字段到后端 APIloginAccount -> code, contactPerson -> contactName
const payload = {
...data,
code: data.loginAccount,
contactName: data.contactPerson,
};
delete (payload as Record<string, unknown>).loginAccount;
delete (payload as Record<string, unknown>).contactPerson;
return http.post<Tenant & { tempPassword: string }>('/v1/admin/tenants', payload);
};
export const updateTenant = (id: number, data: UpdateTenantDto) =>
http.put<Tenant>(`/v1/admin/tenants/${id}`, data);
export const updateTenant = (id: number, data: UpdateTenantDto) => {
// 映射前端字段到后端 APIcontactPerson -> contactName
const payload = { ...data, contactName: data.contactPerson };
delete (payload as Record<string, unknown>).contactPerson;
return http.put<Tenant>(`/v1/admin/tenants/${id}`, payload);
};
export const updateTenantQuota = (id: number, data: UpdateTenantQuotaDto) =>
http.put<Tenant>(`/v1/admin/tenants/${id}/quota`, data);

View File

@ -5,33 +5,18 @@
<!-- 搜索表单 -->
<a-form layout="inline" :model="searchForm" style="margin-bottom: 16px">
<a-form-item label="关键词">
<a-input
v-model:value="searchForm.keyword"
placeholder="学校名称/账号/联系人"
allow-clear
style="width: 200px"
@pressEnter="handleSearch"
/>
<a-input v-model:value="searchForm.keyword" placeholder="学校名称/账号/联系人" allow-clear style="width: 200px"
@pressEnter="handleSearch" />
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="searchForm.status"
placeholder="全部状态"
allow-clear
style="width: 120px"
>
<a-select v-model:value="searchForm.status" placeholder="全部状态" allow-clear style="width: 120px">
<a-select-option value="ACTIVE">生效中</a-select-option>
<a-select-option value="EXPIRED">已过期</a-select-option>
<a-select-option value="SUSPENDED">已暂停</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="套餐">
<a-select
v-model:value="searchForm.packageType"
placeholder="全部套餐"
allow-clear
style="width: 120px"
>
<a-select v-model:value="searchForm.packageType" placeholder="全部套餐" allow-clear style="width: 120px">
<a-select-option value="BASIC">基础版</a-select-option>
<a-select-option value="STANDARD">标准版</a-select-option>
<a-select-option value="ADVANCED">高级版</a-select-option>
@ -41,7 +26,9 @@
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
<template #icon>
<SearchOutlined />
</template>
搜索
</a-button>
<a-button @click="handleReset">重置</a-button>
@ -49,20 +36,17 @@
</a-form-item>
<a-form-item style="float: right">
<a-button type="primary" @click="showAddModal">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
添加租户
</a-button>
</a-form-item>
</a-form>
<!-- 数据表格 -->
<a-table
:columns="columns"
:data-source="tenants"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
>
<a-table :columns="columns" :data-source="tenants" :loading="loading" :pagination="pagination"
@change="handleTableChange">
<template #bodyCell="{ column, record }: any">
<template v-if="column.key === 'name'">
<a-button type="link" @click="handleViewDetail(record)">
@ -87,16 +71,14 @@
</div>
</template>
<template v-else-if="column.key === 'status'">
<a-badge
:status="getStatusType(record.status)"
:text="getStatusText(record.status)"
/>
<a-badge :status="getStatusType(record.status)" :text="getStatusText(record.status)" />
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-dropdown>
<a-button type="link" size="small">
操作 <DownOutlined />
操作
<DownOutlined />
</a-button>
<template #overlay>
<a-menu>
@ -106,16 +88,10 @@
<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 v-if="record.status === 'ACTIVE'" @click="handleUpdateStatus(record, 'SUSPENDED')">
暂停服务
</a-menu-item>
<a-menu-item
v-if="record.status === 'SUSPENDED'"
@click="handleUpdateStatus(record, 'ACTIVE')"
>
<a-menu-item v-if="record.status === 'SUSPENDED'" @click="handleUpdateStatus(record, 'ACTIVE')">
恢复服务
</a-menu-item>
<a-menu-item danger @click="handleDelete(record)">删除</a-menu-item>
@ -129,36 +105,17 @@
</a-card>
<!-- 添加/编辑租户弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="isEdit ? '编辑租户' : '添加租户'"
:confirm-loading="modalLoading"
width="600px"
@ok="handleModalOk"
@cancel="handleModalCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-modal v-model:open="modalVisible" :title="isEdit ? '编辑租户' : '添加租户'" :confirm-loading="modalLoading" width="600px"
@ok="handleModalOk" @cancel="handleModalCancel">
<a-form ref="formRef" :model="formData" :rules="formRules" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="学校名称" name="name">
<a-input v-model:value="formData.name" placeholder="请输入学校名称" />
</a-form-item>
<a-form-item v-if="!isEdit" label="登录账号" name="loginAccount">
<a-input
v-model:value="formData.loginAccount"
placeholder="字母开头4-20位"
:disabled="isEdit"
/>
<a-input v-model:value="formData.loginAccount" placeholder="字母开头4-20位" :disabled="isEdit" />
</a-form-item>
<a-form-item v-if="!isEdit" label="初始密码" name="password">
<a-input-password
v-model:value="formData.password"
placeholder="留空则默认为 123456"
/>
<a-input-password v-model:value="formData.password" placeholder="留空则默认为 123456" />
</a-form-item>
<a-form-item label="联系人" name="contactPerson">
<a-input v-model:value="formData.contactPerson" placeholder="请输入联系人姓名" />
@ -166,54 +123,32 @@
<a-form-item label="联系电话" name="contactPhone">
<a-input v-model:value="formData.contactPhone" placeholder="请输入联系电话" />
</a-form-item>
<a-form-item label="学地址" name="address">
<a-input v-model:value="formData.address" placeholder="请输入学地址" />
<a-form-item label="学地址" name="address">
<a-input v-model:value="formData.address" placeholder="请输入学地址" />
</a-form-item>
<a-form-item label="套餐类型" name="packageType">
<a-select v-model:value="formData.packageType" @change="handlePackageTypeChange">
<a-select-option value="">请选择套餐</a-select-option>
<a-select-option
v-for="pkg in packageList"
:key="pkg.id"
:value="pkg.name"
>
<a-select-option v-for="pkg in packageList" :key="pkg.id" :value="pkg.name">
{{ pkg.name }} ({{ formatPackagePrice(pkg.discountPrice || pkg.price) }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="教师配额" name="teacherQuota">
<a-input-number
v-model:value="formData.teacherQuota"
:min="1"
:max="1000"
style="width: 100%"
/>
<a-input-number v-model:value="formData.teacherQuota" :min="1" :max="1000" style="width: 100%" />
</a-form-item>
<a-form-item label="学生配额" name="studentQuota">
<a-input-number
v-model:value="formData.studentQuota"
:min="1"
:max="10000"
style="width: 100%"
/>
<a-input-number v-model:value="formData.studentQuota" :min="1" :max="10000" style="width: 100%" />
</a-form-item>
<a-form-item label="有效期" name="dateRange">
<a-range-picker
v-model:value="dateRange"
style="width: 100%"
value-format="YYYY-MM-DD"
/>
<a-range-picker v-model:value="formData.dateRange" style="width: 100%" value-format="YYYY-MM-DD"
:disabled-date="disabledPastDate" />
</a-form-item>
</a-form>
</a-modal>
<!-- 配额调整弹窗 -->
<a-modal
v-model:open="quotaModalVisible"
title="调整配额"
:confirm-loading="quotaModalLoading"
@ok="handleQuotaOk"
>
<a-modal v-model:open="quotaModalVisible" title="调整配额" :confirm-loading="quotaModalLoading" @ok="handleQuotaOk">
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="当前租户">
<span>{{ currentTenant?.name }}</span>
@ -227,23 +162,15 @@
</a-select>
</a-form-item>
<a-form-item label="教师配额">
<a-input-number
v-model:value="quotaForm.teacherQuota"
:min="currentTenant?.teacherCount || 1"
:max="1000"
style="width: 100%"
/>
<a-input-number v-model:value="quotaForm.teacherQuota" :min="currentTenant?.teacherCount || 1" :max="1000"
style="width: 100%" />
<div style="color: #999; font-size: 12px">
已使用: {{ currentTenant?.teacherCount || 0 }}
</div>
</a-form-item>
<a-form-item label="学生配额">
<a-input-number
v-model:value="quotaForm.studentQuota"
:min="currentTenant?.studentCount || 1"
:max="10000"
style="width: 100%"
/>
<a-input-number v-model:value="quotaForm.studentQuota" :min="currentTenant?.studentCount || 1" :max="10000"
style="width: 100%" />
<div style="color: #999; font-size: 12px">
已使用: {{ currentTenant?.studentCount || 0 }}
</div>
@ -252,12 +179,7 @@
</a-modal>
<!-- 详情抽屉 -->
<a-drawer
v-model:open="drawerVisible"
title="租户详情"
width="600"
:destroy-on-close="true"
>
<a-drawer v-model:open="drawerVisible" title="租户详情" width="600" :destroy-on-close="true">
<template v-if="detailData">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="学校名称" :span="2">
@ -267,10 +189,7 @@
{{ detailData.loginAccount }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-badge
:status="getStatusType(detailData.status)"
:text="getStatusText(detailData.status)"
/>
<a-badge :status="getStatusType(detailData.status)" :text="getStatusText(detailData.status)" />
</a-descriptions-item>
<a-descriptions-item label="联系人">
{{ detailData.contactPerson || '-' }}
@ -278,7 +197,7 @@
<a-descriptions-item label="联系电话">
{{ detailData.contactPhone || '-' }}
</a-descriptions-item>
<a-descriptions-item label="学地址" :span="2">
<a-descriptions-item label="学地址" :span="2">
{{ detailData.address || '-' }}
</a-descriptions-item>
<a-descriptions-item label="套餐类型">
@ -309,23 +228,13 @@
</a-row>
<a-divider>最近教师</a-divider>
<a-table
v-if="detailData.teachers && detailData.teachers.length > 0"
:columns="teacherColumns"
:data-source="detailData.teachers"
:pagination="false"
size="small"
/>
<a-table v-if="detailData.teachers && detailData.teachers.length > 0" :columns="teacherColumns"
:data-source="detailData.teachers" :pagination="false" size="small" />
<a-empty v-else description="暂无教师数据" />
<a-divider>最近学生</a-divider>
<a-table
v-if="detailData.students && detailData.students.length > 0"
:columns="studentColumns"
:data-source="detailData.students"
:pagination="false"
size="small"
/>
<a-table v-if="detailData.students && detailData.students.length > 0" :columns="studentColumns"
:data-source="detailData.students" :pagination="false" size="small" />
<a-empty v-else description="暂无学生数据" />
</template>
<a-skeleton v-else active />
@ -411,9 +320,8 @@ const modalLoading = ref(false);
const isEdit = ref(false);
const formRef = ref<FormInstance>();
const editingId = ref<number | null>(null);
const dateRange = ref<[string, string] | undefined>(undefined);
const formData = reactive<CreateTenantDto & { dateRange?: string[] }>({
const formData = reactive<CreateTenantDto & { dateRange?: [string, string] }>({
name: '',
loginAccount: '',
password: '',
@ -426,6 +334,7 @@ const formData = reactive<CreateTenantDto & { dateRange?: string[] }>({
studentQuota: 200,
startDate: '',
expireDate: '',
dateRange: undefined,
});
const formRules = {
@ -434,7 +343,30 @@ const formRules = {
{ required: true, message: '请输入登录账号' },
{ pattern: /^[a-zA-Z][a-zA-Z0-9_]{3,19}$/, message: '字母开头4-20位字母数字或下划线' },
],
contactPhone: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' }],
contactPerson: [{ required: true, message: '请输入联系人' }],
contactPhone: [
{ required: true, message: '请输入联系电话' },
{
validator: (_: unknown, value: string) => {
if (!value) return Promise.resolve();
return /^1[3-9]\d{9}$/.test(value) ? Promise.resolve() : Promise.reject(new Error('请输入正确的手机号'));
},
trigger: 'blur',
},
],
address: [{ required: true, message: '请输入学习地址' }],
packageType: [{ required: true, message: '请选择套餐' }],
teacherQuota: [
{ required: true, message: '请输入教师配额' },
{ type: 'number' as const, min: 1, max: 1000, message: '教师配额需为 1-1000 的整数' },
],
studentQuota: [
{ required: true, message: '请输入学生配额' },
{ type: 'number' as const, min: 1, max: 10000, message: '学生配额需为 1-10000 的整数' },
],
dateRange: [
{ required: true, message: '请选择有效期', type: 'array' as const, trigger: 'change' },
],
};
//
@ -454,6 +386,11 @@ const detailData = ref<TenantDetail | null>(null);
//
const packageList = ref<CoursePackage[]>([]);
//
const disabledPastDate = (current: dayjs.Dayjs) => {
return current && current < dayjs().startOf('day');
};
//
const formatPackagePrice = (priceInCents: number) => {
return (priceInCents / 100).toFixed(2);
@ -547,11 +484,11 @@ const showAddModal = () => {
packageId: undefined,
teacherQuota: 20,
studentQuota: 200,
});
dateRange.value = [
dateRange: [
dayjs().format('YYYY-MM-DD'),
dayjs().add(1, 'year').format('YYYY-MM-DD'),
] as [string, string];
] as [string, string],
});
modalVisible.value = true;
};
@ -567,8 +504,8 @@ const handleEdit = (record: Tenant) => {
packageType: record.packageType,
teacherQuota: record.teacherQuota,
studentQuota: record.studentQuota,
dateRange: [record.startDate, record.expireDate] as [string, string],
});
dateRange.value = [record.startDate, record.expireDate] as [string, string];
modalVisible.value = true;
};
@ -582,7 +519,7 @@ const handleModalOk = async () => {
modalLoading.value = true;
try {
const [startDate, expireDate] = dateRange.value || [];
const [startDate, expireDate] = formData.dateRange || [];
const data = {
...formData,
startDate,
@ -594,7 +531,7 @@ const handleModalOk = async () => {
message.success('更新成功');
} else {
const res = await createTenant(data as CreateTenantDto);
message.success(`创建成功,初始密码: ${res.tempPassword}`);
message.success(`创建成功,初始密码: ${res.tempPassword || '123456'}`);
}
modalVisible.value = false;
loadData();

View File

@ -19,6 +19,9 @@ public class TenantCreateRequest {
@Schema(description = "租户编码/登录账号")
private String code;
@Schema(description = "初始密码(留空默认 123456")
private String password;
@Schema(description = "联系人")
private String contactName;

View File

@ -23,6 +23,7 @@ import com.reading.platform.service.CoursePackageService;
import com.reading.platform.service.TenantService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@ -46,6 +47,7 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
private final CourseCollectionMapper collectionMapper;
private final TeacherMapper teacherMapper;
private final StudentMapper studentMapper;
private final PasswordEncoder passwordEncoder;
@Override
@Transactional
@ -84,6 +86,11 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
tenant.setStatus("ACTIVE");
// 设置登录账号和密码username code 一致用于登录
tenant.setUsername(request.getCode());
String rawPassword = StringUtils.hasText(request.getPassword()) ? request.getPassword() : "123456";
tenant.setPassword(passwordEncoder.encode(rawPassword));
// 如果传入了 collectionId查询课程套餐信息并填充相关字段
if (request.getCollectionId() != null) {
CourseCollection collection = collectionMapper.selectById(request.getCollectionId());