增加租户模式

This commit is contained in:
李志强 2026-02-25 21:42:26 +08:00
parent 9669766eb3
commit 4cbb93b19a
9 changed files with 988 additions and 2 deletions

3
auto-imports.d.ts vendored
View File

@ -6,5 +6,6 @@
// biome-ignore lint: disable // biome-ignore lint: disable
export {} export {}
declare global { declare global {
const ElMessage: typeof import('element-plus/es').ElMessage
const ElMessageBox: typeof import('element-plus/es').ElMessageBox
} }

1
components.d.ts vendored
View File

@ -47,7 +47,6 @@ declare module 'vue' {
ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination'] ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress'] ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton']

84
src/api/tenant.js Normal file
View File

@ -0,0 +1,84 @@
import request from "@/utils/request";
/*************************************************
****************** 租户相关接口 ******************
*************************************************/
/**
* 获取租户列表
* @param {Object} params 包含 page pageSize
* @returns {Promise}
*/
export function getTenantList(params) {
return request({
url: "/admin/tenant/getTenant",
method: "get",
params: params,
});
}
/**
* 获取租户详情
* @param {number} id 租户ID
* @returns {Promise}
*/
export function getTenantDetail(id) {
return request({
url: `/admin/tenant/getTenantDetail/${id}`,
method: "get",
});
}
/**
* 创建租户数据
* @param {Object} data 租户数据
* @returns {Promise}
*/
export function createTenant(data) {
return request({
url: "/admin/tenant/createTenant",
method: "post",
data: data,
headers: {
"Content-Type": "multipart/form-data",
},
});
}
/**
* 更新租户数据
* @param {Object} data 租户数据
* @returns {Promise}
*/
export function editTenant(id, data) {
return request({
url: `/admin/tenant/editTenant/${id}`,
method: "post",
data: data,
});
}
/**
* 删除租户数据
* @param {number} id 租户ID
* @returns {Promise}
*/
export function deleteTenant(id) {
return request({
url: `/admin/tenant/deleteTenant/${id}`,
method: "delete",
});
}
/**
* 校验租户编码是否重复
* @param {string} tenant_code 编码
* @param {number} id 可选当前编辑的租户ID
*/
export function checkTenantCode(tenant_code) {
return request({
url: '/admin/tenant/findTenantCode',
method: 'get',
params: { tenant_code }
});
}

View File

