feat: 新增客户管理全模块功能

实现客户信息、客户部门、客户账号三个子模块的完整CRUD,包含:
1. 数据库实体、DTO、控制器、服务层代码
2. RBAC权限映射与菜单种子数据扩展
3. 前端路由与API封装
4. 数据权限隔离与鉴权逻辑
5. 配套工具脚本与测试验证清单
This commit is contained in:
andyesfly committed 2026-08-23 19:15:54 +08:00
1 parent 026ec34471
commit 1c80a24d1d
31 files changed
+26321 -653

No files matched your search

@@ -0,0 +1,401 @@
<template>
<div class="customer-department">
<a-card title="部门管理">
<template #extra>
<a-space>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增部门
</a-button>
</a-space>
</template>
<div class="search-bar">
<a-input
v-model:value="searchForm.keyword"
placeholder="部门名称/联系人/电话"
allow-clear
style="width: 220px"
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-select
v-model:value="searchForm.customerId"
placeholder="所属客户"
allow-clear
style="width: 220px"
:options="customerOptions"
/>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'customerId'">
{{ customerNameMap[record.customerId] || '-' }}
</template>
<template v-if="column.key === 'createdAt'">
{{ formatDateTime(record.createdAt) }}
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-popconfirm title="确定要删除该部门吗?" ok-text="确定" cancel-text="取消" @confirm="handleDelete(record.id)">
<a-button type="link" size="small" danger>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
<template #emptyText>
<a-empty description="暂无部门数据" />
</template>
</a-table>
</div>
</a-card>
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="600px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="所属客户" name="customerId">
<a-select
v-model:value="formData.customerId"
placeholder="请选择所属客户"
:options="customerOptions"
/>
</a-form-item>
<a-form-item label="部门名称" name="deptName">
<a-input v-model:value="formData.deptName" placeholder="请输入部门名称" maxlength="64" />
</a-form-item>
<a-form-item label="联系人" name="contactPerson">
<a-input v-model:value="formData.contactPerson" placeholder="请输入联系人" maxlength="32" />
</a-form-item>
<a-form-item label="联系电话" name="contactPhone">
<a-input v-model:value="formData.contactPhone" placeholder="请输入联系电话" />
</a-form-item>
<a-form-item label="职能描述" name="functionDesc">
<a-textarea v-model:value="formData.functionDesc" placeholder="请输入职能描述" :rows="3" maxlength="256" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
DeleteOutlined,
EditOutlined,
} from '@ant-design/icons-vue';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { customerDepartmentApi, type CustomerDepartmentInfo, type DepartmentQuery } from '@/api/customerDepartment';
import { useTableScroll } from '@/composables/useTableScroll';
import dayjs from 'dayjs';
const { wrapperRef, tableHeight } = useTableScroll();
const loading = ref(false);
const dataSource = ref<CustomerDepartmentInfo[]>([]);
const total = ref(0);
const searchForm = reactive<DepartmentQuery>({
page: 1,
pageSize: 10,
keyword: '',
customerId: undefined as any,
});
const modalVisible = ref(false);
const modalTitle = ref('');
const modalLoading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const customerOptions = ref<{ label: string; value: number }[]>([]);
const customerNameMap = reactive<Record<number, string>>({});
const formData = reactive({
customerId: undefined as number | undefined,
deptName: '',
contactPerson: '',
contactPhone: '',
functionDesc: '',
});
const formRules = {
customerId: [{ required: true, message: '请选择所属客户', trigger: 'change' }],
deptName: [{ required: true, message: '请输入部门名称', trigger: 'blur' }],
contactPhone: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
};
const columns = [
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
{ title: '部门名称', dataIndex: 'deptName', key: 'deptName', ellipsis: true },
{ title: '联系人', dataIndex: 'contactPerson', key: 'contactPerson', ellipsis: true, width: 120 },
{ title: '联系电话', dataIndex: 'contactPhone', key: 'contactPhone', ellipsis: true, width: 140 },
{ title: '职能描述', dataIndex: 'functionDesc', key: 'functionDesc', ellipsis: true },
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', ellipsis: true, width: 180 },
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
onMounted(() => {
fetchList();
fetchCustomerOptions();
});
async function fetchList() {
loading.value = true;
try {
const res: any = await customerDepartmentApi.getDepartmentList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
message.error('获取部门列表失败');
} finally {
loading.value = false;
}
}
async function fetchCustomerOptions() {
try {
const res: any = await customerApi.getCustomerOptions();
if (res.code === 200) {
const list: CustomerInfo[] = res.data;
customerOptions.value = list.map((item) => ({
label: `${item.customerNo} ${item.customerName}`,
value: item.id,
}));
list.forEach((item) => {
customerNameMap[item.id] = `${item.customerNo} ${item.customerName}`;
});
}
} catch (error) {
console.error('获取客户选项失败:', error);
}
}
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.customerId = undefined as any;
searchForm.page = 1;
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
function handleAdd() {
isEdit.value = false;
editId.value = null;
modalTitle.value = '新增部门';
resetForm();
modalVisible.value = true;
}
function handleEdit(record: CustomerDepartmentInfo) {
isEdit.value = true;
editId.value = record.id;
modalTitle.value = '编辑部门';
resetForm();
formData.customerId = record.customerId;
formData.deptName = record.deptName;
formData.contactPerson = record.contactPerson || '';
formData.contactPhone = record.contactPhone || '';
formData.functionDesc = record.functionDesc || '';
modalVisible.value = true;
}
function resetForm() {
formData.customerId = undefined;
formData.deptName = '';
formData.contactPerson = '';
formData.contactPhone = '';
formData.functionDesc = '';
if (formRef.value) {
formRef.value.resetFields();
}
}
async function handleSubmit() {
try {
await formRef.value.validate();
} catch {
return;
}
modalLoading.value = true;
try {
let res: any;
if (isEdit.value && editId.value) {
res = await customerDepartmentApi.updateDepartment(editId.value, {
customerId: formData.customerId,
deptName: formData.deptName,
contactPerson: formData.contactPerson,
contactPhone: formData.contactPhone,
functionDesc: formData.functionDesc,
});
} else {
res = await customerDepartmentApi.createDepartment({
customerId: formData.customerId as number,
deptName: formData.deptName,
contactPerson: formData.contactPerson,
contactPhone: formData.contactPhone,
functionDesc: formData.functionDesc,
});
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
modalLoading.value = false;
}
}
function handleCancel() {
modalVisible.value = false;
resetForm();
}
async function handleDelete(id: number) {
try {
const res: any = await customerDepartmentApi.deleteDepartment(id);
if (res.code === 200) {
message.success('删除成功');
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
function formatDateTime(dateStr: string) {
if (!dateStr) return '-';
return dayjs(dateStr).format('YYYY-MM-DD HH:mm:ss');
}
</script>
<style scoped>
.customer-department {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px;
box-sizing: border-box;
}
:deep(.ant-card) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
:deep(.ant-card-body) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.search-bar {
flex-shrink: 0;
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
:deep(.ant-table) {
font-size: 14px;
height: 100%;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
</style>