Files
smart_grid_iot/client/src/views/customer/department/index.vue
T
andyesfly c60caa4770 refactor(客户管理): 完成客户管理模块UI与架构全面调整
1.  调整菜单与路由:重命名客户信息/客户账号为客户档案管理/客户小程序授权管理,移除客户建筑/房间路由,新增空间拓扑路由
2.  后端新增客户统计接口`/customers/stats`,前端同步API与类型定义
3.  重构客户档案与小程序授权页面,仅保留数据库支持字段,移除无支撑功能
4.  新增空间拓扑页面,合并建筑与房间管理为树形拓扑视图
5.  更新数据库种子数据与权限配置,清理废弃菜单与绑定关系
6.  补充UI设计文档与规范说明
2026-09-09 17:31:24 +08:00

670 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 &amp; 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>