@ -0,0 +1,409 @@
<template>
<el-drawer v-model="visible" :title="dialogTitle" width="500px">
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<div class="form-title">账号信息</div>
<!-- 账号 -->
<el-form-item label="账号">
<el-input v-model="form.account" :disabled="!isAdd" placeholder="请输入账号" />
</el-form-item>
<!-- 密码 -->
<el-form-item label="密码" prop="password" v-if="isAdd">
<el-input
v-model="form.password"
type="password"
autocomplete="new-password"
show-password
:placeholder="isAdd ? '请输入密码至少6位' : '留空则不修改密码'"
/>
</el-form-item>
<!-- 确认密码 -->
<el-form-item label="确认密码" prop="confirmPassword" v-if="isAdd">
<el-input
v-model="form.confirmPassword"
type="password"
autocomplete="new-password"
show-password
:placeholder="isAdd ? '请再次输入密码' : '留空则不修改密码'"
/>
</el-form-item>
<el-divider></el-divider>
<div class="form-title">个人信息</div>
<!-- 姓名 -->
<el-form-item label="姓名">
<el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item>
<!-- 电话 -->
<el-form-item label="电话">
<el-input v-model="form.phone" placeholder="请输入电话" />
</el-form-item>
<!-- 邮箱 -->
<el-form-item label="邮箱">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<!-- QQ -->
<el-form-item label="QQ">
<el-input v-model="form.qq" placeholder="请输入QQ" />
</el-form-item>
<!-- 性别 -->
<el-form-item label="性别">
<el-radio-group v-model="form.sex" placeholder="请选择性别">
<el-radio-button label="男" :value="1" />
<el-radio-button label="女" :value="2" />
</el-radio-group>
</el-form-item>
<!-- 状态 -->
<el-form-item label="状态">
<el-select
v-model="form.status"
placeholder="请选择状态"
style="width: 100%"
>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
</el-form>
<!-- 对话框脚部 -->
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit">保存</el-button>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { ElMessage } from "element-plus";
import { addUser, editUser, getUserInfo } from "@/api/user";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
isEdit: {
type: Boolean,
default: false,
},
statusDict: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["update:modelValue", "submit", "close"]);
const visible = ref(false);
const formRef = ref<any>(null);
const isAdd = ref(false);
const form = ref<any>({
id: null,
account: "",
name: "",
phone: "",
qq: "",
sex: 1,
password: "",
confirmPassword: "",
email: "",
status: 1,
});
const dialogTitle = computed(() => {
return isAdd.value ? "添加用户" : "编辑用户";
});
//
const validatePassword = (rule: any, value: any, callback: any) => {
if (isAdd.value) {
//
if (!value) {
callback(new Error("请输入密码"));
return;
}
if (value.length < 6) {
callback(new Error("密码长度不能少于6位"));
return;
}
} else {
//
if (value && value.length < 6) {
callback(new Error("密码长度不能少于6位"));
return;
}
}
callback();
};
//
const validateConfirmPassword = (rule: any, value: any, callback: any) => {
if (isAdd.value) {
//
if (!value) {
callback(new Error("请再次输入密码"));
return;
}
if (value !== form.value.password) {
callback(new Error("两次输入的密码不一致"));
return;
}
} else {
//
if (form.value.password && value !== form.value.password) {
callback(new Error("两次输入的密码不一致"));
return;
}
//
if (value && !form.value.password) {
callback(new Error("请先输入密码"));
return;
}
}
callback();
};
//
const rules = {
account: [
{ required: true, message: "请输入账号", trigger: "blur" },
{ min: 3, max: 20, message: "账号长度在 3 到 20 个字符", trigger: "blur" },
],
name: [{ required: true, message: "请输入姓名", trigger: "blur" }],
password: [{ validator: validatePassword, trigger: "blur" }],
confirmPassword: [{ validator: validateConfirmPassword, trigger: "blur" }],
email: [{ type: "email", message: "请输入正确的邮箱地址", trigger: "blur" }],
};
// modelValue
watch(
() => props.modelValue,
(newVal) => {
visible.value = newVal;
}
);
// visible
watch(visible, (newVal) => {
if (!newVal) {
emit("update:modelValue", false);
}
});
// statusDict
watch(
() => props.statusDict,
(newVal) => {},
{ immediate: true, deep: true }
);
const loadUserData = async (user: any) => {
try {
// ID
const userId = typeof user === "number" ? user : user?.id || user?.userId;
if (!userId) {
throw new Error("未提供有效的用户 ID");
}
const res = await getUserInfo(userId);
const data = res.data || res;
// sex status
const sexValue =
data.sex !== undefined && data.sex !== null
? Number(data.sex)
: 1;
const statusValue =
data.status !== undefined && data.status !== null
? Number(data.status)
: 1;
form.value = {
id: data.id,
account: data.account,
name: data.name,
phone: data.phone,
qq: data.qq,
sex: sexValue,
password: "",
confirmPassword: "",
email: data.email,
status: statusValue,
};
} catch (e: any) {
console.error("Failed to load user data:", e);
const errorMsg = e?.response?.data?.message || e?.message || "加载用户失败";
ElMessage.error(errorMsg);
throw e;
}
};
const handleCancel = () => {
visible.value = false;
};
const handleSubmit = async () => {
//
if (!formRef.value) {
return;
}
try {
await formRef.value.validate();
} catch (error) {
ElMessage.warning("请检查表单填写是否正确");
return;
}
//
if (isAdd.value) {
//
if (!form.value.password) {
ElMessage.error("请输入密码");
return;
}
if (form.value.password !== form.value.confirmPassword) {
ElMessage.error("两次输入的密码不一致");
return;
}
} else {
//
if (form.value.password) {
if (form.value.password !== form.value.confirmPassword) {
ElMessage.error("两次输入的密码不一致");
return;
}
}
}
try {
if (isAdd.value) {
//
const submitData: any = {
account: form.value.account,
name: form.value.name,
phone: form.value.phone,
qq: form.value.qq,
sex: form.value.sex,
email: form.value.email,
status: form.value.status,
password: form.value.password,
};
await addUser(submitData);
ElMessage.success("添加成功");
} else {
//
if (!form.value.id || form.value.id === 0) {
ElMessage.error("用户ID不能为空");
return;
}
const submitData: any = {
id: form.value.id,
account: form.value.account,
name: form.value.name,
phone: form.value.phone,
qq: form.value.qq,
sex: form.value.sex,
email: form.value.email,
status: form.value.status,
};
//
if (form.value.password) {
submitData.password = form.value.password;
}
await editUser(form.value.id, submitData);
ElMessage.success("更新成功");
}
visible.value = false;
emit("submit");
} catch (e: any) {
const errorMsg = e?.response?.data?.message || e?.message || "操作失败";
ElMessage.error(errorMsg);
}
};
//
defineExpose({
loadUserData,
openAdd: () => {
isAdd.value = true;
form.value = {
id: 0,
account: "",
name: "",
phone: "",
qq: "",
sex: 1,
password: "",
confirmPassword: "",
email: "",
status: 1,
};
visible.value = true;
//
if (formRef.value) {
formRef.value.clearValidate();
}
},
openEdit: (user: any) => {
isAdd.value = false;
visible.value = true;
//
if (formRef.value) {
formRef.value.clearValidate();
}
//
loadUserData(user);
},
open: (user?: any) => {
if (user) {
isAdd.value = false;
loadUserData(user);
} else {
isAdd.value = true;
form.value = {
id: 0,
account: "",
name: "",
phone: "",
qq: "",
sex: 1,
password: "",
confirmPassword: "",
email: "",
status: 1,
};
}
visible.value = true;
//
if (formRef.value) {
formRef.value.clearValidate();
}
},
});
</script>
<style lang="less" scoped>
.form-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 20px;
}
</style>

