feat: 新增客户拜访、建筑、房间及项目全流程业务模块

本次提交新增了客户拜访、客户建筑、客户房间的完整业务模块,同时补充了项目勘察、项目方案、合同管理、项目验收、回访记录的全项目流程相关功能,包括:
1.  新增对应业务的实体类、DTO、控制器、服务层代码
2.  完善RBAC权限映射配置
3.  添加前端API、路由及页面菜单配置
4.  更新数据库种子数据补充菜单和权限绑定
This commit is contained in:
andyesfly committed 2026-08-26 14:08:08 +08:00
1 parent f61ade107b
commit 219091f864
53 files changed
+5990 -6

No files matched your search

+477
View File
@@ -0,0 +1,477 @@
<template>
<div class="page-management">
<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.status" placeholder="状态" allow-clear style="width: 130px">
<a-select-option :value="0">草稿</a-select-option>
<a-select-option :value="1">已确认</a-select-option>
</a-select>
<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 === 'customerName'">
{{ getCustomerName(record.customerId) }}
</template>
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'default'">
{{ record.status === 1 ? '已确认' : '草稿' }}
</a-tag>
</template>
<template v-if="column.key === 'registerTime'">
{{ record.registerTime || '-' }}
</template>
<template v-if="column.key === 'details'">
<a-badge :count="(record.details || []).length" :number-style="{ backgroundColor: '#1677ff' }" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleView(record)">
<template #icon><SearchOutlined /></template>
明细
</a-button>
<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="820px"
@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"
show-search
option-filter-prop="label"
/>
</a-form-item>
<a-form-item label="登记人" name="registrar">
<a-input v-model:value="formData.registrar" placeholder="请输入登记人" maxlength="64" />
</a-form-item>
<a-form-item label="登记时间" name="registerTime">
<a-date-picker v-model:value="formData.registerTime" style="width: 100%" placeholder="请选择登记时间" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="0">草稿</a-radio>
<a-radio :value="1">已确认</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="方案说明" name="planDesc">
<a-textarea v-model:value="formData.planDesc" :rows="2" placeholder="请输入方案说明" maxlength="2000" />
</a-form-item>
</a-form>
<div class="detail-block">
<div class="detail-header">
<span>方案明细</span>
<a-button type="dashed" size="small" @click="addDetailRow">
<template #icon><PlusOutlined /></template>
添加明细
</a-button>
</div>
<a-table
:columns="detailColumns"
:data-source="formData.details"
row-key="id"
:pagination="false"
size="small"
:scroll="{ y: 200 }"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'equipName'">
<a-input v-model:value="record.equipName" placeholder="设备名称" maxlength="128" />
</template>
<template v-if="column.key === 'quantity'">
<a-input-number v-model:value="record.quantity" :min="0" style="width: 100%" placeholder="数量" />
</template>
<template v-if="column.key === 'remark'">
<a-input v-model:value="record.remark" placeholder="备注" maxlength="2000" />
</template>
<template v-if="column.key === 'action'">
<a-button type="link" size="small" danger @click="removeDetailRow(index)">删除</a-button>
</template>
</template>
</a-table>
</div>
</a-modal>
<a-modal v-model:open="viewVisible" title="方案明细" width="700px" :footer="null" @cancel="viewVisible = false">
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="方案单号">{{ viewData.planNo }}</a-descriptions-item>
<a-descriptions-item label="所属客户">{{ getCustomerName(viewData.customerId) }}</a-descriptions-item>
<a-descriptions-item label="登记人">{{ viewData.registrar || '-' }}</a-descriptions-item>
<a-descriptions-item label="登记时间">{{ viewData.registerTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="方案说明">{{ viewData.planDesc || '-' }}</a-descriptions-item>
</a-descriptions>
<a-table
:columns="viewDetailColumns"
:data-source="viewData.details || []"
row-key="id"
:pagination="false"
size="small"
style="margin-top: 16px"
/>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { SearchOutlined, ReloadOutlined, PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import dayjs from 'dayjs';
import { projectPlanApi, type ProjectPlanInfo, type ProjectPlanQuery, type PlanDetail } from '@/api/projectPlan';
import { customerApi } from '@/api/customer';
import { useTableScroll } from '@/composables/useTableScroll';
const { wrapperRef, tableHeight } = useTableScroll();
const loading = ref(false);
const dataSource = ref<ProjectPlanInfo[]>([]);
const total = ref(0);
const searchForm = reactive<ProjectPlanQuery>({ page: 1, pageSize: 10, keyword: '', status: 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 viewVisible = ref(false);
const viewData = reactive<Partial<ProjectPlanInfo>>({});
let detailSeq = 1;
const formData = reactive({
customerId: undefined as number | undefined,
registrar: '',
registerTime: undefined as any,
status: 0,
planDesc: '',
details: [] as any[],
});
const formRules = {
customerId: [{ required: true, message: '请选择所属客户', trigger: 'change' }],
};
const columns = [
{ title: '方案单号', dataIndex: 'planNo', key: 'planNo', ellipsis: true, width: 170 },
{ title: '所属客户', key: 'customerName', ellipsis: true },
{ title: '登记人', dataIndex: 'registrar', key: 'registrar', ellipsis: true },
{ title: '登记时间', key: 'registerTime', width: 110 },
{ title: '明细数', key: 'details', align: 'center' as const, width: 90 },
{ title: '状态', key: 'status', align: 'center' as const, width: 90 },
{ title: '操作', key: 'action', width: 180, fixed: 'right' as const },
];
const detailColumns = [
{ title: '设备名称', key: 'equipName', dataIndex: 'equipName' },
{ title: '数量', key: 'quantity', dataIndex: 'quantity', width: 120 },
{ title: '备注', key: 'remark', dataIndex: 'remark' },
{ title: '操作', key: 'action', width: 70, align: 'center' as const },
];
const viewDetailColumns = [
{ title: '设备名称', dataIndex: 'equipName', key: 'equipName' },
{ title: '数量', dataIndex: 'quantity', key: 'quantity', width: 120 },
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
];
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 projectPlanApi.getList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch {
message.error('获取方案列表失败');
} finally {
loading.value = false;
}
}
async function fetchCustomerOptions() {
try {
const res: any = await customerApi.getCustomerOptions();
if (res.code === 200) {
customerOptions.value = res.data.map((item: any) => ({ label: item.customerName, value: item.id }));
}
} catch {
console.error('获取客户选项失败');
}
}
function getCustomerName(customerId: number): string {
const found = customerOptions.value.find((c) => c.value === customerId);
return found ? found.label : `#${customerId}`;
}
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.status = undefined as any;
searchForm.page = 1;
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
function addDetailRow() {
formData.details.push({ id: `new-${detailSeq++}`, equipName: '', quantity: undefined, remark: '' });
}
function removeDetailRow(index: number) {
formData.details.splice(index, 1);
}
function handleAdd() {
isEdit.value = false;
editId.value = null;
modalTitle.value = '新增方案';
resetForm();
formData.details = [];
modalVisible.value = true;
}
function handleEdit(record: ProjectPlanInfo) {
isEdit.value = true;
editId.value = record.id;
modalTitle.value = '编辑方案';
resetForm();
formData.customerId = record.customerId;
formData.registrar = record.registrar;
formData.registerTime = record.registerTime ? dayjs(record.registerTime) : undefined;
formData.status = record.status ?? 0;
formData.planDesc = record.planDesc;
formData.details = (record.details || []).map((d) => ({
id: d.id ?? `new-${detailSeq++}`,
equipName: d.equipName,
quantity: d.quantity,
remark: d.remark,
}));
modalVisible.value = true;
}
function handleView(record: ProjectPlanInfo) {
Object.assign(viewData, record);
viewVisible.value = true;
}
function resetForm() {
formData.customerId = undefined;
formData.registrar = '';
formData.registerTime = undefined;
formData.status = 0;
formData.planDesc = '';
formData.details = [];
if (formRef.value) {
formRef.value.resetFields();
}
}
async function handleSubmit() {
try {
await formRef.value.validate();
} catch {
return;
}
modalLoading.value = true;
try {
const submitData: any = {
customerId: formData.customerId,
registrar: formData.registrar,
registerTime: formData.registerTime ? formData.registerTime.format('YYYY-MM-DD') : undefined,
status: formData.status,
planDesc: formData.planDesc,
details: formData.details
.filter((d: any) => d.equipName || d.quantity)
.map((d: any) => ({ id: Number(d.id) || undefined, equipName: d.equipName, quantity: d.quantity, remark: d.remark })),
};
let res: any;
if (isEdit.value && editId.value) {
res = await projectPlanApi.update(editId.value, submitData);
} else {
res = await projectPlanApi.create(submitData);
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
fetchList();
} else {
message.error(res.message || '操作失败');
}
} catch {
message.error('操作失败');
} finally {
modalLoading.value = false;
}
}
function handleCancel() {
modalVisible.value = false;
resetForm();
}
async function handleDelete(id: number) {
try {
const res: any = await projectPlanApi.delete(id);
if (res.code === 200) {
message.success('删除成功');
fetchList();
} else {
message.error(res.message || '删除失败');
}
} catch {
message.error('删除失败');
}
}
</script>
<style scoped>
.page-management {
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;
}
.detail-block {
margin-top: 8px;
border-top: 1px solid #f0f0f0;
padding-top: 12px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-weight: 600;
}
</style>