diff --git a/src/main/java/com/campus/water/service/AdminService.java b/src/main/java/com/campus/water/service/AdminService.java index e21d723..bfe1837 100644 --- a/src/main/java/com/campus/water/service/AdminService.java +++ b/src/main/java/com/campus/water/service/AdminService.java @@ -69,42 +69,43 @@ public class AdminService { } /** - * 新增/修改管理员(支持指定角色) - * 重写保存方法,增加区域校验(区域管理员必须关联区域) - */ - public Admin saveAdmin(Admin admin) { - admin.setUpdatedTime(LocalDateTime.now()); - if (admin.getCreatedTime() == null) { - admin.setCreatedTime(LocalDateTime.now()); - } + * 新增/修改管理员(支持指定角色) + * 重写保存方法,增加区域校验(区域管理员必须关联区域) + */ +public Admin saveAdmin(Admin admin) { + admin.setUpdatedTime(LocalDateTime.now()); + if (admin.getCreatedTime() == null) { + admin.setCreatedTime(LocalDateTime.now()); + } - // 区域管理员(ROLE_AREA_ADMIN)的专属校验逻辑 - if (admin.getRole() == Admin.AdminRole.ROLE_AREA_ADMIN) { - // 1. 若未填写区域ID(null/空字符串),直接放行(支持先创建管理员,后续补填) - if (admin.getAreaId() == null || admin.getAreaId().trim().isEmpty()) { - admin.setAreaId(null); // 统一置为null,避免空字符串冗余数据 - // 无需校验,直接允许保存 - } else { - // 2. 若填写了区域ID,进行严格校验:区域存在 + 类型为校区(禁止市区) - String areaId = admin.getAreaId().trim(); - // 校验区域是否存在 - Area targetArea = areaRepository.findById(areaId) - .orElseThrow(() -> new RuntimeException("关联的区域不存在:" + areaId)); - // 核心校验:仅允许关联校区,禁止关联市区 - if (Area.AreaType.zone.equals(targetArea.getAreaType())) { - throw new RuntimeException("区域管理员仅允许关联校区,不能关联市区,请重新选择"); - } - // 校验通过,保留填写的合法校区ID - admin.setAreaId(areaId); - } + // 区域管理员(ROLE_AREA_ADMIN)的专属校验逻辑 + if (admin.getRole() == Admin.AdminRole.ROLE_AREA_ADMIN) { + // 1. 若未填写区域ID(null或空字符串),直接放行(支持先创建管理员,后续补填) + if (admin.getAreaId() == null || admin.getAreaId().trim().isEmpty()) { + admin.setAreaId(null); // 统一置为null,避免空字符串冗余数据 + // 无需校验,直接允许保存 } else { - // 非区域管理员,清空区域ID,避免冗余数据 - admin.setAreaId(null); + // 2. 若填写了区域ID,进行严格校验:区域存在 + 类型为校区(禁止市区) + String areaId = admin.getAreaId().trim(); + // 校验区域是否存在 + Area targetArea = areaRepository.findById(areaId) + .orElseThrow(() -> new RuntimeException("关联的区域不存在:" + areaId)); + // 核心校验:仅允许关联校区,禁止关联市区 + if (Area.AreaType.zone.equals(targetArea.getAreaType())) { + throw new RuntimeException("区域管理员仅允许关联校区,不能关联市区,请重新选择"); + } + // 校验通过,保留填写的合法校区ID + admin.setAreaId(areaId); } - - return adminRepository.save(admin); + } else { + // 非区域管理员,清空区域ID,避免冗余数据 + admin.setAreaId(null); } + return adminRepository.save(admin); +} + + /** * 删除管理员 */ diff --git a/src/main/resources/web/src/views/area/Campus.vue b/src/main/resources/web/src/views/area/Campus.vue index b6ac21f..82fa3f1 100644 --- a/src/main/resources/web/src/views/area/Campus.vue +++ b/src/main/resources/web/src/views/area/Campus.vue @@ -1,4 +1,3 @@ - @@ -207,22 +306,38 @@ interface Area { updatedTime?: Date } +// 区域设备统计视图对象 +interface AreaDeviceStatsVO { + areaId: string + areaName: string + totalDeviceCount: number + onlineDeviceCount: number + offlineDeviceCount: number + faultDeviceCount: number + waterMakerCount: number + waterSupplyCount: number +} + // 响应式数据 -const campusList = ref([]) -const cityList = ref([]) +const campusList = ref([]) // 所有校区列表 +const cityList = ref([]) // 所有市区列表 const adminList = ref([]) const selectedManager = ref(null) +const selectedCityFilter = ref('') // 新增:筛选用的市区ID const selectedCampus = ref('') const currentPage = ref(1) const pageSize = ref(10) const showModal = ref(false) const isEdit = ref(false) const showDeleteConfirm = ref(false) +const showStatsModal = ref(false) +const currentAreaStats = ref(null) const deleteCampusId = ref('') const deleteCampusName = ref('') const loading = ref(false) // 添加加载状态 const saving = ref(false) // 添加保存状态 const deleting = ref(false) // 添加删除状态 +const loadingStats = ref(false) // 添加统计加载状态 // 表单数据 const formData = ref({ @@ -244,13 +359,20 @@ const formatDate = (date: Date | undefined) => { return d.toLocaleDateString('zh-CN') } -// 筛选后的校区列表 +// 获取市区名称 +const getCityName = (cityId: string | null) => { + if (!cityId) return '未知市区' + const city = cityList.value.find(c => c.areaId === cityId) + return city ? city.areaName : '未知市区' +} + +// 筛选后的校区列表 - 按父级市区筛选 const filteredCampus = computed(() => { let filtered = [...campusList.value] - // 根据下拉选择筛选校区 - if (selectedCampus.value) { - filtered = filtered.filter(campus => campus.areaId === selectedCampus.value) + // 按父级市区筛选 + if (selectedCityFilter.value) { + filtered = filtered.filter(campus => campus.parentAreaId === selectedCityFilter.value) } // 分页处理 @@ -261,13 +383,16 @@ const filteredCampus = computed(() => { // 总页数计算 const totalPages = computed(() => { - const filteredCount = selectedCampus.value - ? campusList.value.filter(campus => campus.areaId === selectedCampus.value).length - : campusList.value.length + let filteredCount = campusList.value.length + + if (selectedCityFilter.value) { + filteredCount = campusList.value.filter(campus => campus.parentAreaId === selectedCityFilter.value).length + } + return Math.ceil(filteredCount / pageSize.value) }) -// 获取校区列表 +// 获取所有校区列表(通过多次调用市区接口获取所有校区) const fetchCampusList = async () => { loading.value = true try { @@ -278,8 +403,7 @@ const fetchCampusList = async () => { return } - // 需要先获取所有市区,然后获取所有校区 - // 方案1: 获取所有市区,然后获取每个市区下的校区 + // 先获取所有市区 const citiesResponse = await request<{ code: number msg: string @@ -294,11 +418,12 @@ const fetchCampusList = async () => { return } - const cities = citiesResponse.data - const allCampuses: Area[] = [] + // 更新市区列表 + cityList.value = citiesResponse.data // 获取每个市区下的校区 - for (const city of cities) { + const allCampuses: Area[] = [] + for (const city of citiesResponse.data) { try { const campusResponse = await request<{ code: number @@ -332,7 +457,7 @@ const fetchCampusList = async () => { } } -// 获取市区列表 +// 获取市区列表(实际已经在fetchCampusList中获取了) const fetchCityList = async () => { try { const token = authStore.token @@ -368,7 +493,7 @@ const fetchCityList = async () => { } // 获取区域管理员列表 -// 修改获取管理员列表的函数 +// 获取区域管理员列表 - 修改为获取可分配校区的区域管理员 const fetchAdminList = async () => { try { const token = authStore.token @@ -378,41 +503,41 @@ const fetchAdminList = async () => { return } - // 先获取所有管理员,然后在前端过滤 + // 修改接口调用,使用新接口获取可分配校区的区域管理员 const response = await request<{ code: number msg: string data: Admin[] - }>('/api/web/admin/list', { + }>('/api/web/admin/available-area-admins', { method: 'GET', }) if (response?.code === 200 && response?.data) { - // 过滤出区域管理员 + // 过滤出区域管理员(保持原有逻辑以兼容) const areaAdmins = response.data.filter(admin => admin.role === 'AREA_ADMIN' || admin.role === 'ROLE_AREA_ADMIN' ) adminList.value = areaAdmins } else { - console.error('获取管理员列表失败:', response?.msg || '未知错误') - alert(`获取管理员列表失败:${response?.msg || '未知错误'}`) + console.error('获取可分配校区的区域管理员失败:', response?.msg || '未知错误') + alert(`获取可分配校区的区域管理员失败:${response?.msg || '未知错误'}`) } } catch (error: any) { - console.error('获取管理员列表异常:', error) + console.error('获取可分配校区的区域管理员异常:', error) const errorMsg = error.message.includes('401') || error.message.includes('403') ? '权限不足或登录已过期,请重新登录' : error.message.includes('Network') ? '网络连接失败,请检查网络' - : error.message || '获取管理员列表失败,请稍后重试' - alert(`获取管理员列表失败:${errorMsg}`) + : error.message || '获取可分配校区的区域管理员失败,请稍后重试' + alert(`获取可分配校区的区域管理员失败:${errorMsg}`) } } -// 处理负责人选择变化 +// 处理负责人选择变化 - 修改为传递管理员ID而不是姓名 const onManagerChange = () => { if (selectedManager.value) { - formData.value.manager = selectedManager.value.adminName + formData.value.manager = selectedManager.value.adminId // 传递管理员ID而非姓名 formData.value.managerPhone = selectedManager.value.phone } else { formData.value.manager = '' @@ -420,6 +545,11 @@ const onManagerChange = () => { } } +// 处理市区筛选变化 +const handleCityFilterChange = () => { + currentPage.value = 1 // 重置到第一页 +} + // 新增校区 const handleAddCampus = () => { isEdit.value = false @@ -428,7 +558,7 @@ const handleAddCampus = () => { areaId: '', areaName: '', areaType: 'campus', - parentAreaId: null, // 重置为null,让用户选择 + parentAreaId: null, address: '', manager: '', managerPhone: '', @@ -445,9 +575,15 @@ const handleEdit = (campus: Area) => { formData.value = { ...campus } // 尝试匹配现有的负责人 - const matchedAdmin = adminList.value.find(admin => admin.adminName === campus.manager) + const matchedAdmin = adminList.value.find(admin => admin.adminId === campus.manager) selectedManager.value = matchedAdmin || null + // 如果没有找到匹配的管理员,尝试通过姓名匹配(兼容性处理) + if (!selectedManager.value) { + const matchedByName = adminList.value.find(admin => admin.adminName === campus.manager) + selectedManager.value = matchedByName || null + } + showModal.value = true } @@ -497,7 +633,6 @@ const confirmDelete = async () => { } } -// 保存校区信息 // 保存校区信息 const handleSave = async () => { saving.value = true @@ -515,6 +650,12 @@ const handleSave = async () => { return } + // 验证选择的负责人是否是区域管理员 + if (selectedManager.value.role !== 'ROLE_AREA_ADMIN' && selectedManager.value.role !== 'AREA_ADMIN') { + alert('所选负责人必须是区域管理员角色'); + return; + } + let response if (isEdit.value) { @@ -523,20 +664,19 @@ const handleSave = async () => { code: number msg: string data: Area - }>('/api/web/area/update', { - method: 'PUT', + }>(`/api/web/area/update/${formData.value.areaId}`, { // 修改URL格式以包含areaId + method: 'PUT', // 使用PUT方法 headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authStore.token}` + 'Authorization': `Bearer ${authStore.token}` // 添加认证头 }, body: JSON.stringify({ - areaId: formData.value.areaId, areaName: formData.value.areaName, areaType: 'campus', parentAreaId: formData.value.parentAreaId, address: formData.value.address, - manager: formData.value.manager, - managerPhone: formData.value.managerPhone + manager: selectedManager.value.adminId, // 使用管理员ID而不是姓名 + managerPhone: selectedManager.value.phone }) }) @@ -571,11 +711,11 @@ const handleSave = async () => { areaType: 'campus', parentAreaId: formData.value.parentAreaId, address: formData.value.address, - manager: formData.value.manager, - managerPhone: formData.value.managerPhone + manager: selectedManager.value.adminId, // 使用管理员ID而不是姓名 + managerPhone: selectedManager.value.phone } - console.log('发送的校区数据:', newCampus) // 调试日志 + console.log('发送的校区数据:', newCampus) response = await request<{ code: number @@ -585,7 +725,7 @@ const handleSave = async () => { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authStore.token}` + 'Authorization': `Bearer ${authStore.token}` // 添加认证头 }, body: JSON.stringify(newCampus) }) @@ -605,19 +745,75 @@ const handleSave = async () => { ? '权限不足或登录已过期,请重新登录' : error.message.includes('Network') ? '网络连接失败,请检查网络' - : error.message || '保存失败,请稍后重试' + : error.message.includes('400') + ? '请求参数错误,请检查输入数据' + : error.message || '保存失败,请稍后重试' alert(`保存校区失败:${ errorMsg}`) } finally { saving.value = false } } + +// 根据管理员ID获取管理员姓名 +const getManagerName = (managerId: string) => { + if (!managerId) return '未分配' + const admin = adminList.value.find(admin => admin.adminId === managerId) + return admin ? admin.adminName : '未知负责人' +} + +// 获取区域设备统计信息 +const fetchAreaDeviceStats = async (areaId: string) => { + loadingStats.value = true + try { + const token = authStore.token + if (!token) { + console.warn('未获取到 Token,跳转到登录页') + router.push('/login') + return + } + + const response = await request<{ + code: number + msg: string + data: AreaDeviceStatsVO + }>(`/api/web/area/device-stats/${areaId}`, { + method: 'GET', + }) + + if (response?.code === 200 && response?.data) { + currentAreaStats.value = response.data + showStatsModal.value = true + } else { + const errorMsg = response?.msg || `获取统计信息失败(错误码:${response?.code || '未知'})` + console.error('获取统计信息失败:', errorMsg) + alert(`获取统计信息失败:${errorMsg}`) + } + } catch (error: any) { + console.error('获取统计信息异常:', error) + const errorMsg = error.message.includes('401') || error.message.includes('403') + ? '权限不足或登录已过期,请重新登录' + : error.message.includes('Network') + ? '网络连接失败,请检查网络' + : error.message || '获取统计信息失败,请稍后重试' + alert(`获取统计信息失败:${errorMsg}`) + } finally { + loadingStats.value = false + } +} + +// 处理查看统计信息 +const handleViewStats = (areaId: string, areaName: string) => { + document.title = `查看${areaName}统计信息` + fetchAreaDeviceStats(areaId) +} + // 初始化加载数据 onMounted(async () => { console.log('Token:', authStore.token) await fetchCityList() // 先加载市区列表 await fetchAdminList() // 加载管理员列表 - fetchCampusList() + fetchCampusList() // 获取所有校区数据 }) @@ -742,6 +938,22 @@ onMounted(async () => { opacity: 0.9; } +/* 统计按钮样式 */ +.btn-stats { + background-color: #e6f7ff; + color: #1890ff; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + border: none; + transition: opacity 0.3s; +} + +.btn-stats:hover { + opacity: 0.9; +} + .no-data { text-align: center; padding: 40px 0; @@ -902,6 +1114,124 @@ onMounted(async () => { cursor: not-allowed; } +/* 在现有样式基础上添加筛选框样式 */ +.filter-box { + display: flex; + align-items: center; + gap: 8px; + color: #666; +} + +.area-select { + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + min-width: 180px; + font-size: 14px; +} + +.filter-box label { + white-space: nowrap; +} + +/* 统计弹窗样式 */ +.stats-modal { + width: 600px; + max-width: 90%; +} + +.stats-content { + padding: 16px 0; +} + +.stats-header { + margin-bottom: 20px; + text-align: center; +} + +.stats-header h4 { + margin: 0; + color: #333; + font-size: 18px; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + text-align: center; + padding: 16px; + border: 1px solid #f0f0f0; + border-radius: 8px; + background-color: #fafafa; +} + +.stat-value { + font-size: 24px; + font-weight: bold; + color: #333; + margin-bottom: 4px; +} + +.stat-value.online { + color: #52c41a; +} + +.stat-value.offline { + color: #faad14; +} + +.stat-value.fault { + color: #ff4d4f; +} + +.stat-value.maker { + color: #722ed1; +} + +.stat-value.supply { + color: #13c2c2; +} + +.stat-label { + font-size: 14px; + color: #666; +} + +.stats-details { + border-top: 1px solid #f0f0f0; + padding-top: 16px; +} + +.stats-details h5 { + margin: 0 0 12px 0; + color: #333; + font-size: 16px; +} + +.detail-item { + display: flex; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid #f8f8f8; +} + +.detail-value { + font-weight: 500; + color: #1890ff; +} + +.loading-stats, +.no-stats-data { + text-align: center; + padding: 40px 0; + color: #8c8c8c; +} + /* 响应式调整 */ @media (max-width: 768px) { .action-bar { @@ -916,5 +1246,9 @@ onMounted(async () => { .campus-select, .area-select { width: 100%; } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } } diff --git a/src/main/resources/web/src/views/area/Urban.vue b/src/main/resources/web/src/views/area/Urban.vue index 724304f..b10fb5e 100644 --- a/src/main/resources/web/src/views/area/Urban.vue +++ b/src/main/resources/web/src/views/area/Urban.vue @@ -11,16 +11,6 @@ - -
- - -
@@ -354,7 +344,6 @@ const confirmDelete = async () => { } // 保存片区信息 -// 在 handleSave 方法中修改新增逻辑 const handleSave = async () => { saving.value = true try { @@ -368,14 +357,25 @@ const handleSave = async () => { let response if (isEdit.value) { - // 编辑模式 - 保持原有逻辑 + // 编辑模式 - 修改URL格式以包含areaId作为路径参数 response = await request<{ code: number msg: string data: Area - }>('/api/web/area/update', { + }>(`/api/web/area/update/${formData.value.areaId}`, { // 修改URL格式 method: 'PUT', - body: JSON.stringify(formData.value) + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authStore.token}` // 添加认证头 + }, + body: JSON.stringify({ + areaName: formData.value.areaName, + areaType: formData.value.areaType, + parentAreaId: formData.value.parentAreaId, + address: formData.value.address, + manager: formData.value.manager, + managerPhone: formData.value.managerPhone + }) }) if (response.code === 200) { @@ -402,6 +402,10 @@ const handleSave = async () => { data: Area }>('/api/web/area/add', { method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authStore.token}` // 添加认证头 + }, body: JSON.stringify(newArea) }) @@ -428,6 +432,8 @@ const handleSave = async () => { } + + // 初始化加载数据 onMounted(() => { console.log('Token:', authStore.token) diff --git a/src/main/resources/web/src/views/equipment/WaterMaker.vue b/src/main/resources/web/src/views/equipment/WaterMaker.vue index 3aa1665..1085f96 100644 --- a/src/main/resources/web/src/views/equipment/WaterMaker.vue +++ b/src/main/resources/web/src/views/equipment/WaterMaker.vue @@ -1,43 +1,61 @@