View File

@ -0,0 +1,94 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>用户管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleAddUser">
<el-icon>
<Plus />
</el-icon>
添加用户
</el-button>
<el-button @click="refresh">
<el-icon>
<Refresh />
</el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 用户列表表格 -->
<el-table :data="users" style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" align="center" fixed="left" />
<el-table-column prop="account" label="账号" align="center" />
<el-table-column prop="name" label="姓名" align="center">
<template #default="scope">
<span class="name-link" @click="handlePreview(scope.row)">{{ scope.row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="qq" label="隶属单位" align="center" />
<el-table-column prop="phone" label="部门" align="center" />
<el-table-column prop="phone" label="职位" align="center" />
<el-table-column prop="status" label="账号状态" width="80" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">{{
scope.row.status === 1 ? "启用" : "禁用"
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="240" align="center" fixed="right">
<template #default="scope">
<el-button size="small" @click="handlePreview(scope.row)">查看</el-button>
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button size="small" type="warning" @click="handleChangePassword(scope.row)">
修改密码
</el-button>
<el-button v-if="scope.row.username !== 'admin' && scope.row.id !== 1" size="small" type="danger"
@click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-bar">
<el-pagination :current-page="page" :page-size="pageSize" :total="total" @current-change="handlePageChange"
layout="total, prev, pager, next" />
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style lang="less" scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 1.4rem;
font-weight: 700;
}
}
:deep(.el-alert__title) {
color: #f56c6c !important;
}
.name-link {
color: #3973ff;
cursor: pointer;
text-decoration: none;
transition: color 0.3s;
&:hover {
color: #66b1ff;
text-decoration: underline;
}
}
</style>

View File

