refactor(客户管理): 完成客户管理模块UI与架构全面调整
1. 调整菜单与路由:重命名客户信息/客户账号为客户档案管理/客户小程序授权管理,移除客户建筑/房间路由,新增空间拓扑路由 2. 后端新增客户统计接口`/customers/stats`,前端同步API与类型定义 3. 重构客户档案与小程序授权页面,仅保留数据库支持字段,移除无支撑功能 4. 新增空间拓扑页面,合并建筑与房间管理为树形拓扑视图 5. 更新数据库种子数据与权限配置,清理废弃菜单与绑定关系 6. 补充UI设计文档与规范说明
This commit is contained in:
1 parent
2a06cfea6d
commit
c60caa4770
35 files changed
+7524
-1961
No files matched your search
@@ -68,6 +68,12 @@ export interface CustomerStatusParams {
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface CustomerStats {
|
||||
total: number;
|
||||
statusCounts: Record<number, number>;
|
||||
levelCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
export const customerApi = {
|
||||
getCustomerList: (params: CustomerQuery) =>
|
||||
request.get<CustomerListResult>('/customers', { params }),
|
||||
@@ -78,6 +84,8 @@ export const customerApi = {
|
||||
getCustomerOptions: () =>
|
||||
request.get<CustomerInfo[]>('/customers/options/all'),
|
||||
|
||||
getCustomerStats: () => request.get<CustomerStats>('/customers/stats'),
|
||||
|
||||
createCustomer: (data: CustomerCreateParams) =>
|
||||
request.post('/customers', data),
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface CustomerAccountInfo {
|
||||
id: number;
|
||||
customerId: number;
|
||||
phone: string;
|
||||
wxOpenid?: string | null;
|
||||
status: number;
|
||||
lastLoginAt?: string | null;
|
||||
createdAt: string;
|
||||
|
||||
+56
-23
@@ -1,31 +1,64 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
export interface ModuleCount {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
date: string;
|
||||
customers: number;
|
||||
contracts: number;
|
||||
}
|
||||
|
||||
export interface StatusSlice {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface SurveyOrderItem {
|
||||
id: number;
|
||||
orderNo: string;
|
||||
customerName: string;
|
||||
locale: string;
|
||||
owner: string;
|
||||
status: number;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface ProjectKanban {
|
||||
id: number;
|
||||
customerName: string;
|
||||
amount: number;
|
||||
stage: number;
|
||||
stageLabel: string;
|
||||
progress: number;
|
||||
owner: string;
|
||||
desc: string;
|
||||
orderNo: string;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
deviceTotal: number;
|
||||
deviceOnline: number;
|
||||
alertToday: number;
|
||||
activeUsers: number;
|
||||
deviceTrend: Array<{ date: string; online: number; offline: number }>;
|
||||
deviceTypeDistribution: Array<{ name: string; value: number }>;
|
||||
recentAlerts: Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
device: string;
|
||||
level: string;
|
||||
time: string;
|
||||
status: string;
|
||||
}>;
|
||||
recentActivities: Array<{
|
||||
id: number;
|
||||
user: string;
|
||||
action: string;
|
||||
module: string;
|
||||
ip: string;
|
||||
time: string;
|
||||
}>;
|
||||
customerTotal: number;
|
||||
customerMonthlyNew: number;
|
||||
contractTotal: number;
|
||||
contractYearAmount: number;
|
||||
surveyPending: number;
|
||||
surveyTotal: number;
|
||||
visitMonthly: number;
|
||||
visitTotal: number;
|
||||
planTotal: number;
|
||||
acceptTotal: number;
|
||||
surveyByStatus: StatusSlice[];
|
||||
modules: ModuleCount[];
|
||||
trend: TrendPoint[];
|
||||
customerStatus: StatusSlice[];
|
||||
customerSignRate: number;
|
||||
surveyList: SurveyOrderItem[];
|
||||
projects: ProjectKanban[];
|
||||
}
|
||||
|
||||
export const dashboardApi = {
|
||||
getStats: () =>
|
||||
request.get<DashboardStats>('/dashboard/stats'),
|
||||
};
|
||||
};
|
||||
+10
-16
@@ -32,11 +32,11 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
// 业务模块
|
||||
{
|
||||
path: "customer/info",
|
||||
name: "CustomerInfo",
|
||||
component: () => import("@/views/customer/info/index.vue"),
|
||||
meta: { requiresAuth: true, title: "客户信息", permission: "customer:list" }
|
||||
},
|
||||
path: "customer/info",
|
||||
name: "CustomerInfo",
|
||||
component: () => import("@/views/customer/info/index.vue"),
|
||||
meta: { requiresAuth: true, title: "客户档案管理", permission: "customer:list" }
|
||||
},
|
||||
{
|
||||
path: "customer/department",
|
||||
name: "CustomerDept",
|
||||
@@ -47,7 +47,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: "customer/account",
|
||||
name: "CustomerAccount",
|
||||
component: () => import("@/views/customer/account/index.vue"),
|
||||
meta: { requiresAuth: true, title: "客户账号", permission: "customer:account" }
|
||||
meta: { requiresAuth: true, title: "客户小程序授权管理", permission: "customer:account" }
|
||||
},
|
||||
{
|
||||
path: "customer/visit",
|
||||
@@ -56,16 +56,10 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { requiresAuth: true, title: "客户拜访", permission: "customer:visit" }
|
||||
},
|
||||
{
|
||||
path: "customer/building",
|
||||
name: "CustomerBuilding",
|
||||
component: () => import("@/views/customer/building/index.vue"),
|
||||
meta: { requiresAuth: true, title: "客户建筑", permission: "customer:building" }
|
||||
},
|
||||
{
|
||||
path: "customer/room",
|
||||
name: "CustomerRoom",
|
||||
component: () => import("@/views/customer/room/index.vue"),
|
||||
meta: { requiresAuth: true, title: "客户房间", permission: "customer:room" }
|
||||
path: "customer/topology",
|
||||
name: "CustomerTopology",
|
||||
component: () => import("@/views/customer/topology/index.vue"),
|
||||
meta: { requiresAuth: true, title: "空间拓扑与用电架构", permission: "customer:topology" }
|
||||
},
|
||||
{
|
||||
path: "project",
|
||||
|
||||
@@ -1,114 +1,163 @@
|
||||
<template>
|
||||
<div class="customer-account">
|
||||
<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">MINI PROGRAM AUTHORIZATION</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="content-card">
|
||||
<a-alert
|
||||
type="info"
|
||||
message="初始密码规则:默认密码为手机号后 6 位,如手机号不足 6 位则为 123456;编辑时可勾选重置为默认密码"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<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-select v-model:value="searchForm.status" placeholder="状态" allow-clear style="width: 120px">
|
||||
<a-select-option :value="1">启用</a-select-option>
|
||||
<a-select-option :value="0">禁用</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 === 'customerId'">
|
||||
{{ customerNameMap[record.customerId] || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.status === 1"
|
||||
checked-children="启用"
|
||||
un-checked-children="禁用"
|
||||
@change="(checked: boolean) => handleStatusChange(record.id, checked ? 1 : 0)"
|
||||
<!-- 筛选区 -->
|
||||
<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"
|
||||
/>
|
||||
</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="handleResetPassword(record.id)">
|
||||
<a-button type="link" size="small">
|
||||
<template #icon><KeyOutlined /></template>
|
||||
重置密码
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
<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 class="filter-item">
|
||||
<label class="filter-label">状态</label>
|
||||
<a-select v-model:value="searchForm.status" placeholder="请选择状态" allow-clear>
|
||||
<a-select-option :value="1">启用</a-select-option>
|
||||
<a-select-option :value="0">禁用</a-select-option>
|
||||
</a-select>
|
||||
</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>
|
||||
</a-card>
|
||||
|
||||
<!-- 列表区 -->
|
||||
<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 === 'wxBound'">
|
||||
<a-tag :color="record.wxOpenid ? 'green' : 'default'">
|
||||
{{ record.wxOpenid ? '已绑定' : '未绑定' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.status === 1"
|
||||
checked-children="启用"
|
||||
un-checked-children="禁用"
|
||||
@change="(checked: boolean) => handleStatusChange(record.id, checked ? 1 : 0)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'lastLoginAt'">
|
||||
{{ formatDateTime(record.lastLoginAt) }}
|
||||
</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="handleResetPassword(record.id)">
|
||||
<a-button type="link" size="small">
|
||||
<template #icon><KeyOutlined /></template>
|
||||
重置密码
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
<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="600px"
|
||||
width="640px"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
@@ -119,31 +168,49 @@
|
||||
: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="phone">
|
||||
<a-input v-model:value="formData.phone" placeholder="请输入登录电话" maxlength="20" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">启用</a-radio>
|
||||
<a-radio :value="0">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="isEdit">
|
||||
<a-checkbox v-model:checked="formData.resetPassword">重置为默认密码(手机号后 6 位)</a-checkbox>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px; padding-left: 24px">
|
||||
勾选后保存即重置,下次登录请用新密码
|
||||
<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">Account Information</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="所属客户" name="customerId">
|
||||
<a-select
|
||||
v-model:value="formData.customerId"
|
||||
placeholder="请选择所属客户"
|
||||
:options="customerOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="登录电话" name="phone">
|
||||
<a-input v-model:value="formData.phone" placeholder="请输入登录电话" maxlength="20" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">启用</a-radio>
|
||||
<a-radio :value="0">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</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">Password Policy</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-form-item v-if="isEdit">
|
||||
<a-checkbox v-model:checked="formData.resetPassword">重置为默认密码(手机号后 6 位)</a-checkbox>
|
||||
<div style="font-size: 12px; color: #8a97a8; margin-top: 4px; padding-left: 24px">
|
||||
勾选后保存即重置,下次登录请用新密码
|
||||
</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
@@ -202,16 +269,22 @@ const formRules = {
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '登录手机号', dataIndex: 'phone', key: 'phone', ellipsis: true, width: 140 },
|
||||
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
|
||||
{ title: '登录电话', dataIndex: 'phone', key: 'phone', ellipsis: true, width: 140 },
|
||||
{
|
||||
title: '微信绑定',
|
||||
key: 'wxBound',
|
||||
align: 'center' as const,
|
||||
width: 110,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
align: 'center' as const,
|
||||
width: 100,
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', ellipsis: true, width: 180 },
|
||||
{ title: '操作', key: 'action', width: 260, fixed: 'right' as const },
|
||||
{ title: '最近登录时间', dataIndex: 'lastLoginAt', key: 'lastLoginAt', ellipsis: true, width: 180 },
|
||||
{ title: '操作', key: 'action', width: 200, fixed: 'right' as const },
|
||||
];
|
||||
|
||||
const pagination = computed(() => ({
|
||||
@@ -402,7 +475,7 @@ async function handleResetPassword(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string) {
|
||||
function formatDateTime(dateStr?: string | null) {
|
||||
if (!dateStr) return '-';
|
||||
return dayjs(dateStr).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
@@ -413,33 +486,180 @@ function formatDateTime(dateStr: string) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
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: 0;
|
||||
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 {
|
||||
@@ -448,12 +668,77 @@ function formatDateTime(dateStr: string) {
|
||||
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>
|
||||
@@ -1,368 +0,0 @@
|
||||
<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.customerId"
|
||||
placeholder="所属客户"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
:options="customerOptions"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
/>
|
||||
<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 === '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="620px"
|
||||
@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"
|
||||
:disabled="!isAdmin && isEdit"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="建筑名称" name="buildingName">
|
||||
<a-input v-model:value="formData.buildingName" placeholder="请输入建筑名称" maxlength="128" />
|
||||
</a-form-item>
|
||||
<a-form-item label="建筑用途" name="purpose">
|
||||
<a-input v-model:value="formData.purpose" placeholder="请输入建筑用途" maxlength="128" />
|
||||
</a-form-item>
|
||||
<a-form-item label="楼层数" name="floorCount">
|
||||
<a-input-number v-model:value="formData.floorCount" :min="0" style="width: 100%" placeholder="请输入楼层数" />
|
||||
</a-form-item>
|
||||
<a-form-item label="基本信息" name="basicInfo">
|
||||
<a-textarea v-model:value="formData.basicInfo" :rows="3" placeholder="请输入建筑基本信息" maxlength="2000" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</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 { customerBuildingApi, type CustomerBuildingInfo, type CustomerBuildingQuery } from '@/api/customerBuilding';
|
||||
import { customerApi } from '@/api/customer';
|
||||
import { useTableScroll } from '@/composables/useTableScroll';
|
||||
import { usePermissionStore } from '@/stores/permission';
|
||||
|
||||
const { wrapperRef, tableHeight } = useTableScroll();
|
||||
const permissionStore = usePermissionStore();
|
||||
const isAdmin = computed(() => permissionStore.hasRole('ADMIN'));
|
||||
|
||||
const loading = ref(false);
|
||||
const dataSource = ref<CustomerBuildingInfo[]>([]);
|
||||
const total = ref(0);
|
||||
const searchForm = reactive<CustomerBuildingQuery>({
|
||||
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 formData = reactive({
|
||||
customerId: undefined as number | undefined,
|
||||
buildingName: '',
|
||||
purpose: '',
|
||||
floorCount: undefined as number | undefined,
|
||||
basicInfo: '',
|
||||
});
|
||||
|
||||
const formRules = {
|
||||
customerId: [{ required: true, message: '请选择所属客户', trigger: 'change' }],
|
||||
buildingName: [{ required: true, message: '请输入建筑名称', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '所属客户', key: 'customerName', ellipsis: true },
|
||||
{ title: '建筑名称', dataIndex: 'buildingName', key: 'buildingName', ellipsis: true },
|
||||
{ title: '建筑用途', dataIndex: 'purpose', key: 'purpose', ellipsis: true },
|
||||
{ title: '楼层数', dataIndex: 'floorCount', key: 'floorCount', align: 'center' as const, width: 90 },
|
||||
{ title: '基本信息', dataIndex: 'basicInfo', key: 'basicInfo', ellipsis: true },
|
||||
{ title: '操作', key: 'action', width: 140, 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 customerBuildingApi.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.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: CustomerBuildingInfo) {
|
||||
isEdit.value = true;
|
||||
editId.value = record.id;
|
||||
modalTitle.value = '编辑建筑';
|
||||
resetForm();
|
||||
formData.customerId = record.customerId;
|
||||
formData.buildingName = record.buildingName;
|
||||
formData.purpose = record.purpose;
|
||||
formData.floorCount = record.floorCount;
|
||||
formData.basicInfo = record.basicInfo;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.customerId = undefined;
|
||||
formData.buildingName = '';
|
||||
formData.purpose = '';
|
||||
formData.floorCount = undefined;
|
||||
formData.basicInfo = '';
|
||||
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,
|
||||
buildingName: formData.buildingName,
|
||||
purpose: formData.purpose,
|
||||
floorCount: formData.floorCount,
|
||||
basicInfo: formData.basicInfo,
|
||||
};
|
||||
|
||||
let res: any;
|
||||
if (isEdit.value && editId.value) {
|
||||
res = await customerBuildingApi.update(editId.value, submitData);
|
||||
} else {
|
||||
res = await customerBuildingApi.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 customerBuildingApi.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;
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +1,130 @@
|
||||
<template>
|
||||
<div class="customer-department">
|
||||
<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 DEPARTMENT</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-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 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="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>
|
||||
<!-- 列表区 -->
|
||||
<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 === '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 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="600px"
|
||||
width="720px"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
@@ -94,29 +135,44 @@
|
||||
: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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
@@ -358,30 +414,177 @@ 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 {
|
||||
@@ -390,12 +593,77 @@ function formatDateTime(dateStr: string) {
|
||||
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>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,432 +0,0 @@
|
||||
<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.customerId"
|
||||
placeholder="所属客户"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
:options="customerOptions"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
@change="handleCustomerSearchChange"
|
||||
/>
|
||||
<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 === 'buildingName'">
|
||||
{{ getBuildingName(record.buildingId) }}
|
||||
</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="620px"
|
||||
@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"
|
||||
:disabled="!isAdmin && isEdit"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
@change="handleCustomerFormChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="所属建筑" name="buildingId">
|
||||
<a-select
|
||||
v-model:value="formData.buildingId"
|
||||
placeholder="请选择建筑"
|
||||
:options="buildingOptions"
|
||||
:not-found-content="null"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="房间编码" name="roomCode">
|
||||
<a-input v-model:value="formData.roomCode" placeholder="请输入房间编码" maxlength="64" />
|
||||
</a-form-item>
|
||||
<a-form-item label="房间名称" name="roomName">
|
||||
<a-input v-model:value="formData.roomName" placeholder="请输入房间名称" maxlength="128" />
|
||||
</a-form-item>
|
||||
<a-form-item label="所在楼层" name="floorNo">
|
||||
<a-input v-model:value="formData.floorNo" placeholder="请输入所在楼层" maxlength="16" />
|
||||
</a-form-item>
|
||||
<a-form-item label="房间用途" name="roomPurpose">
|
||||
<a-input v-model:value="formData.roomPurpose" placeholder="请输入房间用途" maxlength="128" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</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 { customerRoomApi, type CustomerRoomInfo, type CustomerRoomQuery } from '@/api/customerRoom';
|
||||
import { customerBuildingApi } from '@/api/customerBuilding';
|
||||
import { customerApi } from '@/api/customer';
|
||||
import { useTableScroll } from '@/composables/useTableScroll';
|
||||
import { usePermissionStore } from '@/stores/permission';
|
||||
|
||||
const { wrapperRef, tableHeight } = useTableScroll();
|
||||
const permissionStore = usePermissionStore();
|
||||
const isAdmin = computed(() => permissionStore.hasRole('ADMIN'));
|
||||
|
||||
const loading = ref(false);
|
||||
const dataSource = ref<CustomerRoomInfo[]>([]);
|
||||
const total = ref(0);
|
||||
const searchForm = reactive<CustomerRoomQuery>({
|
||||
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 buildingOptions = ref<{ label: string; value: number }[]>([]);
|
||||
const allBuildings = ref<{ label: string; value: number; customerId: number }[]>([]);
|
||||
|
||||
const formData = reactive({
|
||||
customerId: undefined as number | undefined,
|
||||
buildingId: undefined as number | undefined,
|
||||
roomCode: '',
|
||||
roomName: '',
|
||||
floorNo: '',
|
||||
roomPurpose: '',
|
||||
});
|
||||
|
||||
const formRules = {
|
||||
customerId: [{ required: true, message: '请选择所属客户', trigger: 'change' }],
|
||||
buildingId: [{ required: true, message: '请选择所属建筑', trigger: 'change' }],
|
||||
roomCode: [{ required: true, message: '请输入房间编码', trigger: 'blur' }],
|
||||
roomName: [{ required: true, message: '请输入房间名称', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '房间编码', dataIndex: 'roomCode', key: 'roomCode', ellipsis: true, width: 130 },
|
||||
{ title: '房间名称', dataIndex: 'roomName', key: 'roomName', ellipsis: true },
|
||||
{ title: '所属客户', key: 'customerName', ellipsis: true },
|
||||
{ title: '所属建筑', key: 'buildingName', ellipsis: true },
|
||||
{ title: '所在楼层', dataIndex: 'floorNo', key: 'floorNo', align: 'center' as const, width: 90 },
|
||||
{ title: '房间用途', dataIndex: 'roomPurpose', key: 'roomPurpose', ellipsis: true },
|
||||
{ title: '操作', key: 'action', width: 140, 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();
|
||||
fetchAllBuildings();
|
||||
});
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await customerRoomApi.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('获取客户选项失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllBuildings() {
|
||||
try {
|
||||
const res: any = await customerBuildingApi.getList({ page: 1, pageSize: 999 });
|
||||
if (res.code === 200) {
|
||||
allBuildings.value = res.data.list.map((item: any) => ({
|
||||
label: item.buildingName,
|
||||
value: item.id,
|
||||
customerId: item.customerId,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
console.error('获取建筑列表失败');
|
||||
}
|
||||
}
|
||||
|
||||
function getCustomerName(customerId: number): string {
|
||||
const found = customerOptions.value.find((c) => c.value === customerId);
|
||||
return found ? found.label : `#${customerId}`;
|
||||
}
|
||||
|
||||
function getBuildingName(buildingId: number): string {
|
||||
const found = allBuildings.value.find((b) => b.value === buildingId);
|
||||
return found ? found.label : `#${buildingId}`;
|
||||
}
|
||||
|
||||
function filterBuildings(customerId?: number) {
|
||||
if (customerId) {
|
||||
buildingOptions.value = allBuildings.value.filter((b) => b.customerId === customerId);
|
||||
} else {
|
||||
buildingOptions.value = allBuildings.value;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomerFormChange(customerId?: number) {
|
||||
formData.buildingId = undefined;
|
||||
filterBuildings(customerId);
|
||||
}
|
||||
|
||||
function handleCustomerSearchChange(customerId?: number) {
|
||||
searchForm.customerId = customerId;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
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();
|
||||
filterBuildings(undefined);
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(record: CustomerRoomInfo) {
|
||||
isEdit.value = true;
|
||||
editId.value = record.id;
|
||||
modalTitle.value = '编辑房间';
|
||||
resetForm();
|
||||
formData.customerId = record.customerId;
|
||||
filterBuildings(record.customerId);
|
||||
formData.buildingId = record.buildingId;
|
||||
formData.roomCode = record.roomCode;
|
||||
formData.roomName = record.roomName;
|
||||
formData.floorNo = record.floorNo;
|
||||
formData.roomPurpose = record.roomPurpose;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.customerId = undefined;
|
||||
formData.buildingId = undefined;
|
||||
formData.roomCode = '';
|
||||
formData.roomName = '';
|
||||
formData.floorNo = '';
|
||||
formData.roomPurpose = '';
|
||||
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,
|
||||
buildingId: formData.buildingId,
|
||||
roomCode: formData.roomCode,
|
||||
roomName: formData.roomName,
|
||||
floorNo: formData.floorNo,
|
||||
roomPurpose: formData.roomPurpose,
|
||||
};
|
||||
|
||||
let res: any;
|
||||
if (isEdit.value && editId.value) {
|
||||
res = await customerRoomApi.update(editId.value, submitData);
|
||||
} else {
|
||||
res = await customerRoomApi.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 customerRoomApi.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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,781 @@
|
||||
<template>
|
||||
<div class="topology-page">
|
||||
<!-- 顶部:标题 + 客户选择 + 新增建筑 -->
|
||||
<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">SPACE TOPOLOGY</span>
|
||||
</div>
|
||||
<p class="desc">以「客户 → 建筑 → 房间」三层结构查看某位客户的用电空间拓扑,支持新增、编辑与删除节点。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a-select
|
||||
v-model:value="selectedCustomerId"
|
||||
placeholder="请选择客户"
|
||||
:options="customerOptions"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
style="width: 280px"
|
||||
:loading="customerLoading"
|
||||
@change="handleCustomerChange"
|
||||
/>
|
||||
<a-button type="primary" :disabled="!selectedCustomerId" @click="handleAddBuilding">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增建筑
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体:拓扑树 -->
|
||||
<div class="content-card">
|
||||
<div class="list-header">
|
||||
<div class="list-title">
|
||||
<span class="dot"></span>
|
||||
<span class="t">拓扑结构</span>
|
||||
<span class="count">{{ buildingCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedCustomerId" class="tree-empty">
|
||||
<a-empty description="请先在上方选择客户,加载其建筑与房间拓扑" />
|
||||
</div>
|
||||
<div v-else-if="!loading && buildingCount === 0" class="tree-empty">
|
||||
<a-empty description="该客户暂无建筑,点击右上角「新增建筑」开始建立拓扑">
|
||||
<a-button type="primary" @click="handleAddBuilding">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增建筑
|
||||
</a-button>
|
||||
</a-empty>
|
||||
</div>
|
||||
<a-tree
|
||||
v-else
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
:tree-data="treeData"
|
||||
:block-node="true"
|
||||
:show-line="false"
|
||||
:show-icon="false"
|
||||
selectable
|
||||
>
|
||||
<template #title="{ data }">
|
||||
<div class="node-row">
|
||||
<span class="node-icon">
|
||||
<component :is="iconFor(data.type)" />
|
||||
</span>
|
||||
<div class="node-main">
|
||||
<span class="node-title">{{ data.title }}</span>
|
||||
<span v-if="data.type !== 'customer' && descFor(data)" class="node-desc">{{ descFor(data) }}</span>
|
||||
</div>
|
||||
<div class="node-actions" @click.stop>
|
||||
<template v-if="data.type === 'building'">
|
||||
<a-button type="link" size="small" @click="handleAddRoom(data)">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增房间
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleEditBuilding(data)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-popconfirm title="删除该建筑及其下所有房间,确定吗?" ok-text="确定" cancel-text="取消" @confirm="handleDeleteBuilding(data.data.id)">
|
||||
<a-button type="link" size="small" danger>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
<template v-else-if="data.type === 'room'">
|
||||
<a-button type="link" size="small" @click="handleEditRoom(data)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-popconfirm title="删除该房间,确定吗?" ok-text="确定" cancel-text="取消" @confirm="handleDeleteRoom(data.data.id)">
|
||||
<a-button type="link" size="small" danger>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-tree>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="modalTitle"
|
||||
:confirm-loading="modalLoading"
|
||||
width="640px"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<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">{{ modalType === 'building' ? '基本信息' : '房间信息' }}</span>
|
||||
<span class="section-sub">{{ modalType === 'building' ? 'Basic Information' : 'Room Information' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="modalType === 'building'">
|
||||
<a-form-item label="所属客户" name="customerId">
|
||||
<a-select v-model:value="formData.customerId" :options="currentCustomerOption" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="建筑名称" name="buildingName">
|
||||
<a-input v-model:value="formData.buildingName" placeholder="请输入建筑名称" maxlength="128" />
|
||||
</a-form-item>
|
||||
<a-form-item label="建筑用途" name="purpose">
|
||||
<a-input v-model:value="formData.purpose" placeholder="请输入建筑用途" maxlength="128" />
|
||||
</a-form-item>
|
||||
<a-form-item label="楼层数" name="floorCount">
|
||||
<a-input-number v-model:value="formData.floorCount" :min="0" style="width: 100%" placeholder="请输入楼层数" />
|
||||
</a-form-item>
|
||||
<a-form-item label="基本信息" name="basicInfo">
|
||||
<a-textarea v-model:value="formData.basicInfo" :rows="3" placeholder="请输入建筑基本信息" maxlength="2000" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-form-item label="所属客户" name="customerId">
|
||||
<a-select v-model:value="formData.customerId" :options="currentCustomerOption" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="所属建筑" name="buildingId">
|
||||
<a-select v-model:value="formData.buildingId" :options="buildingOptions" :not-found-content="null" />
|
||||
</a-form-item>
|
||||
<a-form-item label="房间编码" name="roomCode">
|
||||
<a-input v-model:value="formData.roomCode" placeholder="请输入房间编码" maxlength="64" />
|
||||
</a-form-item>
|
||||
<a-form-item label="房间名称" name="roomName">
|
||||
<a-input v-model:value="formData.roomName" placeholder="请输入房间名称" maxlength="128" />
|
||||
</a-form-item>
|
||||
<a-form-item label="所在楼层" name="floorNo">
|
||||
<a-input v-model:value="formData.floorNo" placeholder="请输入所在楼层" maxlength="16" />
|
||||
</a-form-item>
|
||||
<a-form-item label="房间用途" name="roomPurpose">
|
||||
<a-input v-model:value="formData.roomPurpose" placeholder="请输入房间用途" maxlength="128" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
ClusterOutlined,
|
||||
HomeOutlined,
|
||||
AppstoreOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { customerApi } from '@/api/customer';
|
||||
import { customerBuildingApi, type CustomerBuildingInfo } from '@/api/customerBuilding';
|
||||
import { customerRoomApi, type CustomerRoomInfo } from '@/api/customerRoom';
|
||||
|
||||
type NodeType = 'customer' | 'building' | 'room';
|
||||
|
||||
interface TreeNode {
|
||||
key: string;
|
||||
title: string;
|
||||
type: NodeType;
|
||||
data: any;
|
||||
children?: TreeNode[];
|
||||
}
|
||||
|
||||
const customerOptions = ref<{ label: string; value: number }[]>([]);
|
||||
const customerLoading = ref(false);
|
||||
const selectedCustomerId = ref<number | undefined>(undefined);
|
||||
const selectedCustomerName = ref('');
|
||||
|
||||
const loading = ref(false);
|
||||
const treeData = ref<TreeNode[]>([]);
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const modalLoading = ref(false);
|
||||
const modalTitle = ref('');
|
||||
const modalType = ref<'building' | 'room'>('building');
|
||||
const isEdit = ref(false);
|
||||
const editId = ref<number | null>(null);
|
||||
const formRef = ref();
|
||||
|
||||
const formData = reactive({
|
||||
customerId: undefined as number | undefined,
|
||||
buildingId: undefined as number | undefined,
|
||||
buildingName: '',
|
||||
purpose: '',
|
||||
floorCount: undefined as number | undefined,
|
||||
basicInfo: '',
|
||||
roomCode: '',
|
||||
roomName: '',
|
||||
floorNo: '',
|
||||
roomPurpose: '',
|
||||
});
|
||||
|
||||
const buildingCount = computed(() => {
|
||||
const root = treeData.value[0];
|
||||
return root ? (root.children?.length || 0) : 0;
|
||||
});
|
||||
|
||||
const currentCustomerOption = computed(() =>
|
||||
selectedCustomerId.value !== undefined ? [{ label: selectedCustomerName.value, value: selectedCustomerId.value }] : []
|
||||
);
|
||||
|
||||
const buildingOptions = computed<{ label: string; value: number }[]>(() => {
|
||||
const root = treeData.value[0];
|
||||
if (!root || !root.children) return [];
|
||||
return root.children.map((node) => ({ label: node.title, value: node.data.id as number }));
|
||||
});
|
||||
|
||||
const formRules = computed(() => {
|
||||
if (modalType.value === 'building') {
|
||||
return {
|
||||
customerId: [{ required: true, message: '请选择所属客户', trigger: 'change' }],
|
||||
buildingName: [{ required: true, message: '请输入建筑名称', trigger: 'blur' }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
customerId: [{ required: true, message: '请选择所属客户', trigger: 'change' }],
|
||||
buildingId: [{ required: true, message: '请选择所属建筑', trigger: 'change' }],
|
||||
roomCode: [{ required: true, message: '请输入房间编码', trigger: 'blur' }],
|
||||
roomName: [{ required: true, message: '请输入房间名称', trigger: 'blur' }],
|
||||
};
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchCustomerOptions();
|
||||
});
|
||||
|
||||
function iconFor(type: NodeType) {
|
||||
if (type === 'customer') return ClusterOutlined;
|
||||
if (type === 'building') return HomeOutlined;
|
||||
return AppstoreOutlined;
|
||||
}
|
||||
|
||||
function descFor(data: TreeNode): string {
|
||||
const parts: string[] = [];
|
||||
if (data.type === 'building') {
|
||||
const b: CustomerBuildingInfo = data.data;
|
||||
if (b.purpose) parts.push(b.purpose);
|
||||
if (b.floorCount != null) parts.push(`共${b.floorCount}层`);
|
||||
if (b.basicInfo) parts.push(b.basicInfo);
|
||||
} else if (data.type === 'room') {
|
||||
const r: CustomerRoomInfo = data.data;
|
||||
if (r.roomCode) parts.push(r.roomCode);
|
||||
if (r.floorNo) parts.push(`${r.floorNo}层`);
|
||||
if (r.roomPurpose) parts.push(r.roomPurpose);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
async function fetchCustomerOptions() {
|
||||
customerLoading.value = true;
|
||||
try {
|
||||
const res: any = await customerApi.getCustomerOptions();
|
||||
if (res.code === 200) {
|
||||
customerOptions.value = res.data.map((item: any) => ({
|
||||
label: `${item.customerNo} ${item.customerName}`,
|
||||
value: item.id,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
message.error('获取客户列表失败');
|
||||
} finally {
|
||||
customerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomerChange(id: number) {
|
||||
selectedCustomerId.value = id;
|
||||
const found = customerOptions.value.find((c) => c.value === id);
|
||||
selectedCustomerName.value = found ? found.label : `#${id}`;
|
||||
loadTree();
|
||||
}
|
||||
|
||||
async function loadTree() {
|
||||
if (selectedCustomerId.value === undefined) {
|
||||
treeData.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const bRes: any = await customerBuildingApi.getList({ customerId: selectedCustomerId.value, page: 1, pageSize: 999 });
|
||||
const buildings: CustomerBuildingInfo[] = bRes.code === 200 ? bRes.data.list : [];
|
||||
|
||||
const buildingNodes: TreeNode[] = [];
|
||||
for (const b of buildings) {
|
||||
const rRes: any = await customerRoomApi.getList({ buildingId: b.id, page: 1, pageSize: 999 });
|
||||
const rooms: CustomerRoomInfo[] = rRes.code === 200 ? rRes.data.list : [];
|
||||
buildingNodes.push({
|
||||
key: `building-${b.id}`,
|
||||
title: b.buildingName,
|
||||
type: 'building',
|
||||
data: b,
|
||||
children: rooms.map((r) => ({
|
||||
key: `room-${r.id}`,
|
||||
title: r.roomName,
|
||||
type: 'room',
|
||||
data: r,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
treeData.value = [
|
||||
{
|
||||
key: `customer-${selectedCustomerId.value}`,
|
||||
title: selectedCustomerName.value,
|
||||
type: 'customer',
|
||||
data: null,
|
||||
children: buildingNodes.length ? buildingNodes : undefined,
|
||||
},
|
||||
];
|
||||
expandedKeys.value = [`customer-${selectedCustomerId.value}`];
|
||||
} catch {
|
||||
message.error('加载拓扑失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddBuilding() {
|
||||
modalType.value = 'building';
|
||||
isEdit.value = false;
|
||||
editId.value = null;
|
||||
modalTitle.value = '新增建筑';
|
||||
resetForm();
|
||||
formData.customerId = selectedCustomerId.value;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEditBuilding(node: TreeNode) {
|
||||
const b: CustomerBuildingInfo = node.data;
|
||||
modalType.value = 'building';
|
||||
isEdit.value = true;
|
||||
editId.value = b.id;
|
||||
modalTitle.value = '编辑建筑';
|
||||
resetForm();
|
||||
formData.customerId = b.customerId;
|
||||
formData.buildingName = b.buildingName;
|
||||
formData.purpose = b.purpose || '';
|
||||
formData.floorCount = b.floorCount;
|
||||
formData.basicInfo = b.basicInfo || '';
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleAddRoom(node: TreeNode) {
|
||||
modalType.value = 'room';
|
||||
isEdit.value = false;
|
||||
editId.value = null;
|
||||
modalTitle.value = '新增房间';
|
||||
resetForm();
|
||||
formData.customerId = selectedCustomerId.value;
|
||||
formData.buildingId = node.data.id;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEditRoom(node: TreeNode) {
|
||||
const r: CustomerRoomInfo = node.data;
|
||||
modalType.value = 'room';
|
||||
isEdit.value = true;
|
||||
editId.value = r.id;
|
||||
modalTitle.value = '编辑房间';
|
||||
resetForm();
|
||||
formData.customerId = r.customerId;
|
||||
formData.buildingId = r.buildingId;
|
||||
formData.roomCode = r.roomCode;
|
||||
formData.roomName = r.roomName;
|
||||
formData.floorNo = r.floorNo || '';
|
||||
formData.roomPurpose = r.roomPurpose || '';
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.customerId = undefined;
|
||||
formData.buildingId = undefined;
|
||||
formData.buildingName = '';
|
||||
formData.purpose = '';
|
||||
formData.floorCount = undefined;
|
||||
formData.basicInfo = '';
|
||||
formData.roomCode = '';
|
||||
formData.roomName = '';
|
||||
formData.floorNo = '';
|
||||
formData.roomPurpose = '';
|
||||
if (formRef.value) {
|
||||
formRef.value.clearValidate();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
modalLoading.value = true;
|
||||
try {
|
||||
let res: any;
|
||||
if (modalType.value === 'building') {
|
||||
const payload = {
|
||||
customerId: formData.customerId,
|
||||
buildingName: formData.buildingName,
|
||||
purpose: formData.purpose,
|
||||
floorCount: formData.floorCount,
|
||||
basicInfo: formData.basicInfo,
|
||||
};
|
||||
if (isEdit.value && editId.value != null) {
|
||||
res = await customerBuildingApi.update(editId.value, payload);
|
||||
} else {
|
||||
res = await customerBuildingApi.create(payload);
|
||||
}
|
||||
} else {
|
||||
const payload = {
|
||||
customerId: formData.customerId,
|
||||
buildingId: formData.buildingId,
|
||||
roomCode: formData.roomCode,
|
||||
roomName: formData.roomName,
|
||||
floorNo: formData.floorNo,
|
||||
roomPurpose: formData.roomPurpose,
|
||||
};
|
||||
if (isEdit.value && editId.value != null) {
|
||||
res = await customerRoomApi.update(editId.value, payload);
|
||||
} else {
|
||||
res = await customerRoomApi.create(payload);
|
||||
}
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功');
|
||||
modalVisible.value = false;
|
||||
loadTree();
|
||||
} else {
|
||||
message.error(res.message || '操作失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
} finally {
|
||||
modalLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
modalVisible.value = false;
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function handleDeleteBuilding(id: number) {
|
||||
try {
|
||||
const res: any = await customerBuildingApi.delete(id);
|
||||
if (res.code === 200) {
|
||||
message.success('删除成功');
|
||||
loadTree();
|
||||
} else {
|
||||
message.error(res.message || '删除失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteRoom(id: number) {
|
||||
try {
|
||||
const res: any = await customerRoomApi.delete(id);
|
||||
if (res.code === 200) {
|
||||
message.success('删除成功');
|
||||
loadTree();
|
||||
} else {
|
||||
message.error(res.message || '删除失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.topology-page {
|
||||
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 {
|
||||
min-width: 0;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 14px rgba(13, 28, 44, 0.06);
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.tree-empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.node-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #0054cd;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
font-size: 14px;
|
||||
color: #0d1c2c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.node-desc {
|
||||
font-size: 12px;
|
||||
color: #5b6b7e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-actions {
|
||||
flex-shrink: 0;
|
||||
visibility: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-row:hover .node-actions {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.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-tree-treenode) {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
:deep(.ant-tree-node-content-wrapper) {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
:deep(.ant-tree-title) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
: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>
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user