refactor(客户管理): 完成客户管理模块UI与架构全面调整

1.  调整菜单与路由:重命名客户信息/客户账号为客户档案管理/客户小程序授权管理,移除客户建筑/房间路由,新增空间拓扑路由
2.  后端新增客户统计接口`/customers/stats`,前端同步API与类型定义
3.  重构客户档案与小程序授权页面,仅保留数据库支持字段,移除无支撑功能
4.  新增空间拓扑页面,合并建筑与房间管理为树形拓扑视图
5.  更新数据库种子数据与权限配置,清理废弃菜单与绑定关系
6.  补充UI设计文档与规范说明
This commit is contained in:
andyesfly committed 2026-09-09 17:31:24 +08:00
1 parent 2a06cfea6d
commit c60caa4770
35 files changed
+7524 -1961

No files matched your search

+402 -126
View File
@@ -1,93 +1,132 @@
<template>
<div class="page-management">
<a-card title="客户拜访管理">
<template #extra>
<a-space>
<!-- 顶部标题 + 操作 -->
<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 VISIT</span>
</div>
<p class="desc">登记客户拜访记录,跟踪客户意向方案与拜访跟进情况,支持按业务员检索。</p>
</div>
<div class="header-actions">
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增拜访
</a-button>
</a-space>
</template>
</div>
</div>
</div>
<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-if="isAdmin"
v-model:value="searchForm.salesmanId"
placeholder="负责业务员"
allow-clear
style="width: 160px"
:options="salesmanOptions"
/>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
<!-- 主体卡片 -->
<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" v-if="isAdmin">
<label class="filter-label">负责业务员</label>
<a-select
v-model:value="searchForm.salesmanId"
placeholder="请选择业务员"
allow-clear
:options="salesmanOptions"
/>
</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="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 === 'willPlan'">
<a-tag :color="record.willPlan === 1 ? 'orange' : 'default'">
{{ record.willPlan === 1 ? '有意向方案' : '暂无' }}
</a-tag>
</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>
<!-- 列表区 -->
<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 === 'customerName'">
{{ getCustomerName(record.customerId) }}
</template>
<template v-if="column.key === 'willPlan'">
<a-tag :color="record.willPlan === 1 ? 'orange' : 'default'">
{{ record.willPlan === 1 ? '有意向方案' : '暂无' }}
</a-tag>
</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>
<template #emptyText>
<a-empty description="暂无拜访数据" />
</template>
</a-table>
</div>
</div>
</a-card>
</div>
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="680px"
width="720px"
@ok="handleSubmit"
@cancel="handleCancel"
>
@@ -98,42 +137,61 @@
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="所属客户" name="customerId">
<a-select
v-model:value="formData.customerId"
placeholder="请选择客户"
:options="customerOptions"
:disabled="!isAdmin && isEdit"
show-search
option-filter-prop="label"
/>
</a-form-item>
<a-form-item label="拜访人" name="visitPerson">
<a-input v-model:value="formData.visitPerson" placeholder="请输入拜访人" maxlength="64" />
</a-form-item>
<a-form-item label="联系电话" name="visitPhone">
<a-input v-model:value="formData.visitPhone" placeholder="请输入联系电话" />
</a-form-item>
<a-form-item label="拜访时间" name="visitTime">
<a-date-picker v-model:value="formData.visitTime" style="width: 100%" placeholder="请选择拜访时间" />
</a-form-item>
<a-form-item label="是否有意向方案" name="willPlan">
<a-radio-group v-model:value="formData.willPlan">
<a-radio :value="1">有</a-radio>
<a-radio :value="0">无</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="拜访内容" name="visitContent">
<a-textarea v-model:value="formData.visitContent" :rows="3" placeholder="请输入拜访内容" maxlength="2000" />
</a-form-item>
<a-form-item v-if="isAdmin" label="负责业务员" name="salesmanId">
<a-select
v-model:value="formData.salesmanId"
placeholder="请选择负责业务员"
:options="salesmanOptions"
allow-clear
/>
</a-form-item>
<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"
:disabled="!isAdmin && isEdit"
show-search
option-filter-prop="label"
/>
</a-form-item>
<a-form-item label="拜访人" name="visitPerson">
<a-input v-model:value="formData.visitPerson" placeholder="请输入拜访人" maxlength="64" />
</a-form-item>
<a-form-item label="联系电话" name="visitPhone">
<a-input v-model:value="formData.visitPhone" placeholder="请输入联系电话" />
</a-form-item>
<a-form-item label="拜访时间" name="visitTime">
<a-date-picker v-model:value="formData.visitTime" style="width: 100%" placeholder="请选择拜访时间" />
</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">Visit Detail</span>
</div>
</div>
<a-form-item label="是否有意向方案" name="willPlan">
<a-radio-group v-model:value="formData.willPlan">
<a-radio :value="1">有</a-radio>
<a-radio :value="0">无</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="拜访内容" name="visitContent">
<a-textarea v-model:value="formData.visitContent" :rows="3" placeholder="请输入拜访内容" maxlength="2000" />
</a-form-item>
<a-form-item v-if="isAdmin" label="负责业务员" name="salesmanId">
<a-select
v-model:value="formData.salesmanId"
placeholder="请选择负责业务员"
:options="salesmanOptions"
allow-clear
/>
</a-form-item>
</div>
</a-form>
</a-modal>
</div>
@@ -386,38 +444,256 @@ function formatDateTime(dateStr: string) {
overflow: hidden;
padding: 20px;
box-sizing: border-box;
background: #eef3f8;
}
: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 {
.page-header {
flex-shrink: 0;
margin-bottom: 16px;
}
.breadcrumb {
display: flex;
justify-content: flex-end;
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>