@ -0,0 +1,194 @@
<template>
<el-dialog v-model="visible" :title="formData.id ? '编辑租户' : '添加租户'" width="600px" @closed="handleClosed"
destroy-on-close>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px" v-loading="loading"
style="padding: 20px">
<el-form-item label="租户名称" prop="tenant_name">
<el-input v-model="formData.tenant_name" placeholder="请输入租户名称" />
</el-form-item>
<el-form-item label="租户编码" prop="tenant_code">
<el-input v-model="formData.tenant_code" placeholder="系统自动生成" disabled />
<div class="form-tip" v-if="!formData.id" style="font-size: 12px; color: #999;">
* 编码由系统随机分配提交时将自动校验唯一性
</div>
</el-form-item>
<el-form-item label="联系人" prop="contact_person">
<el-input v-model="formData.contact_person" placeholder="请输入联系人" />
</el-form-item>
<el-form-item label="联系电话" prop="contact_phone">
<el-input v-model="formData.contact_phone" placeholder="请输入联系电话" />
</el-form-item>
<el-form-item label="电子邮箱" prop="contact_email">
<el-input v-model="formData.contact_email" placeholder="请输入电子邮箱" />
</el-form-item>
<el-form-item label="租户地址" prop="address">
<el-input v-model="formData.address" type="textarea" placeholder="请输入地址" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitForm">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { createTenant, editTenant, getTenantDetail, checkTenantCode } from '@/api/tenant';
const emit = defineEmits(['success']);
const visible = ref(false);
const loading = ref(false);
const submitting = ref(false);
const formRef = ref();
const initialData = {
id: null,
tenant_name: '',
tenant_code: '',
contact_person: '',
contact_phone: '',
contact_email: '',
address: '',
status: 1
};
const formData = reactive({ ...initialData });
const rules = {
tenant_name: [{ required: true, message: '请输入租户名称', trigger: 'blur' }],
tenant_code: [{ required: true, message: '请输入租户编码', trigger: 'blur' }],
contact_phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
]
};
//
const open = (id?: number) => {
visible.value = true;
Object.assign(formData, initialData);
if (id) {
formData.id = id;
fetchDetail(id);
} else {
formData.tenant_code = Math.floor(100000 + Math.random() * 900000).toString();
}
};
const fetchDetail = async (id: number) => {
loading.value = true;
try {
const res = await getTenantDetail(id);
if (res.code === 200) {
Object.assign(formData, res.data);
}
} finally {
loading.value = false;
}
};
const submitForm = async () => {
if (!formRef.value) return;
// 1.
await formRef.value.validate();
submitting.value = true;
try {
// 2.
if (!formData.id) {
let isCodeValid = false;
while (!isCodeValid) {
const res = await checkTenantCode(formData.tenant_code);
if (res.code === 200) {
//
isCodeValid = true;
} else {
//
const newCode = Math.floor(100000 + Math.random() * 900000).toString();
//
await ElMessageBox.alert(
`租户编码 [${formData.tenant_code}] 已重复,系统已自动为您重新生成为 [${newCode}],请重新点击提交。`,
'编码重复提示',
{ confirmButtonText: '我知道了', type: 'warning' }
);
formData.tenant_code = newCode;
submitting.value = false;
return; //
}
}
}
// 3.
const saveApi = formData.id ? editTenant(formData.id, formData) : createTenant(formData);
const saveRes = await saveApi;
if (saveRes.code === 200) {
ElMessage.success('保存成功');
visible.value = false;
emit('success');
}
} catch (error) {
console.error('提交失败', error);
} finally {
submitting.value = false;
}
};
/**
* 生成 6 位随机数字并校验唯一性
*/
const generateUniqueCode = async () => {
loading.value = true;
let isUnique = false;
let newCode = '';
let retryCount = 0;
const maxRetries = 10; // 10
while (!isUnique && retryCount < maxRetries) {
// 1. 6
newCode = Math.floor(100000 + Math.random() * 900000).toString();
try {
// 2.
const res = await checkTenantCode(newCode);
if (res.code === 200) {
isUnique = true; // 200
} else {
console.warn(`编码 ${newCode} 重复,正在重试...`);
retryCount++;
}
} catch (error) {
console.error("校验编码失败", error);
break;
}
}
if (isUnique) {
formData.tenant_code = newCode;
} else {
ElMessage.error('无法生成唯一的租户编码,请重试');
}
loading.value = false;
};
const handleClosed = () => {
formRef.value?.resetFields();
};
defineExpose({ open });
</script>

