1. 调整菜单与路由:重命名客户信息/客户账号为客户档案管理/客户小程序授权管理,移除客户建筑/房间路由,新增空间拓扑路由 2. 后端新增客户统计接口`/customers/stats`,前端同步API与类型定义 3. 重构客户档案与小程序授权页面,仅保留数据库支持字段,移除无支撑功能 4. 新增空间拓扑页面,合并建筑与房间管理为树形拓扑视图 5. 更新数据库种子数据与权限配置,清理废弃菜单与绑定关系 6. 补充UI设计文档与规范说明
670 lines
15 KiB
Vue
670 lines
15 KiB
Vue
<template>
|
||
<div class="customer-department">
|
||
<!-- 顶部标题 + 操作 -->
|
||
<div class="page-header">
|
||
<div class="breadcrumb">
|
||
<span>运营管控体系</span>
|
||
<span class="sep">/</span>
|
||
<span>客户管理</span>
|
||
<span class="sep">/</span>
|
||
<span class="cur">客户部门</span>
|
||
</div>
|
||
<div class="header-main">
|
||
<div class="header-left">
|
||
<div class="header-row">
|
||
<span class="title-bar"></span>
|
||
<span class="main-title">客户部门</span>
|
||
<span class="sub-title">CUSTOMER DEPARTMENT</span>
|
||
</div>
|
||
<p class="desc">按所属客户维护其内部组织结构与职能部门,支持快速检索与部门档案管理。</p>
|
||
</div>
|
||
<div class="header-actions">
|
||
<a-button type="primary" @click="handleAdd">
|
||
<template #icon><PlusOutlined /></template>
|
||
新增部门
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主体卡片 -->
|
||
<div class="content-card">
|
||
<!-- 筛选区 -->
|
||
<div class="filter-card">
|
||
<div class="filter-grid">
|
||
<div class="filter-item">
|
||
<label class="filter-label">关键词检索</label>
|
||
<a-input
|
||
v-model:value="searchForm.keyword"
|
||
placeholder="部门名称 / 联系人 / 电话"
|
||
allow-clear
|
||
@pressEnter="handleSearch"
|
||
>
|
||
<template #prefix><SearchOutlined /></template>
|
||
</a-input>
|
||
</div>
|
||
<div class="filter-item">
|
||
<label class="filter-label">所属客户</label>
|
||
<a-select
|
||
v-model:value="searchForm.customerId"
|
||
placeholder="请选择客户"
|
||
allow-clear
|
||
:options="customerOptions"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="filter-bottom">
|
||
<div class="filter-actions">
|
||
<a-button type="primary" @click="handleSearch">
|
||
<template #icon><SearchOutlined /></template>
|
||
查询
|
||
</a-button>
|
||
<a-button @click="handleReset">
|
||
<template #icon><ReloadOutlined /></template>
|
||
重置
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 列表区 -->
|
||
<div class="list-section">
|
||
<div class="list-header">
|
||
<div class="list-title">
|
||
<span class="dot"></span>
|
||
<span class="t">部门列表</span>
|
||
<span class="count">{{ total }}</span>
|
||
</div>
|
||
</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>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 新增/编辑弹窗 -->
|
||
<a-modal
|
||
v-model:open="modalVisible"
|
||
:title="modalTitle"
|
||
:confirm-loading="modalLoading"
|
||
width="720px"
|
||
@ok="handleSubmit"
|
||
@cancel="handleCancel"
|
||
>
|
||
<a-form
|
||
ref="formRef"
|
||
:model="formData"
|
||
:rules="formRules"
|
||
:label-col="{ span: 6 }"
|
||
:wrapper-col="{ span: 16 }"
|
||
>
|
||
<div class="form-section">
|
||
<div class="section-head">
|
||
<span class="section-no">01</span>
|
||
<div class="section-text">
|
||
<span class="section-name">基本信息</span>
|
||
<span class="section-sub">Basic Information</span>
|
||
</div>
|
||
</div>
|
||
<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>
|
||
</div>
|
||
|
||
<div class="form-section">
|
||
<div class="section-head">
|
||
<span class="section-no">02</span>
|
||
<div class="section-text">
|
||
<span class="section-name">联系与职能</span>
|
||
<span class="section-sub">Contact & Function</span>
|
||
</div>
|
||
</div>
|
||
<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>
|
||
</div>
|
||
</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;
|
||
background: #eef3f8;
|
||
}
|
||
|
||
.page-header {
|
||
flex-shrink: 0;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.breadcrumb {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 12px;
|
||
color: #5b6b7e;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.breadcrumb .sep {
|
||
color: #c6d2e0;
|
||
}
|
||
|
||
.breadcrumb .cur {
|
||
color: #0d1c2c;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.header-main {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: space-between;
|
||
gap: 20px;
|
||
}
|
||
|
||
.header-left {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.header-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.title-bar {
|
||
width: 5px;
|
||
height: 22px;
|
||
background: #0054cd;
|
||
border-radius: 3px;
|
||
align-self: center;
|
||
}
|
||
|
||
.main-title {
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
color: #0d1c2c;
|
||
}
|
||
|
||
.sub-title {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: #0054cd;
|
||
letter-spacing: 2px;
|
||
}
|
||
|
||
.desc {
|
||
font-size: 13px;
|
||
color: #5b6b7e;
|
||
line-height: 1.6;
|
||
margin: 0;
|
||
}
|
||
|
||
.header-actions {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.content-card {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
background: #fff;
|
||
border: 1px solid #dbe4ee;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
|
||
padding: 20px;
|
||
}
|
||
|
||
.filter-card {
|
||
flex-shrink: 0;
|
||
background: #f8faff;
|
||
border: 1px solid #dbe4ee;
|
||
border-radius: 10px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.filter-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 12px 16px;
|
||
}
|
||
|
||
.filter-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.filter-label {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #5b6b7e;
|
||
}
|
||
|
||
.filter-bottom {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-top: 14px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid #dbe4ee;
|
||
}
|
||
|
||
.filter-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.list-section {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.list-header {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 2px 4px 12px;
|
||
}
|
||
|
||
.list-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.list-title .dot {
|
||
width: 4px;
|
||
height: 16px;
|
||
background: #0054cd;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.list-title .t {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: #0d1c2c;
|
||
}
|
||
|
||
.list-title .count {
|
||
padding: 1px 8px;
|
||
border-radius: 6px;
|
||
background: #eef4ff;
|
||
color: #0054cd;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.table-wrapper {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.form-section {
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.section-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin: 4px 0 10px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid #eef1f6;
|
||
}
|
||
|
||
.section-no {
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 7px;
|
||
background: #0054cd;
|
||
color: #fff;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.section-text {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
}
|
||
|
||
.section-name {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: #0d1c2c;
|
||
}
|
||
|
||
.section-sub {
|
||
font-size: 11px;
|
||
color: #8a97a8;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
:deep(.ant-table) {
|
||
font-size: 14px;
|
||
height: 100%;
|
||
}
|
||
|
||
:deep(.ant-table-thead > tr > th) {
|
||
background: #f4f7ff;
|
||
color: #5b6b7e;
|
||
font-weight: 600;
|
||
}
|
||
|
||
:deep(.ant-btn-link) {
|
||
padding: 0 4px;
|
||
}
|
||
|
||
:deep(.ant-btn-primary) {
|
||
background: #0054cd;
|
||
border-color: #0054cd;
|
||
}
|
||
|
||
:deep(.ant-btn-primary:not(:disabled):not(.ant-btn-disabled):hover) {
|
||
background: #0947ab;
|
||
border-color: #0947ab;
|
||
}
|
||
|
||
:deep(.ant-modal-title) {
|
||
color: #0d1c2c;
|
||
font-weight: 700;
|
||
}
|
||
</style>
|