View File

@ -0,0 +1,205 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>租户管理</h2>
<div class="header-actions">
<el-button type="primary" @click="editRef.open()">
<el-icon>
<Plus />
</el-icon>
添加租户
</el-button>
<el-button @click="refresh">
<el-icon>
<Refresh />
</el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="租户名称">
<el-input v-model="searchForm.tenant_name" placeholder="请输入租户名称" clearable
@keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="租户编码">
<el-input v-model="searchForm.tenant_code" placeholder="请输入租户编码" clearable
@keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="searchForm.contact_person" placeholder="请输入联系人" clearable
@keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="searchForm.contact_phone" placeholder="请输入联系电话" clearable
@keyup.enter="handleSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
<!-- 租户列表表格 -->
<el-table :data="tenants" style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" align="center" fixed="left" />
<el-table-column prop="tenant_name" label="租户名称" min-width="220" align="center" />
<el-table-column prop="tenant_code" label="租户编码" min-width="120" align="center" />
<el-table-column prop="contact_person" label="联系人" min-width="120" align="center" />
<el-table-column prop="contact_phone" label="联系电话" min-width="120" align="center" />
<el-table-column prop="contact_email" label="电子邮箱" min-width="180" align="center" />
<el-table-column prop="address" label="租户地址" min-width="300" align="center" />
<el-table-column prop="status" label="租户状态" width="80" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">{{
scope.row.status === 1 ? "启用" : "禁用"
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="240" align="center" fixed="right">
<template #default="scope">
<el-button size="small" @click="handlePreview(scope.row)">查看</el-button>
<el-button size="small" @click="editRef.open(scope.row.id)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<EditModal ref="editRef" @success="refresh" />
<!-- 分页 -->
<div class="pagination-bar">
<el-pagination :current-page="page" :page-size="pageSize" :total="total" @current-change="handlePageChange"
layout="total, prev, pager, next" />
</div>
</div>
</template>
<script setup lang="ts">
import { getTenantList, deleteTenant } from '@/api/tenant';
import { onMounted, ref, reactive } from 'vue';
import { useRouter } from 'vue-router';
import EditModal from './components/edit.vue';
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const loading = ref(false);
const router = useRouter();
const tenants = ref([]);
const editRef = ref();
//
const handleDelete = (row) => {
ElMessageBox.confirm('确定要删除该租户吗?', '提示', {
type: 'warning'
}).then(() => {
deleteTenant(row.id).then(res => {
if (res.code === 200) {
ElMessage.success('删除成功');
refresh();
}
});
}).catch(() => { });
};
//
const handlePreview = (row) => {
router.push(`/basicSettings/tenants/detail/${row.id}`);
};
//
const handlePageChange = (val: number) => {
page.value = val;
refresh();
};
//
const searchForm = reactive({
tenant_name: '',
tenant_code: '',
contact_person: '',
contact_phone: ''
});
//
const handleSearch = () => {
page.value = 1;
refresh();
};
//
const resetSearch = () => {
searchForm.tenant_name = '';
searchForm.tenant_code = '';
searchForm.contact_person = '';
searchForm.contact_phone = '';
handleSearch();
};
//
const refresh = () => {
loading.value = true;
//
const queryParams = {
page: page.value,
pageSize: pageSize.value,
...searchForm
};
getTenantList(queryParams).then(res => {
if (res.code === 200) {
tenants.value = res.data.list;
total.value = res.data.total;
}
}).finally(() => {
loading.value = false;
});
};
//
onMounted(() => {
refresh();
});
</script>
<style lang="less" scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 1.4rem;
font-weight: 700;
}
}
:deep(.el-alert__title) {
color: #f56c6c !important;
}
.name-link {
color: #3973ff;
cursor: pointer;
text-decoration: none;
transition: color 0.3s;
&:hover {
color: #66b1ff;
text-decoration: underline;
}
}
.search-form {
background: #f9f9f9;
padding: 20px 20px 0 20px;
border-radius: 4px;
margin-bottom: 20px;
}
</style>