Merge pull request '登录接口' (#57) from zhanghongwei_branch into develop

pull/59/head
pc8xi2fbj 1 month ago
commit cda7d36aa8

@ -102,17 +102,24 @@ public class DeviceStatusController {
@GetMapping("/by-status")
@Operation(summary = "按状态查询设备", description = "根据状态查询设备列表")
public ResponseEntity<ResultVO<List<Device>>> getDevicesByStatus(
@RequestParam String status,
@RequestParam(required = false) String areaId,
@RequestParam(required = false) String deviceType) {
try {
List<Device> devices = deviceStatusService.getDevicesByStatus(status, areaId, deviceType);
return ResponseEntity.ok(ResultVO.success(devices));
} catch (Exception e) {
return ResponseEntity.ok(ResultVO.error(500, "查询设备失败: " + e.getMessage()));
}
@RequestParam String status,
@RequestParam(required = false) String areaId,
@RequestParam(required = false) String deviceType) {
// 添加默认值处理
if (deviceType == null || deviceType.isEmpty()) {
deviceType = "water_maker"; // 默认值
}
try {
List<Device> devices = deviceStatusService.getDevicesByStatus(status, areaId, deviceType);
return ResponseEntity.ok(ResultVO.success(devices));
} catch (Exception e) {
return ResponseEntity.ok(ResultVO.error(500, "查询设备失败: " + e.getMessage()));
}
}
@GetMapping("/status-count")
@Operation(summary = "设备状态数量统计", description = "统计各状态设备数量")
public ResponseEntity<ResultVO<Map<String, Object>>> getDeviceStatusCount(

@ -12,6 +12,7 @@ import com.campus.water.entity.dto.request.LoginRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;
import java.util.UUID;
@ -37,7 +38,7 @@ public class LoginService {
};
}
private LoginVO handleAdminLogin(String username, String password) {
/* private LoginVO handleAdminLogin(String username, String password) {
Admin admin = adminRepository.findByAdminName(username)
.orElseThrow(() -> new RuntimeException("管理员不存在"));
@ -46,8 +47,29 @@ public class LoginService {
}
return createLoginVO(admin.getAdminId(), username, "admin");
}*/
private LoginVO handleAdminLogin(String username, String password) {
Admin admin = adminRepository.findByAdminName(username)
.orElseThrow(() -> new RuntimeException("管理员不存在"));
boolean matches;
// 临时支持 MD5 验证(仅用于测试环境)
if (admin.getPassword().startsWith("$2a$") || admin.getPassword().startsWith("$2y$")) {
// BCrypt 格式密码
matches = passwordEncoder.matches(password, admin.getPassword());
} else {
// MD5 格式密码
String md5Password = DigestUtils.md5DigestAsHex(password.getBytes());
matches = md5Password.equals(admin.getPassword());
}
if (!matches) {
throw new RuntimeException("密码错误");
}
return createLoginVO(admin.getAdminId(), username, "admin");
}
private LoginVO handleUserLogin(String username, String password) {
// 改为查询User实体使用studentName字段匹配用户名
User user = userRepository.findByStudentName(username)

@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://localhost:8080
VITE_APP_ORIGIN=http://localhost:5173

@ -1,58 +1,61 @@
import type { LoginRequest, LoginVO } from './types/auth'
// 替换原文件内容
import type { LoginRequest, LoginResponse, LoginVO } from './types/auth'
// 真实的登录API调用
export const realLoginApi = async (data: LoginRequest): Promise<LoginVO> => {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'
console.log('🌐 调用登录接口:', `${API_BASE_URL}/api/common/login`)
console.log('📤 请求数据:', data)
try {
const response = await fetch(`${API_BASE_URL}/api/common/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
console.log('📥 响应状态:', response.status, response.statusText)
if (!response.ok) {
throw new Error(`网络请求失败: ${response.status} ${response.statusText}`)
export const realLoginApi = async (data: LoginRequest): Promise<LoginResponse> => {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'
console.log('🌐 调用登录接口:', `${API_BASE_URL}/api/common/login`)
console.log('📤 请求数据:', data)
try {
const response = await fetch(`${API_BASE_URL}/api/common/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
console.log('📥 响应状态:', response.status, response.statusText)
if (!response.ok) {
const errorText = await response.text()
console.error('❌ 响应内容:', errorText)
throw new Error(`网络请求失败: ${response.status} ${response.statusText}`)
}
const result: LoginResponse = await response.json()
console.log('✅ 登录响应:', result)
return result
} catch (error: any) {
console.error('❌ 登录接口调用失败:', error)
throw new Error(`登录失败: ${error.message}`)
}
const result: LoginVO = await response.json()
console.log('✅ 登录响应:', result)
return result
} catch (error: any) {
console.error('❌ 登录接口调用失败:', error)
throw new Error(`登录失败: ${error.message}`)
}
}
// 备用模拟登录
export const mockLoginApi = async (data: LoginRequest): Promise<LoginVO> => {
await new Promise(resolve => setTimeout(resolve, 1000))
if (data.username === 'admin' && data.password === '123456') {
return {
code: 200,
message: '登录成功',
data: {
token: 'mock-jwt-token-' + Date.now(),
userInfo: {
id: 1,
username: 'admin',
realName: '张管理员',
role: 'admin',
avatar: ''
export const mockLoginApi = async (data: LoginRequest): Promise<LoginResponse> => {
await new Promise(resolve => setTimeout(resolve, 1000))
if (data.username === 'admin' && data.password === '123456') {
return {
code: 200,
message: '登录成功',
data: {
token: 'mock-jwt-token-' + Date.now(),
userInfo: {
id: 1,
username: 'admin',
realName: '张管理员',
role: 'admin',
avatar: ''
}
}
}
}
} else {
throw new Error('用户名或密码错误')
}
} else {
throw new Error('用户名或密码错误')
}
}

@ -0,0 +1,50 @@
// src/api/deviceStatus.ts
import axios from 'axios'
export const DeviceStatusApi = {
// 获取设备状态列表 - 修改为匹配后端实际接口
getDevicesByStatus: async (status: string, areaId?: string, deviceType?: string) => {
try {
const params: any = { status }
if (areaId) params.areaId = areaId
if (deviceType) params.deviceType = deviceType
const response = await axios.get('/api/web/device-status/by-status', { params })
return response.data
} catch (error) {
throw new Error(`获取设备列表失败: ${error}`)
}
},
// 标记设备在线
markDeviceOnline: async (deviceId: string) => {
try {
const response = await axios.post(`/api/web/device-status/${deviceId}/online`)
return response.data
} catch (error) {
throw new Error(`设置设备在线失败: ${error}`)
}
},
// 标记设备离线
markDeviceOffline: async (deviceId: string, reason?: string) => {
try {
const params = reason ? { reason } : {}
const response = await axios.post(`/api/web/device-status/${deviceId}/offline`, null, { params })
return response.data
} catch (error) {
throw new Error(`设置设备离线失败: ${error}`)
}
},
// 标记设备故障
markDeviceFault: async (deviceId: string, faultType: string, description: string) => {
try {
const params = { faultType, description }
const response = await axios.post(`/api/web/device-status/${deviceId}/fault`, null, { params })
return response.data
} catch (error) {
throw new Error(`设置设备故障失败: ${error}`)
}
}
}

@ -0,0 +1,15 @@
// src/api/modules/auth.ts
import { api } from '../request'
import type { LoginRequest, LoginVO, ResultVO } from '../types/auth'
class AuthApi {
/**
*
*/
async login(data: LoginRequest): Promise<ResultVO<LoginVO>> {
console.log('🚀 调用登录接口:', data)
return api.post<ResultVO<LoginVO>>('/api/common/login', data)
}
}
export const authApi = new AuthApi()

@ -0,0 +1,141 @@
// src/api/request.ts
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'
// 统一的 fetch 封装
export async function request<T>(
url: string,
options: RequestInit = {}
): Promise<T> {
console.log(`🌐 发送请求: ${API_BASE_URL}${url}`, {
method: options.method || 'GET',
headers: options.headers,
body: options.body ? JSON.parse(options.body as string) : undefined,
})
const defaultOptions: RequestInit = {
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
}
// 确保登录请求不携带任何认证信息
const isLoginRequest = url.includes('/login')
if (isLoginRequest) {
console.log('🔐 这是登录请求,不携带认证头')
// 确保没有 Authorization header
const headers = new Headers(defaultOptions.headers)
headers.delete('Authorization')
headers.delete('authorization')
defaultOptions.headers = headers
} else {
// 非登录请求,从存储中获取 token
const token = localStorage.getItem('token') || sessionStorage.getItem('token')
if (token) {
const headers = new Headers(defaultOptions.headers)
headers.set('Authorization', `Bearer ${token}`)
defaultOptions.headers = headers
}
}
try {
const response = await fetch(`${API_BASE_URL}${url}`, {
...defaultOptions,
...options,
})
console.log('📥 响应状态:', response.status, response.statusText)
// 尝试读取响应文本(无论成功与否)
let responseText = ''
try {
responseText = await response.text()
console.log('📥 响应内容:', responseText)
} catch (e) {
console.log('📥 无法读取响应文本')
}
if (!response.ok) {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`
if (responseText) {
try {
const errorJson = JSON.parse(responseText)
errorMessage = errorJson.message || errorJson.error || errorMessage
} catch {
errorMessage = `${errorMessage}\n${responseText}`
}
}
console.error('❌ 请求失败:', errorMessage)
throw new Error(errorMessage)
}
// 尝试解析 JSON
if (responseText) {
try {
const data = JSON.parse(responseText)
console.log('✅ 解析成功的数据:', data)
return data
} catch (e) {
console.error('❌ JSON 解析失败:', e)
throw new Error(`响应不是有效的 JSON: ${responseText}`)
}
} else {
// 没有响应体的情况(如 204 No Content
return {} as T
}
} catch (error: any) {
console.error('❌ 请求异常:', error)
// 处理网络错误
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('网络连接失败,请检查网络设置和后端服务')
}
throw error
}
}
// 封装常用 HTTP 方法
export const api = {
get<T>(url: string) {
return request<T>(url, { method: 'GET' })
},
post<T>(url: string, data?: any) {
return request<T>(url, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
})
},
put<T>(url: string, data?: any) {
return request<T>(url, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
})
},
patch<T>(url: string, data?: any) {
return request<T>(url, {
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
})
},
delete<T>(url: string) {
return request<T>(url, { method: 'DELETE' })
},
upload<T>(url: string, formData: FormData) {
const headers = new Headers()
// 上传文件时不要设置 Content-Type浏览器会自动设置
headers.delete('Content-Type')
return request<T>(url, {
method: 'POST',
headers,
body: formData,
})
},
}

@ -1,22 +1,29 @@
// 与后端 LoginRequest 对应的类型
// src/api/types/auth.ts
// 登录请求参数 - 匹配后端的 LoginRequest
export interface LoginRequest {
username: string
password: string
rememberMe?: boolean
username: string
password: string
userType: string // 添加这个属性
rememberMe?: boolean
}
// 通用响应结构
export interface ResultVO<T = any> {
code: number
message: string
data: T
}
// 与后端 LoginVO 对应的类型
// 登录响应数据 - 匹配后端的 LoginVO
export interface LoginVO {
code: number
message: string
data: {
token: string
userInfo: {
id: number
username: string
realName: string
role: string
avatar?: string
id: number
username: string
realName?: string // 根据后端字段调整
role?: string // 根据后端字段调整
userType?: string // 添加这个字段
avatar?: string
}
}
}

@ -4,7 +4,7 @@
<h1 class="system-title">校园矿化水系统</h1>
</div>
<div class="header-right">
<div class="user-info">
<div class="user-info" @click="goToProfile">
<div class="user-avatar">
<img :src="userAvatar" alt="用户头像" />
</div>
@ -17,8 +17,15 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const userAvatar = ref('images/用户界面/u106.jpg') //
const router = useRouter()
//
const goToProfile = () => {
router.push('/home/profile')
}
</script>
<style scoped>

@ -29,15 +29,18 @@ interface MenuItem {
const route = useRoute()
const router = useRouter()
// ID
const activeItem = computed(() => {
const currentPath = route.path
//
const item = menuItems.find(item =>
item.route === currentPath ||
item.children?.some(child => child.route === currentPath)
)
return item?.id || 1
return item?.id || 1 //
})
//
const menuItems: MenuItem[] = [
{
id: 1,
@ -72,7 +75,7 @@ const menuItems: MenuItem[] = [
id: 4,
name: '人员管理',
icon: '👥',
route: '/home/personnel',
//
children: [
{ name: '管理员', route: '/home/personnel/admin' },
{ name: '维修人员', route: '/home/personnel/maintenance' },
@ -83,7 +86,7 @@ const menuItems: MenuItem[] = [
id: 5,
name: '片区',
icon: '🗺️',
route: '/home/area',
//
children: [
{ name: '市区', route: '/home/area/urban' },
{ name: '校区', route: '/home/area/campus' }
@ -97,6 +100,7 @@ const menuItems: MenuItem[] = [
}
]
//
const handleItemClick = (itemId: number) => {
const item = menuItems.find(m => m.id === itemId)
if (item?.route) {
@ -111,6 +115,8 @@ const handleItemClick = (itemId: number) => {
background: white;
border-right: 1px solid #e1e8ed;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.05);
height: calc(100vh - 60px); /* 减去头部高度 */
overflow-y: auto; /* 菜单过多时可滚动 */
}
.sidebar-nav {

@ -1,33 +1,251 @@
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHistory, type NavigationGuardNext, type RouteLocationNormalized } from 'vue-router'
import LoginView from '../views/LoginView.vue'
import MainLayout from '../components/layout/MainLayout.vue' // 导入布局组件
import MainLayout from '../components/layout/MainLayout.vue'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'login',
component: LoginView
},
{
path: '/home',
component: MainLayout, // 使用 MainLayout 作为布局
children: [
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '',
name: 'home',
component: () => import('../views/Dashboard.vue') // Dashboard 作为子路由
}
]
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue')
},
],
path: '/',
name: 'login',
component: LoginView,
meta: {
title: '登录',
requiresAuth: false // 不需要认证
}
},
{
path: '/home',
component: MainLayout,
meta: {
requiresAuth: true // 需要认证
},
children: [
{
path: '',
name: 'home',
component: () => import('../views/Dashboard.vue'),
meta: {
title: '首页'
}
},
// 设备监控相关路由
{
path: 'equipment',
name: 'equipment',
component: () => import('../views/equipment/EquipmentView.vue'),
meta: {
title: '设备监控'
}
},
{
path: 'equipment/water-maker',
name: 'water-maker',
component: () => import('../views/equipment/WaterMaker.vue'),
meta: {
title: '制水设备'
}
},
{
path: 'equipment/water-supplier',
name: 'water-supplier',
component: () => import('../views/equipment/WaterSupplier.vue'),
meta: {
title: '供水设备'
}
},
// 工单管理相关路由
{
path: 'work-order',
name: 'work-order',
component: () => import('../views/workorder/WorkOrderView.vue'),
meta: {
title: '工单管理'
}
},
{
path: 'work-order/pending',
name: 'work-order-pending',
component: () => import('../views/workorder/Pending.vue'),
meta: {
title: '待处理工单'
}
},
{
path: 'work-order/timeout',
name: 'work-order-timeout',
component: () => import('../views/workorder/Timeout.vue'),
meta: {
title: '超时工单'
}
},
{
path: 'work-order/processing',
name: 'work-order-processing',
component: () => import('../views/workorder/Processing.vue'),
meta: {
title: '处理中工单'
}
},
{
path: 'work-order/review',
name: 'work-order-review',
component: () => import('../views/workorder/Review.vue'),
meta: {
title: '待审核工单'
}
},
{
path: 'work-order/review/:id',
name: 'ReviewDetail',
component: () => import('../views/workorder/ReviewDetail.vue'),
meta: {
title: '工单审核详情'
}
},
{
path: 'work-order/completed',
name: 'work-order-completed',
component: () => import('../views/workorder/Completed.vue'),
meta: {
title: '已完成工单'
}
},
{
path: '/home/work-order/completed/:id',
name: 'CompletedDetail',
component: () => import('@/views/workorder/CompletedDetail.vue'),
meta: {
title: '结单信息'
}
},
// 人员管理相关路由
{
path: 'personnel/admin',
name: 'personnel-admin',
component: () => import('../views/personnel/Admin.vue'),
meta: {
title: '管理员管理'
}
},
{
path: 'personnel/maintenance',
name: 'personnel-maintenance',
component: () => import('../views/personnel/Maintenance.vue'),
meta: {
title: '运维人员管理'
}
},
{
path: 'personnel/user',
name: 'personnel-user',
component: () => import('../views/personnel/User.vue'),
meta: {
title: '用户管理'
}
},
// 片区相关路由
{
path: 'area/urban',
name: 'area-urban',
component: () => import('../views/area/Urban.vue'),
meta: {
title: '城市片区'
}
},
{
path: 'equipment/water-maker/:id',
name: 'water-maker-detail',
component: () => import('../views/equipment/WaterMakerDetail.vue'),
meta: {
title: '制水设备详情'
}
},
{
path: 'area/campus',
name: 'area-campus',
component: () => import('../views/area/Campus.vue'),
meta: {
title: '校园片区'
}
},
// 个人信息路由
{
path: 'profile',
name: 'profile',
component: () => import('../views/Profile.vue'),
meta: {
title: '个人信息',
requiresAuth: true
}
}
]
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue'),
meta: {
title: '关于',
requiresAuth: false
}
},
/* // 404
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('../views/NotFound.vue'),
meta: {
title: '页面不存在',
requiresAuth: false
}
}*/
]
})
// 路由守卫
router.beforeEach(async (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
// 设置页面标题
const title = to.meta?.title as string || '校园直饮水管理系统'
document.title = title
// 获取认证状态
const authStore = useAuthStore()
// 初始化登录状态
if (!authStore.isLoggedIn) {
authStore.initialize()
}
// 判断是否需要认证
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
// 如果路由需要认证但用户未登录
if (requiresAuth && !authStore.isLoggedIn) {
// 重定向到登录页面,并保存当前想要访问的路径
next({
name: 'login',
query: { redirect: to.fullPath }
})
}
// 如果用户已登录但访问登录页面
else if (to.name === 'login' && authStore.isLoggedIn) {
// 检查是否有重定向路径
const redirect = from.query.redirect as string || '/home'
next(redirect)
}
// 其他情况正常放行
else {
next()
}
})
// 路由后置守卫 - 可在这里添加一些统计或清理工作
router.afterEach((to: RouteLocationNormalized, from: RouteLocationNormalized) => {
// 可以在这里添加页面访问统计等
console.log(`路由跳转: ${from.fullPath} -> ${to.fullPath}`)
})
export default router

@ -1,71 +1,105 @@
// src/stores/auth.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { realLoginApi } from '@/api/auth'
import type { LoginVO } from '@/api/types/auth'
import { authApi } from '@/api/modules/auth'
import type { LoginRequest, LoginVO, ResultVO } from '@/api/types/auth'
interface UserInfo {
id: number
username: string
realName: string
role: string
avatar?: string
id: number
username: string
realName: string
role: string
avatar?: string
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string>('')
const userInfo = ref<UserInfo | null>(null)
const isLoggedIn = ref(false)
const token = ref<string>('')
const userInfo = ref<UserInfo | null>(null)
const isLoggedIn = ref(false)
// 登录方法
const login = async (loginData: { username: string; password: string; rememberMe?: boolean }) => {
try {
const response: LoginVO = await realLoginApi(loginData)
if (response.code === 200) {
token.value = response.data.token
userInfo.value = response.data.userInfo
isLoggedIn.value = true
// 存储到localStorage
localStorage.setItem('token', response.data.token)
localStorage.setItem('userInfo', JSON.stringify(response.data.userInfo))
return response
} else {
throw new Error(response.message || '登录失败')
}
} catch (error: any) {
throw new Error(error.message || '登录失败,请检查网络连接')
// 真实登录接口调用
const login = async (loginData: LoginRequest) => {
try {
// 调用真实后端接口
const response: ResultVO<LoginVO> = await authApi.login(loginData)
// 检查响应状态
if (response.code !== 200) {
throw new Error(response.message || '登录失败')
}
// 保存 token 和用户信息
token.value = response.data.token
userInfo.value = response.data.userInfo
isLoggedIn.value = true
// 存储到 localStorage如果用户选择记住我
if (loginData.rememberMe) {
localStorage.setItem('token', response.data.token)
localStorage.setItem('userInfo', JSON.stringify(response.data.userInfo))
localStorage.setItem('rememberMe', 'true')
} else {
sessionStorage.setItem('token', response.data.token)
sessionStorage.setItem('userInfo', JSON.stringify(response.data.userInfo))
localStorage.removeItem('rememberMe')
}
return response
} catch (error: any) {
console.error('登录失败:', error)
throw error
}
}
// 退出登录
const logout = () => {
// 可以调用后端登出接口
// authApi.logout().catch(console.error)
// 清除本地存储
token.value = ''
userInfo.value = null
isLoggedIn.value = false
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
localStorage.removeItem('rememberMe')
sessionStorage.removeItem('token')
sessionStorage.removeItem('userInfo')
}
}
// 退出登录
const logout = () => {
token.value = ''
userInfo.value = null
isLoggedIn.value = false
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
}
// 初始化时从存储恢复状态
const initialize = () => {
const rememberMe = localStorage.getItem('rememberMe')
const storage = rememberMe ? localStorage : sessionStorage
const savedToken = storage.getItem('token')
const savedUserInfo = storage.getItem('userInfo')
// 初始化时从localStorage恢复状态
const initialize = () => {
const savedToken = localStorage.getItem('token')
const savedUserInfo = localStorage.getItem('userInfo')
if (savedToken && savedUserInfo) {
token.value = savedToken
userInfo.value = JSON.parse(savedUserInfo)
isLoggedIn.value = true
if (savedToken && savedUserInfo) {
try {
token.value = savedToken
userInfo.value = JSON.parse(savedUserInfo)
isLoggedIn.value = true
} catch (e) {
console.error('恢复登录状态失败:', e)
logout()
}
}
}
}
return {
token,
userInfo,
isLoggedIn,
login,
logout,
initialize
}
// 检查是否已登录(用于路由守卫)
const checkAuth = (): boolean => {
return isLoggedIn.value && !!token.value
}
return {
token,
userInfo,
isLoggedIn,
login,
logout,
initialize,
checkAuth,
}
})

@ -7,6 +7,22 @@
</div>
<form class="login-form" @submit.prevent="handleLogin">
<!-- 用户类型选择 -->
<div class="form-group">
<label for="userType">用户类型</label>
<select
id="userType"
v-model="loginForm.userType"
class="form-input"
:disabled="loading"
required
>
<option value="user">普通用户</option>
<option value="admin">管理员</option>
<option value="repairer">维修人员</option>
</select>
</div>
<div class="form-group">
<label for="username">用户名</label>
<input
@ -68,9 +84,11 @@ const debugInfo = ref('')
const loginForm = reactive({
username: '',
password: '',
userType: 'user', //
rememberMe: false
})
// handleLogin
const handleLogin = async () => {
if (!loginForm.username.trim() || !loginForm.password.trim()) {
alert('请输入用户名和密码')
@ -78,27 +96,34 @@ const handleLogin = async () => {
}
loading.value = true
debugInfo.value = '开始登录...'
try {
debugInfo.value = `调用接口: ${import.meta.env.VITE_API_BASE_URL}/api/common/login\n请求数据: ${JSON.stringify(loginForm, null, 2)}`
//
//
await authStore.login({
username: loginForm.username,
password: loginForm.password,
userType: loginForm.userType, //
rememberMe: loginForm.rememberMe
})
debugInfo.value += '\n✅ 登录成功,跳转到首页...'
//
router.push('/home')
//
const redirect = router.currentRoute.value.query.redirect as string
if (redirect) {
router.push(redirect)
} else {
router.push('/home')
}
} catch (error: any) {
console.error('登录失败:', error)
debugInfo.value += `\n❌ 登录失败: ${error.message}`
alert(error.message || '登录失败,请检查用户名和密码')
//
const errorMessage = error.message.includes('Network')
? '网络连接失败,请检查网络设置'
: error.message.includes('401')
? '用户名或密码错误'
: error.message || '登录失败,请稍后重试'
alert(errorMessage)
} finally {
loading.value = false
}
@ -242,4 +267,4 @@ const handleLogin = async () => {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>
</style>

@ -0,0 +1,629 @@
<!-- src/views/profile/Profile.vue -->
<template>
<div class="profile-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>个人信息</h2>
<div class="breadcrumb">校园矿化水平台 / 个人中心 / 个人信息</div>
</div>
<div class="profile-container">
<!-- 左侧头像和基本信息 -->
<div class="profile-sidebar">
<div class="avatar-container">
<img :src="userInfo.avatar || defaultAvatar" alt="用户头像" class="user-avatar">
<button class="change-avatar-btn" @click="triggerAvatarUpload"></button>
<input
type="file"
ref="avatarInput"
class="avatar-input"
accept="image/*"
@change="handleAvatarUpload"
>
</div>
<div class="basic-info">
<div class="info-item">
<span class="label">用户名</span>
<span class="value">{{ userInfo.username }}</span>
</div>
<div class="info-item">
<span class="label">真实姓名</span>
<span class="value">{{ userInfo.realName }}</span>
</div>
<div class="info-item">
<span class="label">身份</span>
<span class="value role-tag">{{ formatRole(userInfo.role) }}</span>
</div>
<div class="info-item">
<span class="label">联系电话</span>
<span class="value">{{ userInfo.phone || '未设置' }}</span>
</div>
<div class="info-item">
<span class="label">账号状态</span>
<span class="value status-tag" :class="userInfo.status">
{{ userInfo.status === 'active' ? '启用' : '禁用' }}
</span>
</div>
<div class="info-item">
<span class="label">最后登录</span>
<span class="value">{{ formatDate(userInfo.lastLoginTime) }}</span>
</div>
</div>
</div>
<!-- 右侧信息编辑和密码修改 -->
<div class="profile-content">
<!-- 个人信息编辑表单 -->
<div class="profile-card">
<div class="card-header">
<h3>编辑个人信息</h3>
</div>
<div class="card-body">
<form @submit.prevent="updateProfile">
<div class="form-row">
<div class="form-item">
<label>真实姓名</label>
<input
type="text"
v-model="profileForm.realName"
placeholder="请输入真实姓名"
required
>
</div>
<div class="form-item">
<label>联系电话</label>
<input
type="tel"
v-model="profileForm.phone"
placeholder="请输入联系电话"
pattern="^1[3-9]\d{9}$"
>
</div>
</div>
<div class="form-row">
<div class="form-item full-width">
<label>备注信息</label>
<textarea
v-model="profileForm.remark"
placeholder="请输入备注信息(选填)"
rows="3"
></textarea>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-save">保存修改</button>
</div>
</form>
</div>
</div>
<!-- 密码修改表单 -->
<div class="profile-card password-card">
<div class="card-header">
<h3>修改密码</h3>
</div>
<div class="card-body">
<form @submit.prevent="changePassword">
<div class="form-row">
<div class="form-item full-width">
<label>当前密码</label>
<input
type="password"
v-model="passwordForm.oldPassword"
placeholder="请输入当前密码"
required
>
</div>
</div>
<div class="form-row">
<div class="form-item full-width">
<label>新密码</label>
<input
type="password"
v-model="passwordForm.newPassword"
placeholder="请输入新密码至少8位包含字母和数字"
pattern="^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$"
required
>
</div>
</div>
<div class="form-row">
<div class="form-item full-width">
<label>确认新密码</label>
<input
type="password"
v-model="passwordForm.confirmPassword"
placeholder="请再次输入新密码"
required
>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-change-pwd">修改密码</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- 操作成功提示 -->
<div class="toast" v-if="showToast">
{{ toastMessage }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
// 使Element Plus
//
const defaultAvatar = 'images/avatar/default-avatar.png'
//
interface UserInfo {
id: string
username: string
realName: string
role: 'student' | 'teacher' | 'visitor' | 'admin' | 'maintenance'
phone?: string
avatar?: string
status: 'active' | 'disabled'
lastLoginTime: string
remark?: string
}
//
const avatarInput = ref<HTMLInputElement | null>(null)
const showToast = ref(false)
const toastMessage = ref('')
//
const userInfo = ref<UserInfo>({
id: '1',
username: 'admin01',
realName: '管理员',
role: 'admin',
phone: '13800138000',
avatar: '',
status: 'active',
lastLoginTime: '2025-12-05 09:23:45',
remark: '系统超级管理员'
})
//
const profileForm = reactive({
realName: '',
phone: '',
remark: ''
})
//
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
//
onMounted(() => {
profileForm.realName = userInfo.value.realName
profileForm.phone = userInfo.value.phone || ''
profileForm.remark = userInfo.value.remark || ''
})
//
const formatRole = (role: string) => {
const roleMap: Record<string, string> = {
student: '学生',
teacher: '老师',
visitor: '游客',
admin: '管理员',
maintenance: '维修人员'
}
return roleMap[role] || '未知身份'
}
//
const formatDate = (dateStr: string) => {
if (!dateStr) return '暂无记录'
return new Date(dateStr).toLocaleString('zh-CN')
}
//
const triggerAvatarUpload = () => {
avatarInput.value?.click()
}
//
const handleAvatarUpload = (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
//
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
showToastMessage('请上传图片格式的文件!')
return
}
if (!isLt2M) {
showToastMessage('头像大小不能超过2MB')
return
}
//
const reader = new FileReader()
reader.onload = (e) => {
userInfo.value.avatar = e.target?.result as string
showToastMessage('头像上传成功!')
}
reader.readAsDataURL(file)
// input便
target.value = ''
}
}
//
const updateProfile = () => {
//
if (!profileForm.realName.trim()) {
showToastMessage('真实姓名不能为空!')
return
}
//
if (profileForm.phone && !/^1[3-9]\d{9}$/.test(profileForm.phone)) {
showToastMessage('请输入正确的手机号码!')
return
}
//
userInfo.value.realName = profileForm.realName
userInfo.value.phone = profileForm.phone
userInfo.value.remark = profileForm.remark
showToastMessage('个人信息修改成功!')
}
//
const changePassword = () => {
//
if (!passwordForm.oldPassword) {
showToastMessage('请输入当前密码!')
return
}
if (passwordForm.newPassword.length < 8) {
showToastMessage('新密码长度不能少于8位')
return
}
if (!/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/.test(passwordForm.newPassword)) {
showToastMessage('新密码必须包含字母和数字!')
return
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
showToastMessage('两次输入的新密码不一致!')
return
}
//
showToastMessage('密码修改成功,请重新登录!')
//
passwordForm.oldPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
}
//
const showToastMessage = (message: string) => {
toastMessage.value = message
showToast.value = true
// 3
setTimeout(() => {
showToast.value = false
}, 3000)
}
</script>
<style scoped>
/* 基础样式 */
.profile-page {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
/* 布局容器 */
.profile-container {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
/* 左侧侧边栏 */
.profile-sidebar {
width: 300px;
flex-shrink: 0;
}
.avatar-container {
text-align: center;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 16px;
}
.user-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 4px solid white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 12px;
}
.change-avatar-btn {
background: #667eea;
color: white;
border: none;
padding: 6px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.change-avatar-btn:hover {
background: #556cd6;
}
.avatar-input {
display: none;
}
.basic-info {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
}
.info-item {
display: flex;
margin-bottom: 12px;
font-size: 14px;
}
.info-item:last-child {
margin-bottom: 0;
}
.label {
width: 80px;
color: #666;
flex-shrink: 0;
}
.value {
color: #333;
flex: 1;
}
/* 角色和状态标签 */
.role-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
background-color: #e6f7ff;
color: #1890ff;
}
.status-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.active {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.disabled {
background-color: #f5f5f5;
color: #8c8c8c;
}
/* 右侧内容区 */
.profile-content {
flex: 1;
min-width: 500px;
}
.profile-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
margin-bottom: 24px;
overflow: hidden;
}
.password-card {
margin-bottom: 0;
}
.card-header {
padding: 16px 20px;
background: #f8f9fa;
border-bottom: 1px solid #f0f0f0;
}
.card-header h3 {
margin: 0;
font-size: 16px;
color: #333;
font-weight: 600;
}
.card-body {
padding: 20px;
}
/* 表单样式 */
.form-row {
display: flex;
gap: 20px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.form-item {
flex: 1;
min-width: 200px;
}
.form-item.full-width {
flex: 1 1 100%;
min-width: 100%;
}
.form-item label {
display: block;
margin-bottom: 8px;
color: #666;
font-size: 14px;
font-weight: 500;
}
.form-item input,
.form-item textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
transition: border-color 0.3s;
}
.form-item input:focus,
.form-item textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.btn-save, .btn-change-pwd {
background: #42b983;
color: white;
border: none;
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-save:hover, .btn-change-pwd:hover {
background: #359e75;
}
.btn-change-pwd {
background: #667eea;
}
.btn-change-pwd:hover {
background: #556cd6;
}
/* 提示框样式 */
.toast {
position: fixed;
top: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 12px 20px;
border-radius: 4px;
z-index: 1000;
animation: fadeIn 0.3s, fadeOut 0.3s 2.7s;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-20px); }
}
/* 响应式调整 */
@media (max-width: 900px) {
.profile-container {
flex-direction: column;
}
.profile-sidebar {
width: 100%;
}
.profile-content {
min-width: 100%;
}
}
@media (max-width: 576px) {
.form-row {
flex-direction: column;
gap: 16px;
}
.form-item {
min-width: 100%;
}
}
</style>

@ -0,0 +1,598 @@
<!-- src/views/area/CampusManagement.vue -->
<template>
<div class="campus-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>校区管理</h2>
<div class="breadcrumb">校园矿化水平台 / 区域管理 / 校区管理</div>
</div>
<!-- 操作按钮区 -->
<div class="action-bar">
<!-- 新增校区按钮 -->
<button class="btn-add" @click="handleAddCampus"></button>
<!-- 校区下拉筛选 -->
<div class="filter-box">
<label>选择校区</label>
<select v-model="selectedCampus" @change="handleCampusChange" class="campus-select">
<option value="">全部校区</option>
<option v-for="campus in campusList" :key="campus.id" :value="campus.id">
{{ campus.name }}
</option>
</select>
</div>
</div>
<!-- 校区表格新增所属市区列 -->
<div class="card">
<table class="campus-table">
<thead>
<tr>
<th>校区</th>
<th>所属市区</th> <!-- 新增列 -->
<th>设备数量</th>
<th>范围</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="campus in filteredCampus" :key="campus.id">
<td>{{ campus.name }}</td>
<td>{{ getAreaName(campus.areaId) }}</td> <!-- 显示所属市区名称 -->
<td>{{ campus.deviceCount }}</td>
<td>{{ campus.range }}</td>
<td class="operation-buttons">
<button
class="btn-delete"
@click="handleDelete(campus.id)"
>
删除
</button>
</td>
</tr>
<tr v-if="filteredCampus.length === 0">
<td colspan="5" class="no-data">暂无校区数据</td> <!-- 列数调整为5 -->
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
<!-- 新增/编辑校区弹窗新增所属市区选择 -->
<div class="modal-mask" v-if="showModal">
<div class="modal-container">
<div class="modal-header">
<h3>{{ isEdit ? '编辑校区' : '新增校区' }}</h3>
<button class="close-btn" @click="showModal = false">×</button>
</div>
<div class="modal-body">
<form @submit.prevent="handleSave">
<div class="form-item">
<label>校区名称</label>
<input
type="text"
v-model="formData.name"
placeholder="请输入校区名称"
required
>
</div>
<div class="form-item">
<label>所属市区</label> <!-- 新增表单项 -->
<select
v-model="formData.areaId"
class="area-select"
required
>
<option value="">请选择所属市区</option>
<option v-for="area in areaList" :key="area.id" :value="area.id">
{{ area.name }}
</option>
</select>
</div>
<div class="form-item">
<label>校区范围</label>
<textarea
v-model="formData.range"
placeholder="请输入校区范围描述"
rows="3"
required
></textarea>
</div>
<div class="form-item">
<label>设备数量</label>
<input
type="number"
v-model="formData.deviceCount"
placeholder="请输入设备数量"
min="0"
required
>
</div>
<div class="form-actions">
<button type="button" class="btn-cancel" @click="showModal = false">取消</button>
<button type="submit" class="btn-submit">保存</button>
</div>
</form>
</div>
</div>
</div>
<!-- 删除确认弹窗 -->
<div class="modal-mask" v-if="showDeleteConfirm">
<div class="modal-container confirm-modal">
<div class="modal-header">
<h3>确认删除</h3>
<button class="close-btn" @click="showDeleteConfirm = false">×</button>
</div>
<div class="modal-body">
<p>确定要删除 "{{ deleteCampusName }}" 校区吗此操作不可撤销</p>
<div class="form-actions">
<button type="button" class="btn-cancel" @click="showDeleteConfirm = false">取消</button>
<button type="button" class="btn-delete-confirm" @click="confirmDelete"></button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
//
interface Area {
id: string
name: string
deviceCount: number
range: string
}
// areaId
interface Campus {
id: string
name: string
areaId: string // ID
deviceCount: number
range: string
}
//
const areaList = ref<Area[]>([
{
id: '1',
name: '市区东区',
deviceCount: 28,
range: '东至东风路,西至解放路,南至人民路,北至建设路'
},
{
id: '2',
name: '市区西区',
deviceCount: 19,
range: '东至解放路,西至滨河路,南至青年路,北至黄河路'
}
])
//
const campusList = ref<Campus[]>([
{
id: '1',
name: '主校区',
areaId: '1', //
deviceCount: 89,
range: '包含教学区、生活区、运动区,覆盖整个主校区范围'
},
{
id: '2',
name: '分校区',
areaId: '2', // 西
deviceCount: 45,
range: '包含新教学楼、实训楼、学生公寓1-5号楼'
},
{
id: '3',
name: '南校区',
areaId: '1', //
deviceCount: 67,
range: '包含研究生院、国际交流中心、留学生公寓'
}
])
//
const selectedCampus = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const showModal = ref(false)
const isEdit = ref(false)
const showDeleteConfirm = ref(false)
const deleteCampusId = ref('')
const deleteCampusName = ref('')
// areaId
const formData = ref<Campus>({
id: '',
name: '',
areaId: '',
deviceCount: 0,
range: ''
})
// ID
const getAreaName = (areaId: string) => {
const area = areaList.value.find(item => item.id === areaId)
return area ? area.name : '未指定'
}
//
const filteredCampus = computed(() => {
let filtered = [...campusList.value]
//
if (selectedCampus.value) {
filtered = filtered.filter(campus => campus.id === selectedCampus.value)
}
//
const startIndex = (currentPage.value - 1) * pageSize.value
const endIndex = startIndex + pageSize.value
return filtered.slice(startIndex, endIndex)
})
//
const totalPages = computed(() => {
const filteredCount = selectedCampus.value
? campusList.value.filter(campus => campus.id === selectedCampus.value).length
: campusList.value.length
return Math.ceil(filteredCount / pageSize.value)
})
//
const handleCampusChange = () => {
currentPage.value = 1 //
}
//
const handleAddCampus = () => {
isEdit.value = false
//
formData.value = {
id: '',
name: '',
areaId: '',
deviceCount: 0,
range: ''
}
showModal.value = true
}
//
const handleDelete = (id: string) => {
const campus = campusList.value.find(item => item.id === id)
if (campus) {
deleteCampusId.value = id
deleteCampusName.value = campus.name
showDeleteConfirm.value = true
}
}
//
const confirmDelete = () => {
campusList.value = campusList.value.filter(campus => campus.id !== deleteCampusId.value)
showDeleteConfirm.value = false
//
if (selectedCampus.value === deleteCampusId.value) {
selectedCampus.value = ''
}
}
//
const handleSave = () => {
if (isEdit.value) {
//
const index = campusList.value.findIndex(item => item.id === formData.value.id)
if (index !== -1) {
campusList.value[index] = { ...formData.value }
}
} else {
//
const newCampus: Campus = {
...formData.value,
id: Date.now().toString() // ID
}
campusList.value.unshift(newCampus)
}
showModal.value = false
}
</script>
<style scoped>
/* 基础样式 - 与市区管理页面完全一致 */
.campus-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 16px;
}
.btn-add {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-add:hover {
background: #359e75;
}
.filter-box {
display: flex;
align-items: center;
gap: 8px;
color: #666;
}
.campus-select, .area-select { /* 新增area-select样式 */
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
min-width: 200px;
font-size: 14px;
}
/* 表格样式 */
.campus-table {
width: 100%;
border-collapse: collapse;
}
.campus-table th,
.campus-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.campus-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.campus-table tbody tr:hover {
background-color: #f8f9fa;
}
.operation-buttons {
display: flex;
gap: 8px;
}
.btn-delete {
background-color: #ffebe6;
color: #cf1322;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
}
.btn-delete:hover {
opacity: 0.9;
}
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
/* 分页样式 */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 弹窗样式 */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-container {
background: white;
border-radius: 8px;
width: 500px;
max-width: 90%;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.confirm-modal {
width: 400px;
}
.modal-header {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
color: #333;
}
.close-btn {
background: transparent;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #666;
}
.modal-body {
padding: 16px;
}
.form-item {
margin-bottom: 16px;
}
.form-item label {
display: block;
margin-bottom: 8px;
color: #666;
font-size: 14px;
}
.form-item input,
.form-item textarea,
.form-item select { /* 新增select样式 */
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
}
.btn-cancel {
background: #f5f5f5;
color: #666;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-submit {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-delete-confirm {
background: #cf1322;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
/* 响应式调整 */
@media (max-width: 768px) {
.action-bar {
flex-direction: column;
align-items: flex-start;
}
.filter-box {
width: 100%;
}
.campus-select, .area-select {
width: 100%;
}
}
</style>

@ -0,0 +1,553 @@
<!-- src/views/area/AreaManagement.vue -->
<template>
<div class="area-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>片区管理</h2>
<div class="breadcrumb">校园矿化水平台 / 区域管理 / 片区管理</div>
</div>
<!-- 操作按钮区 -->
<div class="action-bar">
<!-- 新增片区按钮 -->
<button class="btn-add" @click="handleAddArea"></button>
<!-- 片区下拉筛选 -->
<div class="filter-box">
<label>选择片区</label>
<select v-model="selectedArea" @change="handleAreaChange" class="area-select">
<option value="">全部片区</option>
<!-- 核心修改移除下拉选项中的设备数量展示仅保留片区名称 -->
<option v-for="area in areaList" :key="area.id" :value="area.id">
{{ area.name }}
</option>
</select>
</div>
</div>
<!-- 片区表格 -->
<div class="card">
<table class="area-table">
<thead>
<tr>
<th>片区</th>
<th>设备数量</th>
<th>范围</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="area in filteredAreas" :key="area.id">
<td>{{ area.name }}</td>
<td>{{ area.deviceCount }}</td>
<td>{{ area.range }}</td>
<td class="operation-buttons">
<button
class="btn-delete"
@click="handleDelete(area.id)"
>
删除
</button>
</td>
</tr>
<tr v-if="filteredAreas.length === 0">
<td colspan="4" class="no-data">暂无片区数据</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
<!-- 新增/编辑片区弹窗 -->
<div class="modal-mask" v-if="showModal">
<div class="modal-container">
<div class="modal-header">
<h3>{{ isEdit ? '编辑片区' : '新增片区' }}</h3>
<button class="close-btn" @click="showModal = false">×</button>
</div>
<div class="modal-body">
<form @submit.prevent="handleSave">
<div class="form-item">
<label>片区名称</label>
<input
type="text"
v-model="formData.name"
placeholder="请输入片区名称"
required
>
</div>
<div class="form-item">
<label>片区范围</label>
<textarea
v-model="formData.range"
placeholder="请输入片区范围描述"
rows="3"
required
></textarea>
</div>
<div class="form-item">
<label>设备数量</label>
<input
type="number"
v-model="formData.deviceCount"
placeholder="请输入设备数量"
min="0"
required
>
</div>
<div class="form-actions">
<button type="button" class="btn-cancel" @click="showModal = false">取消</button>
<button type="submit" class="btn-submit">保存</button>
</div>
</form>
</div>
</div>
</div>
<!-- 删除确认弹窗 -->
<div class="modal-mask" v-if="showDeleteConfirm">
<div class="modal-container confirm-modal">
<div class="modal-header">
<h3>确认删除</h3>
<button class="close-btn" @click="showDeleteConfirm = false">×</button>
</div>
<div class="modal-body">
<p>确定要删除 "{{ deleteAreaName }}" 片区吗此操作不可撤销</p>
<div class="form-actions">
<button type="button" class="btn-cancel" @click="showDeleteConfirm = false">取消</button>
<button type="button" class="btn-delete-confirm" @click="confirmDelete"></button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
//
interface Area {
id: string
name: string
deviceCount: number
range: string
}
//
const areaList = ref<Area[]>([
{
id: '1',
name: '市区东区',
deviceCount: 28,
range: '东至东风路,西至解放路,南至人民路,北至建设路'
},
{
id: '2',
name: '市区西区',
deviceCount: 19,
range: '东至解放路,西至滨河路,南至青年路,北至黄河路'
},
{
id: '3',
name: '校区南区',
deviceCount: 45,
range: '大学校园内南部区域包含教学楼、图书馆、学生宿舍1-8号楼'
},
{
id: '4',
name: '校区北区',
deviceCount: 32,
range: '大学校园内北部区域包含实验楼、体育馆、学生宿舍9-16号楼'
}
])
//
const selectedArea = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const showModal = ref(false)
const isEdit = ref(false)
const showDeleteConfirm = ref(false)
const deleteAreaId = ref('')
const deleteAreaName = ref('')
//
const formData = ref<Area>({
id: '',
name: '',
deviceCount: 0,
range: ''
})
//
const filteredAreas = computed(() => {
let filtered = [...areaList.value]
//
if (selectedArea.value) {
filtered = filtered.filter(area => area.id === selectedArea.value)
}
//
const startIndex = (currentPage.value - 1) * pageSize.value
const endIndex = startIndex + pageSize.value
return filtered.slice(startIndex, endIndex)
})
//
const totalPages = computed(() => {
const filteredCount = selectedArea.value
? areaList.value.filter(area => area.id === selectedArea.value).length
: areaList.value.length
return Math.ceil(filteredCount / pageSize.value)
})
//
const handleAreaChange = () => {
currentPage.value = 1 //
}
//
const handleAddArea = () => {
isEdit.value = false
//
formData.value = {
id: '',
name: '',
deviceCount: 0,
range: ''
}
showModal.value = true
}
//
const handleDelete = (id: string) => {
const area = areaList.value.find(item => item.id === id)
if (area) {
deleteAreaId.value = id
deleteAreaName.value = area.name
showDeleteConfirm.value = true
}
}
//
const confirmDelete = () => {
areaList.value = areaList.value.filter(area => area.id !== deleteAreaId.value)
showDeleteConfirm.value = false
//
if (selectedArea.value === deleteAreaId.value) {
selectedArea.value = ''
}
}
//
const handleSave = () => {
if (isEdit.value) {
//
const index = areaList.value.findIndex(item => item.id === formData.value.id)
if (index !== -1) {
areaList.value[index] = { ...formData.value }
}
} else {
//
const newArea: Area = {
...formData.value,
id: Date.now().toString() // ID
}
areaList.value.unshift(newArea)
}
showModal.value = false
}
</script>
<style scoped>
/* 基础样式 - 与人员管理页面保持一致 */
.area-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 16px;
}
.btn-add {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-add:hover {
background: #359e75;
}
.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: 200px;
font-size: 14px;
}
/* 表格样式 */
.area-table {
width: 100%;
border-collapse: collapse;
}
.area-table th,
.area-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.area-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.area-table tbody tr:hover {
background-color: #f8f9fa;
}
.operation-buttons {
display: flex;
gap: 8px;
}
.btn-delete {
background-color: #ffebe6;
color: #cf1322;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
}
.btn-delete:hover {
opacity: 0.9;
}
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
/* 分页样式 */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 弹窗样式 */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-container {
background: white;
border-radius: 8px;
width: 500px;
max-width: 90%;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.confirm-modal {
width: 400px;
}
.modal-header {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
color: #333;
}
.close-btn {
background: transparent;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #666;
}
.modal-body {
padding: 16px;
}
.form-item {
margin-bottom: 16px;
}
.form-item label {
display: block;
margin-bottom: 8px;
color: #666;
font-size: 14px;
}
.form-item input,
.form-item textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
}
.btn-cancel {
background: #f5f5f5;
color: #666;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-submit {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-delete-confirm {
background: #cf1322;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
/* 响应式调整 */
@media (max-width: 768px) {
.action-bar {
flex-direction: column;
align-items: flex-start;
}
.filter-box {
width: 100%;
}
.area-select {
width: 100%;
}
}
</style>

@ -0,0 +1,7 @@
<!-- src/views/equipment/EquipmentView.vue -->
<template>
<div class="page-container">
<h1>设备监控</h1>
<p>请选择左侧子菜单查看具体设备信息</p>
</div>
</template>

@ -0,0 +1,804 @@
<!-- src/views/equipment/WaterMaker.vue -->
<template>
<div class="water-maker-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>制水机管理</h2>
<div class="breadcrumb">校园矿化水平台 / 设备监控 / 制水机</div>
</div>
<!-- 操作按钮区 -->
<div class="action-bar">
<button class="btn-add" @click="showAddModal = true">添加制水机</button>
<div class="filters">
<!-- 搜索框 -->
<div class="search-box">
<input
type="text"
placeholder="搜索设备ID或位置..."
v-model="searchKeyword"
@input="handleSearch"
>
<button class="search-btn" @click="handleSearch"></button>
</div>
<!-- 片区筛选 -->
<select
v-model="selectedArea"
class="filter-select"
@change="handleSearch"
>
<option value="">全部片区</option>
<option value="市区">市区</option>
<option value="校区">校区</option>
</select>
<!-- 状态筛选 -->
<select
v-model="selectedStatus"
class="filter-select"
@change="handleSearch"
>
<option value="">全部状态</option>
<option value="online">在线</option>
<option value="offline">离线</option>
<option value="warning">警告</option>
<option value="fault">故障</option>
</select>
</div>
</div>
<!-- 设备表格 - 新增设备机型列 -->
<div class="card">
<table class="equipment-table">
<thead>
<tr>
<th>设备ID</th>
<th>设备机型</th> <!-- 新增机型列 -->
<th>所属片区</th>
<th>详细位置</th>
<th>状态</th>
<th>最后上传时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="device in paginatedDevices" :key="device.deviceId">
<td>{{ device.deviceId }}</td>
<td>{{ device.deviceType === 'WATER_MAKER' ? '制水机' : device.deviceType }}</td>
<td>{{ device.areaId }}</td>
<td>{{ device.installLocation }}</td>
<td>
<span :class="`status-tag ${device.status}`">
{{ formatStatus(device.status) }}
</span>
</td>
<td>{{ formatDate(device.lastHeartbeatTime) }}</td>
<td class="operation-buttons">
<button class="btn-view" @click="viewDevice(device.deviceId)"></button>
<button
class="btn-online"
@click="updateDeviceStatus(device.deviceId, 'online')"
:disabled="device.status === 'online'"
>
设为在线
</button>
<button
class="btn-offline"
@click="showOfflineModal(device.deviceId)"
:disabled="device.status === 'offline'"
>
设为离线
</button>
<button
class="btn-fault"
@click="showFaultModalFunc(device.deviceId)"
:disabled="device.status === 'fault'"
>
设为故障
</button>
</td>
</tr>
<tr v-if="paginatedDevices.length === 0">
<td colspan="7" class="no-data">暂无设备数据</td> <!-- colspan从6改为7 -->
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }} ( {{ filteredDevices.length }} 条记录)
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
<!-- 添加设备模态框 -->
<div v-if="showAddModal" class="modal-overlay" @click="showAddModal = false">
<div class="modal-content" @click.stop>
<h3>添加制水机</h3>
<form @submit.prevent="addDevice">
<div class="form-group">
<label>设备ID:</label>
<input v-model="newDevice.deviceId" type="text" required>
</div>
<div class="form-group">
<label>设备名称:</label>
<input v-model="newDevice.deviceName" type="text" required>
</div>
<div class="form-group">
<label>所属片区:</label>
<select v-model="newDevice.areaId" required>
<option value="市区">市区</option>
<option value="校区">校区</option>
</select>
</div>
<div class="form-group">
<label>安装位置:</label>
<input v-model="newDevice.installLocation" type="text" required>
</div>
<div class="form-actions">
<button type="button" @click="showAddModal = false">取消</button>
<button type="submit">添加</button>
</div>
</form>
</div>
</div>
<!-- 离线原因模态框 -->
<div v-if="showOfflineReasonModal" class="modal-overlay" @click="showOfflineReasonModal = false">
<div class="modal-content" @click.stop>
<h3>设置离线原因</h3>
<form @submit.prevent="confirmOffline">
<div class="form-group">
<label>离线原因:</label>
<textarea v-model="offlineReason" placeholder="请输入离线原因"></textarea>
</div>
<div class="form-actions">
<button type="button" @click="showOfflineReasonModal = false">取消</button>
<button type="submit">确认</button>
</div>
</form>
</div>
</div>
<!-- 故障信息模态框 -->
<div v-if="showFaultModal" class="modal-overlay" @click="showFaultModal = false">
<div class="modal-content" @click.stop>
<h3>设置故障信息</h3>
<form @submit.prevent="confirmFault">
<div class="form-group">
<label>故障类型:</label>
<input v-model="faultInfo.faultType" type="text" placeholder="请输入故障类型" required>
</div>
<div class="form-group">
<label>故障描述:</label>
<textarea v-model="faultInfo.description" placeholder="请输入故障描述" required></textarea>
</div>
<div class="form-actions">
<button type="button" @click="showFaultModal = false">取消</button>
<button type="submit">确认</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { DeviceStatusApi } from '@/api/deviceStatus'
//
type DeviceStatus = 'online' | 'offline' | 'fault'
//
interface WaterMakerDevice {
deviceId: string
deviceName: string
deviceType: string
areaId: string
installLocation: string
status: DeviceStatus
lastHeartbeatTime?: string
createTime?: string
}
//
const devices = ref<WaterMakerDevice[]>([])
const searchKeyword = ref('')
const selectedArea = ref('') //
const selectedStatus = ref('') //
const currentPage = ref(1)
const pageSize = 10 //
const router = useRouter()
//
const showAddModal = ref(false)
const showOfflineReasonModal = ref(false)
const showFaultModal = ref(false)
// ID
const currentDeviceId = ref('')
//
const newDevice = ref({
deviceId: '',
deviceName: '',
areaId: '市区',
installLocation: '',
deviceType: 'WATER_MAKER'
})
const offlineReason = ref('')
const faultInfo = ref({
faultType: '',
description: ''
})
//
// - loadDevices
// loadDevices deviceType
const loadDevices = async (): Promise<WaterMakerDevice[]> => {
try {
const statuses = ['online', 'offline', 'fault']
const allDevices: WaterMakerDevice[] = []
for (const status of statuses) {
// deviceType
const result = await DeviceStatusApi.getDevicesByStatus(status, undefined, 'water_maker')
if (result.code === 200 && result.data && Array.isArray(result.data)) {
allDevices.push(...result.data.map((item: any) => ({
deviceId: item.deviceId,
deviceName: item.deviceName,
deviceType: item.deviceType,
areaId: item.areaId,
installLocation: item.installLocation,
status: item.status,
lastHeartbeatTime: item.lastHeartbeatTime
})))
}
}
devices.value = allDevices
return allDevices
} catch (error) {
console.error('加载设备数据失败:', error)
return []
}
}
//
const filteredDevices = computed(() => {
return devices.value.filter(device => {
const keywordMatch = searchKeyword.value.trim() === '' ||
device.deviceId.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
device.installLocation.toLowerCase().includes(searchKeyword.value.toLowerCase())
const areaMatch = selectedArea.value === '' || device.areaId === selectedArea.value
const statusMatch = selectedStatus.value === '' || device.status === selectedStatus.value
return keywordMatch && areaMatch && statusMatch
})
})
//
const paginatedDevices = computed(() => {
const start = (currentPage.value - 1) * pageSize
const end = start + pageSize
return filteredDevices.value.slice(start, end)
})
const totalPages = computed(() => {
return Math.ceil(filteredDevices.value.length / pageSize)
})
//
// - script setup
const formatStatus = (status: DeviceStatus): string => {
const statusMap: Record<string, string> = {
online: '在线',
offline: '离线',
fault: '故障'
}
return statusMap[status] || status
}
//
const formatDate = (dateString?: string): string => {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleString('zh-CN')
}
//
const handleSearch = () => {
currentPage.value = 1 //
}
//
const viewDevice = (id: string) => {
router.push(`/home/equipment/water-maker/${id}`)
}
// 线
const showOfflineModal = (deviceId: string) => {
currentDeviceId.value = deviceId
showOfflineReasonModal.value = true
}
//
const showFaultModalFunc = (deviceId: string) => {
currentDeviceId.value = deviceId
showFaultModal.value = true
}
// 线
const confirmOffline = async () => {
try {
const result = await DeviceStatusApi.markDeviceOffline(
currentDeviceId.value,
offlineReason.value
)
if (result.code === 200) {
const device = devices.value.find(d => d.deviceId === currentDeviceId.value)
if (device) {
device.status = 'offline'
}
showOfflineReasonModal.value = false
offlineReason.value = ''
}
} catch (error) {
console.error('设置设备离线失败:', error)
}
}
//
const confirmFault = async () => {
try {
const result = await DeviceStatusApi.markDeviceFault(
currentDeviceId.value,
faultInfo.value.faultType,
faultInfo.value.description
)
if (result.code === 200) {
const device = devices.value.find(d => d.deviceId === currentDeviceId.value)
if (device) {
device.status = 'fault'
}
showFaultModal.value = false
faultInfo.value = { faultType: '', description: '' }
}
} catch (error) {
console.error('设置设备故障失败:', error)
}
}
//
// - updateDeviceStatus
const updateDeviceStatus = async (deviceId: string, status: string, remark: string = '') => {
try {
let result: any;
switch (status) {
case 'online':
result = await DeviceStatusApi.markDeviceOnline(deviceId)
break
case 'offline':
result = await DeviceStatusApi.markDeviceOffline(deviceId, remark)
break
case 'fault':
//
result = await DeviceStatusApi.markDeviceFault(deviceId, 'MANUAL_FAULT', remark || '手动设置故障')
break
default:
throw new Error('不支持的状态类型')
}
if (result.code === 200) {
//
const device = devices.value.find(d => d.deviceId === deviceId)
if (device) {
device.status = status as DeviceStatus
}
return true
} else {
throw new Error(result.message || '操作失败')
}
} catch (error) {
console.error('更新设备状态失败:', error)
throw error
}
}
//
const addDevice = async () => {
try {
//
const deviceToAdd = {
deviceId: newDevice.value.deviceId,
deviceName: newDevice.value.deviceName,
areaId: newDevice.value.areaId,
installLocation: newDevice.value.installLocation,
deviceType: newDevice.value.deviceType
};
// API
const result = await fetch('/api/web/device/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(deviceToAdd)
}).then(response => response.json());
if (result.code === 200) {
//
await loadDevices();
//
showAddModal.value = false;
newDevice.value = {
deviceId: '',
deviceName: '',
areaId: '市区',
installLocation: '',
deviceType: 'WATER_MAKER'
};
console.log('设备添加成功');
} else {
console.error('设备添加失败:', result.message);
}
} catch (error) {
console.error('添加设备失败:', error);
}
};
//
// onMounted
onMounted(async () => {
console.log('🚀 开始加载设备数据...')
try {
const result = await loadDevices()
console.log('🌐 API返回 data:', result)
if (result.length === 0) {
console.warn('⚠️ 数据库中无设备数据')
} else {
console.log('✅ 成功加载设备数据:', result)
}
} catch (error) {
console.error('❌ 加载设备数据失败:', error)
}
})
</script>
<style scoped>
/* 样式与供水机页面保持一致 */
.water-maker-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 16px;
}
.btn-add {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-add:hover {
background: #359e75;
}
.filters {
display: flex;
gap: 12px;
align-items: center;
}
.search-box {
display: flex;
gap: 8px;
}
.search-box input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 240px;
}
.search-btn {
background: #667eea;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.filter-select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.equipment-table {
width: 100%;
border-collapse: collapse;
}
.equipment-table th,
.equipment-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.equipment-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.equipment-table tbody tr:hover {
background-color: #f8f9fa;
}
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.online {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.offline {
background-color: #f5f5f5;
color: #8c8c8c;
}
.status-tag.warning {
background-color: #fff7e6;
color: #d48806;
}
.status-tag.fault {
background-color: #ffebe6;
color: #cf1322;
}
.operation-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.operation-buttons button {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
}
.operation-buttons button:hover:not(:disabled) {
opacity: 0.9;
}
.operation-buttons button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-view {
background-color: #e6f7ff;
color: #1890ff;
}
.btn-online {
background-color: #e6f7ee;
color: #00875a;
}
.btn-offline {
background-color: #f5f5f5;
color: #8c8c8c;
}
.btn-fault {
background-color: #ffebe6;
color: #cf1322;
}
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 24px;
border-radius: 8px;
min-width: 400px;
max-width: 500px;
}
.modal-content h3 {
margin-top: 0;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: 500;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
.form-group textarea {
min-height: 80px;
resize: vertical;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.form-actions button {
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
border: 1px solid #ddd;
}
.form-actions button[type="button"] {
background: #f5f5f5;
}
.form-actions button[type="submit"] {
background: #42b983;
color: white;
border: none;
}
/* 响应式调整 */
@media (max-width: 768px) {
.filters {
flex-direction: column;
width: 100%;
}
.search-box, .filter-select {
width: 100%;
}
.modal-content {
width: 90%;
min-width: auto;
}
}
</style>

@ -0,0 +1,804 @@
<!-- src/views/equipment/WaterMakerDetail.vue -->
<template>
<div class="water-machine-detail">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>制水机设备详情</h2>
<div class="breadcrumb">校园矿化水平台 / 设备管理 / 制水机 / 设备详情</div>
</div>
<!-- 设备基本信息卡片 -->
<div class="card device-info-card">
<div class="info-header">
<h3 class="card-title">设备基本信息</h3>
<button class="btn-refresh" @click="refreshData">
<i class="refresh-icon"></i> 刷新数据
</button>
</div>
<div class="info-grid">
<div class="info-item">
<span class="info-label">设备ID</span>
<span class="info-value">{{ machineInfo.deviceId }}</span>
</div>
<div class="info-item">
<span class="info-label">设备型号</span>
<span class="info-value">{{ machineInfo.model }}</span>
</div>
<div class="info-item">
<span class="info-label">所属片区</span>
<span class="info-value">{{ machineInfo.area }}</span>
</div>
<div class="info-item">
<span class="info-label">安装位置</span>
<span class="info-value">{{ machineInfo.location }}</span>
</div>
<div class="info-item">
<span class="info-label">安装日期</span>
<span class="info-value">{{ formatDate(machineInfo.installDate) }}</span>
</div>
<div class="info-item">
<span class="info-label">运行状态</span>
<span class="info-value status-tag" :class="machineInfo.status">{{ formatStatus(machineInfo.status) }}</span>
</div>
<div class="info-item">
<span class="info-label">最后在线时间</span>
<span class="info-value">{{ formatDate(machineInfo.lastOnlineTime) }}</span>
</div>
</div>
</div>
<!-- 设备状态和数据 -->
<div class="left-column">
<!-- 实时监测数据 -->
<div class="card">
<h3 class="card-title">实时监测数据</h3>
<div class="realtime-data">
<!-- 自来水TDS -->
<div class="data-item">
<div class="data-label">自来水TDS</div>
<div class="data-value">{{ realtimeData.tapWaterTds }} ppm</div>
<div class="data-status" :class="getTdsStatus(realtimeData.tapWaterTds, 'tap')"></div>
</div>
<!-- 纯净水TDS -->
<div class="data-item">
<div class="data-label">纯净水TDS</div>
<div class="data-value">{{ realtimeData.pureWaterTds }} ppm</div>
<div class="data-status" :class="getTdsStatus(realtimeData.pureWaterTds, 'pure')"></div>
</div>
<div class="data-item">
<div class="data-label">水温</div>
<div class="data-value">{{ realtimeData.temperature }} °C</div>
</div>
<div class="data-item">
<div class="data-label">出水压力</div>
<div class="data-value">{{ realtimeData.pressure }} MPa</div>
</div>
<div class="data-item">
<div class="data-label">流量计1</div>
<div class="data-value">{{ realtimeData.flow1 }} L/min</div>
</div>
<div class="data-item">
<div class="data-label">流量计2</div>
<div class="data-value">{{ realtimeData.flow2 }} L/min</div>
</div>
</div>
</div>
<!-- 滤芯状态 -->
<div class="card">
<h3 class="card-title">滤芯状态</h3>
<div class="filter-status">
<div class="filter-item" v-for="filter in filterStatus" :key="filter.id">
<div class="filter-name">{{ filter.name }}</div>
<div class="filter-progress">
<div class="progress-bar" :style="{ width: filter.usage + '%' }" :class="getFilterStatusClass(filter.usage)"></div>
</div>
<div class="filter-info">
<span class="usage">{{ filter.usage }}%</span>
<span class="remaining">剩余{{ filter.remainingDays }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 历史数据记录 -->
<div class="card">
<div class="table-header">
<h3 class="card-title">历史数据记录</h3>
<div class="table-filter">
<label>日期筛选</label>
<input type="date" v-model="historyDate" @change="fetchHistoryData">
</div>
</div>
<table class="history-table">
<thead>
<tr>
<th>日期</th>
<th>自来水TDS平均值 (ppm)</th>
<th>纯净水TDS平均值 (ppm)</th>
<th>矿化水TDS平均值 (ppm)</th>
</tr>
</thead>
<tbody>
<tr v-if="historyData.length > 0">
<td>{{ historyData[0]?.date }}</td>
<td>{{ historyData[0]?.tapWaterTdsAvg }}</td>
<td>{{ historyData[0]?.pureWaterTdsAvg }}</td>
<td>{{ historyData[0]?.mineralWaterTdsAvg }}</td>
</tr>
<tr v-if="historyData.length === 0">
<td colspan="4" class="no-data">暂无历史数据记录</td>
</tr>
</tbody>
</table>
</div>
<!-- 维护记录 -->
<div class="card">
<h3 class="card-title">维护记录</h3>
<table class="maintenance-table">
<thead>
<tr>
<th>工单编号</th>
<th>维护类型</th>
<th>维护人员</th>
<th>维护时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="record in maintenanceRecords" :key="record.orderNo">
<td>{{ record.orderNo }}</td>
<td>{{ record.maintenanceType }}</td>
<td>{{ record.maintainer }}</td>
<td>{{ formatDate(record.maintenanceTime) }}</td>
<td><span class="status-tag small" :class="record.status">{{ formatStatus(record.status) }}</span></td>
<td>
<button class="btn-view" @click="viewMaintenanceDetail(record.orderNo)"></button>
</td>
</tr>
<tr v-if="maintenanceRecords.length === 0">
<td colspan="6" class="no-data">暂无维护记录</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { WaterMakerApi } from '@/api/waterMaker'
//
type DeviceStatus = 'online' | 'offline' | 'warning' | 'error'
//
interface MachineInfo {
deviceId: string
model: string
area: string
location: string
installDate: string
status: DeviceStatus
lastOnlineTime: string
}
//
interface RealtimeData {
tapWaterTds: number // TDS
pureWaterTds: number // TDS
temperature: number
pressure: number
flow1: number
flow2: number
updateTime: string
}
//
interface FilterStatus {
id: string
name: string
usage: number
remainingDays: number
}
//
interface HistoryRecord {
date: string //
tapWaterTdsAvg: number // TDS
pureWaterTdsAvg: number // TDS
mineralWaterTdsAvg: number // TDS
}
//
interface MaintenanceRecord {
orderNo: string
maintenanceType: string
maintainer: string
maintenanceTime: string
status: 'completed' | 'processing' | 'pending'
}
//
const route = useRoute()
const router = useRouter()
const historyDate = ref('')
// deviceIdstring
const deviceId: string = (() => {
const paramId = route.params.id as string | undefined
return paramId ?? 'WM-2023-001'
})()
//
const machineInfo = ref<MachineInfo>({
deviceId: deviceId,
model: 'WM-RO-500G',
area: '校区',
location: '第一教学楼一楼大厅',
installDate: '2023-06-15',
status: 'online',
lastOnlineTime: new Date().toISOString()
})
//
const realtimeData = ref<RealtimeData>({
tapWaterTds: 186, // TDS
pureWaterTds: 12, // TDS
temperature: 25.6,
pressure: 0.45,
flow1: 1.2,
flow2: 8.1,
updateTime: new Date().toISOString()
})
//
const filterStatus = ref<FilterStatus[]>([
{ id: 'f1', name: '滤芯1', usage: 65, remainingDays: 45 },
{ id: 'f2', name: '滤芯2', usage: 42, remainingDays: 90 },
{ id: 'f3', name: '滤芯3', usage: 30, remainingDays: 120 }
])
//
const historyData = ref<HistoryRecord[]>([])
//
const maintenanceRecords = ref<MaintenanceRecord[]>([
{
orderNo: 'ORD-20231015-002',
maintenanceType: '滤芯更换',
maintainer: '张三',
maintenanceTime: '2023-10-15 14:30:00',
status: 'completed'
},
{
orderNo: 'ORD-20230920-015',
maintenanceType: '常规检修',
maintainer: '李四',
maintenanceTime: '2023-09-20 09:15:00',
status: 'completed'
}
])
//
onMounted(async () => {
//
const today = new Date().toISOString().substring(0, 10)
historyDate.value = today
//
await Promise.all([
loadDeviceBasicInfo(),
loadRealtimeData(),
loadFilterStatus(),
fetchHistoryData(),
loadMaintenanceRecords()
])
})
//
const loadDeviceBasicInfo = async () => {
try {
const result = await WaterMakerApi.getDeviceById(deviceId)
if (result.code === 200) {
const data = result.data
machineInfo.value = {
deviceId: data.deviceId,
model: data.model || '未知型号',
area: data.areaId,
location: data.installLocation,
installDate: data.installDate,
status: data.status,
lastOnlineTime: data.lastHeartbeatTime
}
}
} catch (error) {
console.error('加载设备基本信息失败:', error)
}
}
//
const loadRealtimeData = async () => {
try {
const result = await WaterMakerApi.getRealtimeData(deviceId)
if (result.code === 200) {
const data = result.data
realtimeData.value = {
tapWaterTds: data.rawWaterTds || 0,
pureWaterTds: data.pureWaterTds || 0,
temperature: data.temperature || 0,
pressure: data.waterPressure || 0,
flow1: data.flowRate1 || 0,
flow2: data.flowRate2 || 0,
updateTime: new Date().toISOString()
}
}
} catch (error) {
console.error('加载实时数据失败:', error)
}
}
//
const loadFilterStatus = async () => {
try {
const result = await WaterMakerApi.getFilterStatus(deviceId)
if (result.code === 200) {
filterStatus.value = result.data.map((item: any) => ({
id: item.filterId,
name: item.filterName,
usage: item.usagePercentage,
remainingDays: item.remainingDays
}))
}
} catch (error) {
console.error('加载滤芯状态失败:', error)
}
}
//
const fetchHistoryData = async () => {
try {
const result = await WaterMakerApi.getHistoryData(deviceId, historyDate.value)
if (result.code === 200) {
historyData.value = result.data.map((item: any) => ({
date: item.statDate,
tapWaterTdsAvg: item.rawWaterTdsAvg,
pureWaterTdsAvg: item.pureWaterTdsAvg,
mineralWaterTdsAvg: item.mineralWaterTdsAvg
}))
}
} catch (error) {
console.error('获取历史数据失败:', error)
}
}
//
const loadMaintenanceRecords = async () => {
try {
const result = await WaterMakerApi.getMaintenanceRecords(deviceId)
if (result.code === 200) {
maintenanceRecords.value = result.data.map((item: any) => ({
orderNo: item.orderNo,
maintenanceType: item.type,
maintainer: item.maintainer,
maintenanceTime: item.maintenanceTime,
status: item.status
}))
}
} catch (error) {
console.error('加载维护记录失败:', error)
}
}
//
const formatStatus = (status: string): string => {
const statusMap: Record<string, string> = {
online: '在线',
offline: '离线',
warning: '警告',
error: '故障',
completed: '已完成',
processing: '处理中',
pending: '待处理'
}
return statusMap[status] || status
}
//
const formatDate = (dateString?: string): string => {
if (!dateString) return '-'
try {
const date = new Date(dateString)
return isNaN(date.getTime()) ? '-' : date.toLocaleString('zh-CN')
} catch {
return '-'
}
}
// TDS
const getTdsStatus = (tds: number, type: 'tap' | 'pure'): string => {
if (type === 'pure') {
// TDS
if (tds < 10) return 'excellent'
if (tds < 20) return 'good'
return 'warning'
} else {
// TDS
if (tds < 200) return 'good'
if (tds < 300) return 'warning'
return 'error'
}
}
//
const getFilterStatusClass = (usage: number) => {
if (usage < 70) return 'normal'
if (usage < 90) return 'warning'
return 'error'
}
//
const refreshData = async () => {
try {
const result = await WaterMakerApi.refreshDeviceData(deviceId)
if (result.code === 200) {
//
await Promise.all([
loadRealtimeData(),
loadFilterStatus()
])
}
} catch (error) {
console.error('刷新数据失败:', error)
}
}
//
const viewMaintenanceDetail = (orderNo: string) => {
router.push(`/home/order/detail/${orderNo}`)
}
</script>
<style scoped>
.water-machine-detail {
padding: 20px;
}
/* 页面头部样式 */
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
/* 信息卡片样式 */
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0 0 16px 0;
}
.btn-refresh {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: #f8f9fa;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.btn-refresh:hover {
background-color: #e9ecef;
}
.refresh-icon {
font-size: 16px;
}
/* 信息网格样式 */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.info-item {
display: flex;
flex-direction: column;
}
.info-label {
font-size: 14px;
color: #666;
margin-bottom: 4px;
}
.info-value {
font-size: 15px;
color: #333;
font-weight: 500;
}
/* 状态标签样式 */
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.online {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.offline {
background-color: #f5f5f5;
color: #8c8c8c;
}
.status-tag.warning {
background-color: #fff7e6;
color: #d48806;
}
.status-tag.error {
background-color: #ffebe6;
color: #cf1322;
}
.status-tag.completed {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.processing {
background-color: #e6f7ff;
color: #1890ff;
}
.status-tag.pending {
background-color: #fff7e6;
color: #d48806;
}
.status-tag.small {
padding: 2px 6px;
font-size: 11px;
}
/* 左侧列样式 */
.left-column {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 20px;
}
/* 实时数据样式 */
.realtime-data {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.data-item {
background-color: #f8f9fa;
padding: 16px;
border-radius: 6px;
text-align: center;
}
.data-label {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.data-value {
font-size: 22px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.data-status {
width: 10px;
height: 10px;
border-radius: 50%;
margin: 0 auto;
}
.data-status.excellent {
background-color: #52c41a;
}
.data-status.good {
background-color: #1890ff;
}
.data-status.warning {
background-color: #faad14;
}
.data-status.error {
background-color: #ff4d4f;
}
/* 滤芯状态样式 */
.filter-status {
display: flex;
flex-direction: column;
gap: 16px;
}
.filter-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-name {
font-size: 14px;
color: #333;
}
.filter-progress {
height: 8px;
background-color: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.progress-bar {
height: 100%;
transition: width 0.3s;
}
.progress-bar.normal {
background-color: #52c41a;
}
.progress-bar.warning {
background-color: #faad14;
}
.progress-bar.error {
background-color: #ff4d4f;
}
.filter-info {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #666;
}
/* 表格样式 */
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.table-filter {
display: flex;
align-items: center;
gap: 8px;
}
.table-filter input {
padding: 6px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.history-table, .maintenance-table {
width: 100%;
border-collapse: collapse;
}
.history-table th, .history-table td,
.maintenance-table th, .maintenance-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.history-table th, .maintenance-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.history-table tbody tr:hover,
.maintenance-table tbody tr:hover {
background-color: #f8f9fa;
}
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
/* 按钮样式 */
.btn-view {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
background-color: #e6f7ff;
color: #1890ff;
}
.btn-view:hover {
opacity: 0.9;
}
/* 响应式调整 */
@media (max-width: 768px) {
.info-grid {
grid-template-columns: 1fr 1fr;
}
.realtime-data {
grid-template-columns: 1fr;
}
.table-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
@media (max-width: 480px) {
.info-grid {
grid-template-columns: 1fr;
}
.history-table, .maintenance-table {
font-size: 12px;
}
.history-table th, .history-table td,
.maintenance-table th, .maintenance-table td {
padding: 8px 6px;
}
}
</style>

@ -0,0 +1,412 @@
<!-- src/views/equipment/WaterSupplier.vue -->
<template>
<div class="water-supplier-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>供水机管理</h2>
<div class="breadcrumb">校园矿化水平台 / 设备监控 / 供水机</div>
</div>
<!-- 操作按钮区 -->
<div class="action-bar">
<button class="btn-add">添加供水机</button>
<div class="filters">
<!-- 搜索框 -->
<div class="search-box">
<input
type="text"
placeholder="搜索设备ID或位置..."
v-model="searchKeyword"
@input="handleSearch"
>
<button class="search-btn">搜索</button>
</div>
<!-- 片区筛选 -->
<select
v-model="selectedArea"
class="filter-select"
@change="handleSearch"
>
<option value="">全部片区</option>
<option value="市区">市区</option>
<option value="校区">校区</option>
</select>
<!-- 状态筛选 -->
<select
v-model="selectedStatus"
class="filter-select"
@change="handleSearch"
>
<option value="">全部状态</option>
<option value="online">在线</option>
<option value="offline">离线</option>
<option value="warning">警告</option>
<option value="error">故障</option>
</select>
</div>
</div>
<!-- 设备表格 - 新增设备机型列 -->
<div class="card">
<table class="equipment-table">
<thead>
<tr>
<th>设备ID</th>
<th>设备机型</th> <!-- 新增机型列 -->
<th>所属片区</th>
<th>详细位置</th>
<th>状态</th>
<th>最后上传时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="device in filteredDevices" :key="device.id">
<td>{{ device.id }}</td>
<td>供水机</td> <!-- 固定显示供水机机型 -->
<td>{{ device.area }}</td>
<td>{{ device.location }}</td>
<td>
<span :class="`status-tag ${device.status}`">
{{ formatStatus(device.status) }}
</span>
</td>
<td>{{ device.lastUploadTime }}</td>
<td class="operation-buttons">
<button class="btn-view" @click="viewDevice(device.id)"></button>
</td>
</tr>
<tr v-if="filteredDevices.length === 0">
<td colspan="7" class="no-data">暂无设备数据</td> <!-- colspan从6改为7 -->
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
//
type DeviceStatus = 'online' | 'offline' | 'warning' | 'error'
//
interface WaterSupplierDevice {
id: string
area: string
location: string
status: DeviceStatus
lastUploadTime: string
}
//
const waterSupplierDevices: WaterSupplierDevice[] = [
{
id: 'WS-2023-001',
area: '市区',
location: '行政中心大楼1楼大厅',
status: 'online',
lastUploadTime: '2023-10-25 10:15:33'
},
{
id: 'WS-2023-002',
area: '校区',
location: '研究生公寓3号楼一层',
status: 'online',
lastUploadTime: '2023-10-25 09:30:22'
},
{
id: 'WS-2023-003',
area: '市区',
location: '科技园区A座大厅',
status: 'warning',
lastUploadTime: '2023-10-25 08:45:11'
},
{
id: 'WS-2023-004',
area: '校区',
location: '留学生公寓1楼',
status: 'offline',
lastUploadTime: '2023-10-24 23:05:47'
},
{
id: 'WS-2023-005',
area: '市区',
location: '图书馆新馆2楼',
status: 'error',
lastUploadTime: '2023-10-25 07:20:35'
}
]
//
const devices = ref<WaterSupplierDevice[]>(waterSupplierDevices)
const searchKeyword = ref('')
const selectedArea = ref('') //
const selectedStatus = ref('') //
const currentPage = ref(1)
const pageSize = 10 //
const router = useRouter()
//
const filteredDevices = computed(() => {
return devices.value.filter(device => {
const keywordMatch = searchKeyword.value.trim() === '' ||
device.id.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
device.location.toLowerCase().includes(searchKeyword.value.toLowerCase())
const areaMatch = selectedArea.value === '' || device.area === selectedArea.value
const statusMatch = selectedStatus.value === '' || device.status === selectedStatus.value
return keywordMatch && areaMatch && statusMatch
})
})
//
const totalPages = computed(() => {
return Math.ceil(filteredDevices.value.length / pageSize)
})
//
const formatStatus = (status: DeviceStatus): string => {
const statusMap = {
online: '在线',
offline: '离线',
warning: '警告',
error: '故障'
}
return statusMap[status]
}
//
const handleSearch = () => {
currentPage.value = 1 //
}
//
const viewDevice = (id: string) => {
router.push(`/home/equipment/water-supplier/${id}`)
}
</script>
<style scoped>
/* 样式与制水机页面保持一致 */
.water-supplier-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 16px;
}
.btn-add {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-add:hover {
background: #359e75;
}
.filters {
display: flex;
gap: 12px;
align-items: center;
}
.search-box {
display: flex;
gap: 8px;
}
.search-box input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 240px;
}
.search-btn {
background: #667eea;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.filter-select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.equipment-table {
width: 100%;
border-collapse: collapse;
}
.equipment-table th,
.equipment-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.equipment-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.equipment-table tbody tr:hover {
background-color: #f8f9fa;
}
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.online {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.offline {
background-color: #f5f5f5;
color: #8c8c8c;
}
.status-tag.warning {
background-color: #fff7e6;
color: #d48806;
}
.status-tag.error {
background-color: #ffebe6;
color: #cf1322;
}
.operation-buttons {
display: flex;
gap: 8px;
}
.operation-buttons button {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
}
.operation-buttons button:hover {
opacity: 0.9;
}
.btn-view {
background-color: #e6f7ff;
color: #1890ff;
}
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 响应式调整 */
@media (max-width: 768px) {
.filters {
flex-direction: column;
width: 100%;
}
.search-box, .filter-select {
width: 100%;
}
}
</style>

@ -0,0 +1,371 @@
<!-- src/views/personnel/Admin.vue -->
<template>
<div class="admin-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>管理员管理</h2>
<div class="breadcrumb">校园矿化水平台 / 人员管理 / 管理员</div>
</div>
<!-- 操作按钮区 -->
<div class="action-bar">
<button class="btn-add" @click="handleAddAdmin"></button>
<div class="search-box">
<input
type="text"
placeholder="搜索姓名或账号..."
v-model="searchKeyword"
@input="handleSearch"
>
<button class="search-btn" @click="handleSearch"></button>
</div>
</div>
<!-- 管理员表格 -->
<div class="card">
<table class="admin-table">
<thead>
<tr>
<th>姓名</th>
<th>账号</th>
<th>联系电话</th>
<th>身份</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="admin in filteredAdmins" :key="admin.id">
<td>{{ admin.name }}</td>
<td>{{ admin.account }}</td>
<td>{{ admin.phone }}</td>
<td>{{ admin.role }}</td>
<td>
<span :class="`status-tag ${admin.status}`">
{{ admin.status === 'active' ? '启用' : '禁用' }}
</span>
</td>
<td class="operation-buttons">
<button
class="btn-edit"
@click="handleEdit(admin.id)"
>
编辑
</button>
<button
class="btn-status"
:class="admin.status === 'active' ? 'btn-disable' : 'btn-enable'"
@click="handleStatusChange(admin.id, admin.status)"
>
{{ admin.status === 'active' ? '禁用' : '启用' }}
</button>
</td>
</tr>
<tr v-if="filteredAdmins.length === 0">
<td colspan="6" class="no-data">暂无管理员数据</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
//
type AdminStatus = 'active' | 'disabled'
//
interface Admin {
id: string
name: string
account: string
phone: string
role: string
status: AdminStatus
}
//
const adminList: Admin[] = [
{
id: '1',
name: '张三',
account: 'admin01',
phone: '13800138000',
role: '超级管理员',
status: 'active'
},
{
id: '2',
name: '李四',
account: 'admin02',
phone: '13900139000',
role: '设备管理员',
status: 'active'
},
{
id: '3',
name: '王五',
account: 'admin03',
phone: '13700137000',
role: '系统管理员',
status: 'disabled'
}
]
//
const admins = ref<Admin[]>(adminList)
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = 10 //
const router = useRouter()
//
const filteredAdmins = computed(() => {
return admins.value.filter(admin => {
const keywordMatch = searchKeyword.value.trim() === '' ||
admin.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
admin.account.toLowerCase().includes(searchKeyword.value.toLowerCase())
return keywordMatch
})
})
//
const totalPages = computed(() => {
return Math.ceil(filteredAdmins.value.length / pageSize)
})
//
const handleSearch = () => {
currentPage.value = 1 //
}
//
const handleStatusChange = (id: string, currentStatus: AdminStatus) => {
const newStatus: AdminStatus = currentStatus === 'active' ? 'disabled' : 'active'
admins.value = admins.value.map(admin =>
admin.id === id ? { ...admin, status: newStatus } : admin
)
// API
}
//
const handleEdit = (id: string) => {
router.push(`/home/personnel/admin/edit/${id}`)
}
//
const handleAddAdmin = () => {
router.push('/home/personnel/admin/add')
}
</script>
<style scoped>
.admin-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 16px;
}
.btn-add {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-add:hover {
background: #359e75;
}
.search-box {
display: flex;
gap: 8px;
}
.search-box input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 240px;
}
.search-btn {
background: #667eea;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.admin-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.admin-table tbody tr:hover {
background-color: #f8f9fa;
}
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.active {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.disabled {
background-color: #f5f5f5;
color: #8c8c8c;
}
.operation-buttons {
display: flex;
gap: 8px;
}
.operation-buttons button {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
}
.operation-buttons button:hover {
opacity: 0.9;
}
.btn-edit {
background-color: #e6f7ff;
color: #1890ff;
}
.btn-enable {
background-color: #e6f7ee;
color: #00875a;
}
.btn-disable {
background-color: #ffebe6;
color: #cf1322;
}
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 响应式调整 */
@media (max-width: 768px) {
.action-bar {
flex-direction: column;
align-items: flex-start;
}
.search-box {
width: 100%;
}
.search-box input {
width: 100%;
}
}
</style>

@ -0,0 +1,387 @@
<!-- src/views/personnel/Maintenance.vue -->
<template>
<div class="maintenance-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>维修人员管理</h2>
<div class="breadcrumb">校园矿化水平台 / 人员管理 / 维修人员</div>
</div>
<!-- 操作按钮区 -->
<div class="action-bar">
<button class="btn-add" @click="handleAddMaintenance"></button>
<div class="search-box">
<input
type="text"
placeholder="搜索姓名或账号..."
v-model="searchKeyword"
@input="handleSearch"
>
<button class="search-btn" @click="handleSearch"></button>
</div>
</div>
<!-- 维修人员表格 -->
<div class="card">
<table class="maintenance-table">
<thead>
<tr>
<th>姓名</th>
<th>账号</th>
<th>联系电话</th>
<th>维修片区</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="staff in filteredStaff" :key="staff.id">
<td>{{ staff.name }}</td>
<td>{{ staff.account }}</td>
<td>{{ staff.phone }}</td>
<td>{{ staff.area }}</td>
<td>
<span :class="`status-tag ${staff.status}`">
{{ staff.status === 'active' ? '启用' : '禁用' }}
</span>
</td>
<td class="operation-buttons">
<button
class="btn-view"
@click="handleViewRecords(staff.id)"
>
查看维修记录
</button>
<button
class="btn-edit"
@click="handleEdit(staff.id)"
>
编辑
</button>
<button
class="btn-status"
:class="staff.status === 'active' ? 'btn-disable' : 'btn-enable'"
@click="handleStatusChange(staff.id, staff.status)"
>
{{ staff.status === 'active' ? '禁用' : '启用' }}
</button>
</td>
</tr>
<tr v-if="filteredStaff.length === 0">
<td colspan="6" class="no-data">暂无维修人员数据</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
//
type StaffStatus = 'active' | 'disabled'
//
interface MaintenanceStaff {
id: string
name: string
account: string
phone: string
area: string
status: StaffStatus
}
//
const staffList: MaintenanceStaff[] = [
{
id: '1',
name: '赵六',
account: 'repair01',
phone: '13500135000',
area: '市区',
status: 'active'
},
{
id: '2',
name: '孙七',
account: 'repair02',
phone: '13600136000',
area: '校区',
status: 'active'
},
{
id: '3',
name: '周八',
account: 'repair03',
phone: '13400134000',
area: '市区',
status: 'disabled'
}
]
//
const staff = ref<MaintenanceStaff[]>(staffList)
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = 10 //
const router = useRouter()
//
const filteredStaff = computed(() => {
return staff.value.filter(person => {
const keywordMatch = searchKeyword.value.trim() === '' ||
person.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
person.account.toLowerCase().includes(searchKeyword.value.toLowerCase())
return keywordMatch
})
})
//
const totalPages = computed(() => {
return Math.ceil(filteredStaff.value.length / pageSize)
})
//
const handleSearch = () => {
currentPage.value = 1 //
}
//
const handleStatusChange = (id: string, currentStatus: StaffStatus) => {
const newStatus: StaffStatus = currentStatus === 'active' ? 'disabled' : 'active'
staff.value = staff.value.map(person =>
person.id === id ? { ...person, status: newStatus } : person
)
// API
}
//
const handleEdit = (id: string) => {
router.push(`/home/personnel/maintenance/edit/${id}`)
}
//
const handleViewRecords = (id: string) => {
router.push(`/home/personnel/maintenance/records/${id}`)
}
//
const handleAddMaintenance = () => {
router.push('/home/personnel/maintenance/add')
}
</script>
<style scoped>
.maintenance-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 16px;
}
.btn-add {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-add:hover {
background: #359e75;
}
.search-box {
display: flex;
gap: 8px;
}
.search-box input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 240px;
}
.search-btn {
background: #667eea;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.maintenance-table {
width: 100%;
border-collapse: collapse;
}
.maintenance-table th,
.maintenance-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.maintenance-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.maintenance-table tbody tr:hover {
background-color: #f8f9fa;
}
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.active {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.disabled {
background-color: #f5f5f5;
color: #8c8c8c;
}
.operation-buttons {
display: flex;
gap: 8px;
}
.operation-buttons button {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
}
.operation-buttons button:hover {
opacity: 0.9;
}
.btn-view {
background-color: #f6f7ff;
color: #667eea;
}
.btn-edit {
background-color: #e6f7ff;
color: #1890ff;
}
.btn-enable {
background-color: #e6f7ee;
color: #00875a;
}
.btn-disable {
background-color: #ffebe6;
color: #cf1322;
}
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 响应式调整 */
@media (max-width: 768px) {
.action-bar {
flex-direction: column;
align-items: flex-start;
}
.search-box {
width: 100%;
}
.search-box input {
width: 100%;
}
}
</style>

@ -0,0 +1,407 @@
<!-- src/views/personnel/User.vue -->
<template>
<div class="user-page">
<!-- 页面标题和面包屑恢复与管理员/维修人员页面一致 -->
<div class="page-header">
<h2>用户管理</h2>
<div class="breadcrumb">校园矿化水平台 / 人员管理 / 用户</div>
</div>
<!-- 操作按钮区移除新增按钮保留搜索功能 -->
<div class="action-bar">
<!-- 移除新增用户按钮 -->
<div class="search-box">
<input
type="text"
placeholder="搜索姓名或账号..."
v-model="searchKeyword"
@input="handleSearch"
>
<button class="search-btn" @click="handleSearch"></button>
</div>
</div>
<!-- 用户表格保持与管理员/维修人员页面一致的列结构 -->
<div class="card">
<table class="user-table">
<thead>
<tr>
<th>姓名</th>
<th>账号</th>
<th>联系电话</th>
<th>身份</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="user in filteredUsers" :key="user.id">
<td>{{ user.name }}</td>
<td>{{ user.account }}</td>
<td>{{ user.phone }}</td>
<td>
<span :class="`role-tag ${user.role}`">
{{ formatRole(user.role) }}
</span>
</td>
<td>
<span :class="`status-tag ${user.status}`">
{{ user.status === 'active' ? '启用' : '禁用' }}
</span>
</td>
<td class="operation-buttons">
<button
class="btn-view"
@click="handleView(user.id)"
>
查看
</button>
<button
class="btn-edit"
@click="handleEdit(user.id)"
>
编辑
</button>
<button
class="btn-status"
:class="user.status === 'active' ? 'btn-disable' : 'btn-enable'"
@click="handleStatusChange(user.id, user.status)"
>
{{ user.status === 'active' ? '禁用' : '启用' }}
</button>
</td>
</tr>
<tr v-if="filteredUsers.length === 0">
<td colspan="6" class="no-data">暂无用户数据</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件与管理员/维修人员页面样式一致 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
//
type UserRole = 'student' | 'teacher' | 'visitor'
type UserStatus = 'active' | 'disabled'
//
interface User {
id: string
name: string
account: string
phone: string
role: UserRole
status: UserStatus
}
//
const userList: User[] = [
{
id: '1',
name: '吴九',
account: 'user01',
phone: '13100131000',
role: 'student', //
status: 'active'
},
{
id: '2',
name: '郑十',
account: 'user02',
phone: '13200132000',
role: 'teacher', //
status: 'active'
},
{
id: '3',
name: '王十一',
account: 'user03',
phone: '13300133000',
role: 'visitor', //
status: 'disabled'
}
]
// /
const users = ref<User[]>(userList)
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = 10
const router = useRouter()
//
const formatRole = (role: UserRole) => {
switch(role) {
case 'student': return '学生';
case 'teacher': return '老师';
case 'visitor': return '游客';
default: return '未知身份';
}
}
//
const filteredUsers = computed(() => {
return users.value.filter(user => {
const keywordMatch = searchKeyword.value.trim() === '' ||
user.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
user.account.toLowerCase().includes(searchKeyword.value.toLowerCase())
return keywordMatch
})
})
// /
const totalPages = computed(() => {
return Math.ceil(filteredUsers.value.length / pageSize)
})
//
const handleSearch = () => {
currentPage.value = 1 //
}
//
const handleStatusChange = (id: string, currentStatus: UserStatus) => {
const newStatus: UserStatus = currentStatus === 'active' ? 'disabled' : 'active'
users.value = users.value.map(user =>
user.id === id ? { ...user, status: newStatus } : user
)
}
//
const handleView = (id: string) => {
router.push(`/home/personnel/user/view/${id}`)
}
//
const handleEdit = (id: string) => {
router.push(`/home/personnel/user/edit/${id}`)
}
</script>
<style scoped>
/* 完全复用管理员/维修人员页面的样式,仅调整角色标签颜色 */
.user-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 16px;
}
.search-box {
display: flex;
gap: 8px;
}
.search-box input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 240px;
}
.search-btn {
background: #667eea;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.user-table {
width: 100%;
border-collapse: collapse;
}
.user-table th,
.user-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.user-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.user-table tbody tr:hover {
background-color: #f8f9fa;
}
/* 状态标签样式(与管理员/维修人员页面一致) */
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.active {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.disabled {
background-color: #f5f5f5;
color: #8c8c8c;
}
/* 角色标签样式(新增,区分学生/老师/游客) */
.role-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.role-tag.student {
background-color: #e6f7ff;
color: #1890ff;
}
.role-tag.teacher {
background-color: #f6f7ff;
color: #667eea;
}
.role-tag.visitor {
background-color: #fff7e6;
color: #d48806;
}
.operation-buttons {
display: flex;
gap: 8px;
}
.operation-buttons button {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
}
.operation-buttons button:hover {
opacity: 0.9;
}
.btn-view {
background-color: #f6f7ff;
color: #667eea;
}
.btn-edit {
background-color: #e6f7ff;
color: #1890ff;
}
.btn-enable {
background-color: #e6f7ee;
color: #00875a;
}
.btn-disable {
background-color: #ffebe6;
color: #cf1322;
}
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 响应式调整(与管理员/维修人员页面一致) */
@media (max-width: 768px) {
.action-bar {
flex-direction: column;
align-items: flex-start;
}
.search-box {
width: 100%;
}
.search-box input {
width: 100%;
}
}
</style>

@ -0,0 +1,290 @@
<!-- src/views/order/OrderCompleted.vue -->
<template>
<div class="order-completed-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>已结单工单</h2>
<div class="breadcrumb">校园矿化水平台 / 工单管理 / 已结单</div>
</div>
<!-- 检索筛选区 -->
<div class="filter-bar">
<!-- 工单号/设备ID搜索 -->
<div class="filter-item search-item">
<label>搜索</label>
<input
type="text"
v-model="searchKeyword"
class="search-input"
placeholder="输入工单号或设备ID搜索"
@input="handleSearch"
>
</div>
<!-- 片区筛选 -->
<div class="filter-item">
<label>所属片区</label>
<select v-model="filterForm.area" class="filter-select" @change="handleFilter">
<option value="">全部片区</option>
<option value="市区">市区</option>
<option value="校区">校区</option>
</select>
</div>
<!-- 日期筛选 -->
<div class="filter-item">
<label>创建日期</label>
<input
type="date"
v-model="filterForm.createDate"
class="filter-input"
@change="handleFilter"
>
</div>
<!-- 重置按钮 -->
<button class="btn-reset" @click="resetFilter"></button>
</div>
<!-- 工单表格 -->
<div class="card">
<table class="order-table">
<thead>
<tr>
<th>工单号</th>
<th>设备</th>
<th>片区</th>
<th>问题描述</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="order in filteredOrders" :key="order.id">
<td>{{ order.orderNo }}</td>
<td>
<div class="device-info">
<div class="device-type">{{ order.deviceType }}</div>
<div class="device-id">{{ order.deviceId }}</div>
</div>
</td>
<td>{{ order.area }}</td>
<td class="desc-cell">{{ order.problemDesc }}</td>
<td>
<span :class="`status-tag ${order.status}`">
{{ formatStatus(order.status) }}
</span>
</td>
<td>{{ order.createTime }}</td>
<td class="operation-buttons">
<button class="btn-view" @click="viewOrderDetail(order.id)"></button>
</td>
</tr>
<tr v-if="filteredOrders.length === 0">
<td colspan="7" class="no-data">暂无已结单工单</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
//
type OrderStatus = 'timeout' | 'pending' | 'processing' | 'reviewing' | 'completed'
//
interface CompletedOrder {
id: string
orderNo: string
deviceType: string // /
deviceId: string // ID
area: string //
problemDesc: string //
status: OrderStatus //
createTime: string //
}
//
const orderList: CompletedOrder[] = [
{
id: '11',
orderNo: 'ORD-20231023-001',
deviceType: '制水机',
deviceId: 'WM-2023-001',
area: '市区',
problemDesc: '更换密封垫后漏水问题已解决,设备运行正常',
status: 'completed',
createTime: '2023-10-23 08:10:05'
},
{
id: '12',
orderNo: 'ORD-20231023-002',
deviceType: '供水机',
deviceId: 'WS-2023-001',
area: '校区',
problemDesc: '水质检测合格,出水口感异常问题已解决',
status: 'completed',
createTime: '2023-10-23 14:20:33'
},
{
id: '13',
orderNo: 'ORD-20231024-003',
deviceType: '制水机',
deviceId: 'WM-2023-002',
area: '校区',
problemDesc: '水泵检修完成,出水速度恢复正常,已审核通过',
status: 'completed',
createTime: '2023-10-24 11:30:15'
}
]
//
const orders = ref<CompletedOrder[]>(orderList)
const currentPage = ref(1)
const pageSize = 10 //
const router = useRouter()
// /ID
const searchKeyword = ref('')
//
const filterForm = ref({
area: '', //
createDate: '' //
})
//
const formatStatus = (status: OrderStatus): string => {
const statusMap = {
timeout: '超时未抢',
pending: '待抢单',
processing: '处理中',
reviewing: '待审核',
completed: '已结单'
}
return statusMap[status]
}
//
const filteredOrders = computed(() => {
return orders.value.filter(order => {
// /ID
const keywordMatch = searchKeyword.value.trim() === '' ||
order.orderNo.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
order.deviceId.toLowerCase().includes(searchKeyword.value.toLowerCase())
//
const areaMatch = filterForm.value.area === '' || order.area === filterForm.value.area
//
const dateMatch = filterForm.value.createDate === '' ||
order.createTime.split(' ')[0] === filterForm.value.createDate
return keywordMatch && areaMatch && dateMatch
})
})
//
const totalPages = computed(() => {
return Math.ceil(filteredOrders.value.length / pageSize)
})
//
const handleSearch = () => {
currentPage.value = 1 //
}
// /
const handleFilter = () => {
currentPage.value = 1 //
}
//
const resetFilter = () => {
searchKeyword.value = '' //
filterForm.value = {
area: '',
createDate: ''
}
currentPage.value = 1
}
//
const viewOrderDetail = (id: string) => {
router.push(`/home/work-order/completed/${id}`)
}
</script>
<style scoped>
/* 样式与前几个页面完全一致,仅修改页面容器类名 */
.order-completed-page {
padding: 20px;
}
/* 复用相同样式 */
.page-header { margin-bottom: 24px; }
.page-header h2 { font-size: 24px; font-weight: 600; color: #333; margin-bottom: 8px; }
.breadcrumb { color: #666; font-size: 14px; }
.filter-bar { display: flex; align-items: center; gap: 20px; padding: 16px; background-color: #f8f9fa; border-radius: 4px; margin-bottom: 16px; flex-wrap: wrap; }
.filter-item { display: flex; align-items: center; gap: 8px; }
.filter-item label { font-size: 14px; color: #4e5969; font-weight: 500; }
.search-input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 240px; }
.filter-select, .filter-input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 160px; }
.btn-reset { padding: 8px 16px; border: 1px solid #ddd; background-color: white; border-radius: 4px; cursor: pointer; font-size: 14px; color: #666; transition: all 0.3s; }
.btn-reset:hover { background-color: #f0f0f0; }
.order-table { width: 100%; border-collapse: collapse; }
.order-table th, .order-table td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #f0f0f0; }
.order-table th { background-color: #f8f9fa; font-weight: 600; color: #4e5969; font-size: 14px; }
.order-table tbody tr:hover { background-color: #f8f9fa; }
.device-info { display: flex; flex-direction: column; gap: 4px; }
.device-type { font-weight: 500; color: #333; }
.device-id { font-size: 12px; color: #666; }
.desc-cell { max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
.desc-cell:hover { white-space: normal; overflow: visible; background-color: white; z-index: 10; position: relative; }
.status-tag { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
.status-tag.timeout { background-color: #ffebe6; color: #cf1322; }
.status-tag.pending { background-color: #fff7e6; color: #d48806; }
.status-tag.processing { background-color: #e6f7ff; color: #1890ff; }
.status-tag.reviewing { background-color: #f6f7ff; color: #667eea; }
.status-tag.completed { background-color: #e6f7ee; color: #00875a; }
.operation-buttons { display: flex; gap: 8px; }
.btn-view { padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer; border: none; transition: opacity 0.3s; background-color: #e6f7ff; color: #1890ff; }
.btn-view:hover { opacity: 0.9; }
.no-data { text-align: center; padding: 40px 0; color: #8c8c8c; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 24px; color: #666; font-size: 14px; }
.page-btn { padding: 4px 12px; border: 1px solid #ddd; background: white; border-radius: 4px; cursor: pointer; }
.page-btn:disabled { opacity: 0.6; cursor: not-allowed; }
/* 响应式调整 */
@media (max-width: 768px) {
.filter-bar { flex-direction: column; align-items: flex-start; }
.filter-item { width: 100%; }
.search-input, .filter-select, .filter-input { width: 100%; }
}
</style>

@ -0,0 +1,455 @@
<!-- src/views/workorder/CompletedDetail.vue -->
<template>
<div class="completed-detail-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<div>
<h2>结单信息</h2>
<div class="breadcrumb">矿化水平台 / 工单管理 / 已结单 / 工单信息</div>
</div>
<button class="btn-back" @click="handleBack"></button>
</div>
<!-- 工单基本信息卡片 -->
<div class="card main-card">
<div class="card-header">
<h3>工单基本信息</h3>
</div>
<div class="card-body">
<div class="info-grid">
<div class="info-item">
<span class="info-label">工单号</span>
<span class="info-value">{{ orderDetail.orderNo }}</span>
</div>
<div class="info-item">
<span class="info-label">工单状态</span>
<span class="info-value status-tag completed">{{ formatStatus(orderDetail.status) }}</span>
</div>
<div class="info-item">
<span class="info-label">维修人员</span>
<span class="info-value">{{ orderDetail.repairman }}</span>
</div>
<div class="info-item">
<span class="info-label">联系电话</span>
<span class="info-value">{{ orderDetail.phone }}</span>
</div>
<div class="info-item">
<span class="info-label">接单时间</span>
<span class="info-value">{{ orderDetail.acceptTime }}</span>
</div>
<div class="info-item">
<span class="info-label">设备ID</span>
<span class="info-value">{{ orderDetail.deviceId }}</span>
</div>
<div class="info-item">
<span class="info-label">警告项目</span>
<span class="info-value">{{ orderDetail.warningItem }}</span>
</div>
<div class="info-item">
<span class="info-label">设备位置</span>
<span class="info-value">{{ orderDetail.location }}</span>
</div>
</div>
</div>
</div>
<!-- 处理详情 -->
<div class="card">
<div class="card-header">
<h3>处理详情</h3>
</div>
<div class="card-body">
<div class="detail-item">
<span class="detail-label">处理备注</span>
<div class="detail-content">{{ orderDetail.processRemark }}</div>
</div>
<div class="detail-item">
<span class="detail-label">实际处理</span>
<div class="detail-content">{{ orderDetail.actualProcess }}</div>
</div>
</div>
</div>
<!-- 现场照片 -->
<div class="card">
<div class="card-header">
<h3>现场照片</h3>
</div>
<div class="card-body">
<div class="photos-container">
<div class="photo-item" v-for="(photo, index) in orderDetail.photos" :key="index">
<img :src="photo" :alt="`现场照片 ${index + 1}`" class="现场照片" @click="previewPhoto(photo)">
</div>
<div v-if="orderDetail.photos.length === 0" class="no-photos"></div>
</div>
</div>
</div>
<!-- 结单信息 -->
<div class="card">
<div class="card-header">
<h3>结单信息</h3>
</div>
<div class="card-body">
<div class="detail-item total-cost">
<span class="detail-label">结单总费用</span>
<div class="detail-content cost-value">{{ orderDetail.totalCost }}</div>
</div>
<div class="detail-item">
<span class="detail-label">审核意见</span>
<div class="detail-content">{{ orderDetail.reviewOpinion || '无审核意见' }}</div>
</div>
<div class="detail-item">
<span class="detail-label">结单时间</span>
<div class="detail-content">{{ orderDetail.completeTime }}</div>
</div>
</div>
</div>
<!-- 照片预览弹窗 -->
<div v-if="previewImage" class="photo-preview-overlay" @click="previewImage = ''">
<div class="photo-preview-container" @click.stop>
<img :src="previewImage" alt="照片预览" class="preview-img">
<button class="preview-close" @click="previewImage = ''">×</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
//
type OrderStatus = 'timeout' | 'pending' | 'processing' | 'reviewing' | 'completed'
//
interface OrderDetail {
id: string
orderNo: string
status: OrderStatus
repairman: string
phone: string
acceptTime: string
completeTime: string
deviceId: string
warningItem: string
location: string
processRemark: string
actualProcess: string
totalCost: string
reviewOpinion?: string
photos: string[]
}
//
const orderDetail = ref<OrderDetail>({
id: '',
orderNo: '',
status: 'completed',
repairman: '',
phone: '',
acceptTime: '',
completeTime: '',
deviceId: '',
warningItem: '',
location: '',
processRemark: '',
actualProcess: '',
totalCost: '',
photos: []
})
const previewImage = ref('')
//
const route = useRoute()
const router = useRouter()
//
const formatStatus = (status: OrderStatus): string => {
const statusMap: Record<OrderStatus, string> = {
timeout: '超时未抢',
pending: '待抢单',
processing: '处理中',
reviewing: '待审核',
completed: '已结单'
}
return statusMap[status]
}
//
const fetchOrderDetail = async (id: string) => {
try {
// API
// const response = await getCompletedOrderDetailApi(id)
// orderDetail.value = response.data
// API
const mockData: Record<string, OrderDetail> = {
'11': {
id: '11',
orderNo: '1O05',
status: 'completed',
repairman: '王五',
phone: '123456789',
acceptTime: '2025/10/20-16:21',
completeTime: '2025/10/20-18:45',
deviceId: '供水机#A11',
warningItem: '更换滤芯',
location: 'A区图书馆',
processRemark: '原滤芯老旧已损,更换了新滤芯',
actualProcess: '更换滤芯',
totalCost: '110元',
reviewOpinion: '审核通过,已结单',
photos: [
'https://picsum.photos/seed/filter1/400/300',
'https://picsum.photos/seed/filter2/400/300'
]
},
'12': {
id: '12',
orderNo: '1O06',
status: 'completed',
repairman: '赵六',
phone: '987654321',
acceptTime: '2025/10/21-09:15',
completeTime: '2025/10/21-11:30',
deviceId: '制水机#B07',
warningItem: '水泵故障',
location: 'B区教学楼',
processRemark: '水泵已修复,设备运行正常',
actualProcess: '维修水泵,更换损坏零件',
totalCost: '260元',
reviewOpinion: '维修合格,同意结单',
photos: [
'https://picsum.photos/seed/pump1/400/300',
'https://picsum.photos/seed/pump2/400/300'
]
}
}
//
await new Promise(resolve => setTimeout(resolve, 300))
if (mockData[id]) {
orderDetail.value = mockData[id]
} else {
throw new Error('未找到工单数据')
}
} catch (error) {
console.error('获取工单详情失败:', error)
alert('获取工单详情详情失败,请重试')
router.push('/home/work-order/completed')
}
}
//
const previewPhoto = (photo: string) => {
previewImage.value = photo
}
//
const handleBack = () => {
router.go(-1)
}
//
onMounted(() => {
const id = route.params.id as string
if (id) {
fetchOrderDetail(id)
} else {
router.push('/home/work-order/completed')
}
})
</script>
<style scoped>
.completed-detail-page {
padding: 20px;
background-color: #f5f7fa;
min-height: 100vh;
box-sizing: border-box;
}
.page-header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin: 0 0 8px 0;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
.btn-back {
padding: 8px 16px;
background-color: #fff;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-back:hover {
background-color: #f5f5f5;
}
.card {
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
margin-bottom: 24px;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.card-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
}
.card-body {
padding: 20px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.info-item {
display: flex;
margin-bottom: 8px;
}
.info-label, .detail-label {
color: #666;
width: 100px;
flex-shrink: 0;
font-weight: 500;
}
.info-value, .detail-content {
flex-grow: 1;
color: #333;
}
.status-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
color: white;
}
.status-tag.completed {
background-color: #52c41a;
}
.detail-item {
margin-bottom: 16px;
display: flex;
}
.detail-content {
line-height: 1.5;
}
.photos-container {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 10px;
}
.photo-item {
width: 160px;
height: 120px;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
}
.photo-item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.photo-item img:hover {
transform: scale(1.05);
}
.no-photos {
color: #999;
padding: 20px 0;
}
.total-cost .cost-value {
color: #f5222d;
font-weight: 600;
font-size: 16px;
}
/* 照片预览样式 */
.photo-preview-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.photo-preview-container {
position: relative;
max-width: 90%;
max-height: 90%;
}
.preview-img {
max-width: 100%;
max-height: 80vh;
border: 4px solid white;
border-radius: 4px;
}
.preview-close {
position: absolute;
top: -30px;
right: -30px;
background-color: rgba(0, 0, 0, 0.5);
color: white;
border: none;
width: 30px;
height: 30px;
border-radius: 50%;
font-size: 20px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
}
</style>

@ -0,0 +1,654 @@
<!-- src/views/workorder/Pending.vue -->
<template>
<div class="order-to-claim-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>待抢单工单</h2>
<div class="breadcrumb">校园矿化水平台 / 工单管理 / 待抢单</div>
</div>
<!-- 检索筛选区 -->
<div class="filter-bar">
<!-- 工单号/设备ID搜索 -->
<div class="filter-item search-item">
<label>搜索</label>
<input
type="text"
v-model="searchKeyword"
class="search-input"
placeholder="输入工单号或设备ID搜索"
@input="handleSearch"
>
</div>
<!-- 片区筛选 -->
<div class="filter-item">
<label>所属片区</label>
<select v-model="filterForm.area" class="filter-select" @change="handleFilter">
<option value="">全部片区</option>
<option value="市区">市区</option>
<option value="校区">校区</option>
</select>
</div>
<!-- 日期筛选 -->
<div class="filter-item">
<label>创建日期</label>
<input
type="date"
v-model="filterForm.createDate"
class="filter-input"
@change="handleFilter"
>
</div>
<!-- 重置按钮 -->
<button class="btn-reset" @click="resetFilter"></button>
</div>
<!-- 工单表格 -->
<div class="card">
<table class="order-table">
<thead>
<tr>
<th>工单号</th>
<th>设备</th>
<th>片区</th>
<th>问题描述</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="order in filteredOrders" :key="order.id">
<td>{{ order.orderNo }}</td>
<td>
<div class="device-info">
<div class="device-type">{{ order.deviceType }}</div>
<div class="device-id">{{ order.deviceId }}</div>
</div>
</td>
<td>{{ order.area }}</td>
<td class="desc-cell">{{ order.problemDesc }}</td>
<td>
<span :class="`status-tag ${order.status}`">
{{ formatStatus(order.status) }}
</span>
</td>
<td>{{ order.createTime }}</td>
<td class="operation-buttons">
<button class="btn-view" @click="viewOrderDetail(order)"></button>
</td>
</tr>
<tr v-if="filteredOrders.length === 0">
<td colspan="7" class="no-data">暂无待抢单工单</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
<!-- 工单详情弹窗 -->
<div v-if="showDetailModal" class="modal-overlay" @click="closeDetailModal">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>工单详情</h3>
<button class="modal-close" @click="closeDetailModal">×</button>
</div>
<div class="modal-body">
<div class="detail-item">
<span class="detail-label">工单号</span>
<span class="detail-value">{{ currentOrder?.orderNo }}</span>
</div>
<div class="detail-item">
<span class="detail-label">建单时间</span>
<span class="detail-value">{{ currentOrder?.createTime }}</span>
</div>
<div class="detail-item">
<span class="detail-label">设备机型</span>
<span class="detail-value">{{ currentOrder?.deviceType }}</span>
</div>
<div class="detail-item">
<span class="detail-label">设备ID</span>
<span class="detail-value">{{ currentOrder?.deviceId }}</span>
</div>
<div class="detail-item">
<span class="detail-label">警告内容</span>
<span class="detail-value">{{ currentOrder?.problemDesc }}</span>
</div>
<div class="detail-item">
<span class="detail-label">设备最后上传时间</span>
<span class="detail-value">{{ currentOrder?.lastUploadTime }}</span>
</div>
<div class="detail-item">
<span class="detail-label">位置</span>
<span class="detail-value">{{ currentOrder?.location }}</span>
</div>
<div class="detail-item">
<span class="detail-label">当前状态</span>
<span :class="`detail-status status-tag ${currentOrder?.status}`">
{{ currentOrder ? formatStatus(currentOrder.status) : '' }}
</span>
</div>
</div>
<div class="modal-footer">
<button class="btn-close" @click="closeDetailModal"></button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
//
type OrderStatus = 'pending' | 'processing' | 'completed' | 'cancelled'
//
interface ToClaimOrder {
id: string
orderNo: string
deviceType: string // /
deviceId: string // ID
area: string //
problemDesc: string //
status: OrderStatus //
createTime: string //
lastUploadTime: string //
location: string //
}
//
const orderList: ToClaimOrder[] = [
{
id: '1',
orderNo: 'ORD-20231025-001',
deviceType: '制水机',
deviceId: 'WM-2023-002',
area: '校区',
problemDesc: '出水速度慢,水压不足,需要检修水泵',
status: 'pending',
createTime: '2023-10-25 08:30:15',
lastUploadTime: '2023-10-25 08:25:30',
location: '教学区A栋一楼大厅'
},
{
id: '2',
orderNo: 'ORD-20231025-002',
deviceType: '供水机',
deviceId: 'WS-2023-005',
area: '市区',
problemDesc: '设备显示故障代码E12无法正常出水',
status: 'pending',
createTime: '2023-10-25 09:15:22',
lastUploadTime: '2023-10-25 09:10:05',
location: '市区图书馆二楼北侧'
},
{
id: '3',
orderNo: 'ORD-20231025-003',
deviceType: '制水机',
deviceId: 'WM-2023-003',
area: '市区',
problemDesc: '滤芯更换提醒,需要更换一级滤芯',
status: 'pending',
createTime: '2023-10-24 16:40:08',
lastUploadTime: '2023-10-24 16:35:22',
location: '行政楼一楼大厅'
},
{
id: '4',
orderNo: 'ORD-20231025-004',
deviceType: '供水机',
deviceId: 'WS-2023-004',
area: '校区',
problemDesc: '设备离线,无法上传数据,检查网络连接',
status: 'pending',
createTime: '2023-10-25 10:05:11',
lastUploadTime: '2023-10-25 09:50:45',
location: '学生宿舍3号楼一楼'
}
]
//
const orders = ref<ToClaimOrder[]>(orderList)
const currentPage = ref(1)
const pageSize = 10 //
// /ID
const searchKeyword = ref('')
//
const filterForm = ref({
area: '', //
createDate: '' //
})
//
const showDetailModal = ref(false)
const currentOrder = ref<ToClaimOrder | null>(null)
//
const formatStatus = (status: OrderStatus): string => {
const statusMap = {
pending: '待抢单',
processing: '处理中',
completed: '已完成',
cancelled: '已取消'
}
return statusMap[status]
}
//
const filteredOrders = computed(() => {
return orders.value.filter(order => {
// /ID
const keywordMatch = searchKeyword.value.trim() === '' ||
order.orderNo.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
order.deviceId.toLowerCase().includes(searchKeyword.value.toLowerCase())
//
const areaMatch = filterForm.value.area === '' || order.area === filterForm.value.area
//
const dateMatch = filterForm.value.createDate === '' ||
order.createTime.split(' ')[0] === filterForm.value.createDate
return keywordMatch && areaMatch && dateMatch
})
})
//
const totalPages = computed(() => {
return Math.ceil(filteredOrders.value.length / pageSize)
})
//
const handleSearch = () => {
currentPage.value = 1 //
}
// /
const handleFilter = () => {
currentPage.value = 1 //
}
//
const resetFilter = () => {
searchKeyword.value = '' //
filterForm.value = {
area: '',
createDate: ''
}
currentPage.value = 1
}
//
const viewOrderDetail = (order: ToClaimOrder) => {
currentOrder.value = order
showDetailModal.value = true
}
//
const closeDetailModal = () => {
showDetailModal.value = false
currentOrder.value = null
}
</script>
<style scoped>
.order-to-claim-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
/* 筛选区样式 */
.filter-bar {
display: flex;
align-items: center;
gap: 20px;
padding: 16px;
background-color: #f8f9fa;
border-radius: 4px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filter-item {
display: flex;
align-items: center;
gap: 8px;
}
.filter-item label {
font-size: 14px;
color: #4e5969;
font-weight: 500;
}
/* 搜索输入框样式 */
.search-input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 240px;
}
.filter-select, .filter-input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 160px;
}
.btn-reset {
padding: 8px 16px;
border: 1px solid #ddd;
background-color: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.3s;
}
.btn-reset:hover {
background-color: #f0f0f0;
}
/* 表格样式 */
.order-table {
width: 100%;
border-collapse: collapse;
}
.order-table th,
.order-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.order-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.order-table tbody tr:hover {
background-color: #f8f9fa;
}
/* 设备信息样式 */
.device-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.device-type {
font-weight: 500;
color: #333;
}
.device-id {
font-size: 12px;
color: #666;
}
/* 问题描述单元格 */
.desc-cell {
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.desc-cell:hover {
white-space: normal;
overflow: visible;
background-color: white;
z-index: 10;
position: relative;
}
/* 状态标签样式 */
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.pending {
background-color: #fff7e6;
color: #d48806;
}
.status-tag.processing {
background-color: #e6f7ff;
color: #1890ff;
}
.status-tag.completed {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.cancelled {
background-color: #f5f5f5;
color: #8c8c8c;
}
/* 操作按钮样式 */
.operation-buttons {
display: flex;
gap: 8px;
}
.btn-view {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
background-color: #e6f7ff;
color: #1890ff;
}
.btn-view:hover {
opacity: 0.9;
}
/* 空数据样式 */
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
/* 分页样式 */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
width: 100%;
max-width: 500px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.modal-header {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
color: #333;
}
.modal-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #666;
padding: 0;
line-height: 1;
}
.modal-body {
padding: 20px;
}
.detail-item {
margin-bottom: 16px;
display: flex;
flex-wrap: wrap;
}
.detail-label {
flex: 0 0 120px;
color: #666;
font-size: 14px;
}
.detail-value {
flex: 1;
font-size: 14px;
color: #333;
}
.detail-status {
margin-top: 2px;
}
.modal-footer {
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: flex-end;
}
.btn-close {
padding: 6px 16px;
background-color: #f8f9fa;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.3s;
}
.btn-close:hover {
background-color: #e9ecef;
}
/* 响应式调整 */
@media (max-width: 768px) {
.filter-bar {
flex-direction: column;
align-items: flex-start;
}
.filter-item {
width: 100%;
}
.search-input, .filter-select, .filter-input {
width: 100%;
}
.modal-content {
width: 90%;
}
.detail-label {
flex: 0 0 100px;
}
}
</style>

@ -0,0 +1,460 @@
<!-- src/views/workorder/Processing.vue -->
<template>
<div class="order-processing-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>处理中工单</h2>
<div class="breadcrumb">校园矿化水平台 / 工单管理 / 处理中</div>
</div>
<!-- 检索筛选区 -->
<div class="filter-bar">
<!-- 工单号/设备ID搜索 -->
<div class="filter-item search-item">
<label>搜索</label>
<input
type="text"
v-model="searchKeyword"
class="search-input"
placeholder="输入工单号或设备ID搜索"
@input="handleSearch"
>
</div>
<!-- 片区筛选 -->
<div class="filter-item">
<label>所属片区</label>
<select v-model="filterForm.area" class="filter-select" @change="handleFilter">
<option value="">全部片区</option>
<option value="市区">市区</option>
<option value="校区">校区</option>
</select>
</div>
<!-- 日期筛选 -->
<div class="filter-item">
<label>创建日期</label>
<input
type="date"
v-model="filterForm.createDate"
class="filter-input"
@change="handleFilter"
>
</div>
<!-- 重置按钮 -->
<button class="btn-reset" @click="resetFilter"></button>
</div>
<!-- 工单表格 -->
<div class="card">
<table class="order-table">
<thead>
<tr>
<th>工单号</th>
<th>设备</th>
<th>片区</th>
<th>问题描述</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="order in filteredOrders" :key="order.id">
<td>{{ order.orderNo }}</td>
<td>
<div class="device-info">
<div class="device-type">{{ order.deviceType }}</div>
<div class="device-id">{{ order.deviceId }}</div>
</div>
</td>
<td>{{ order.area }}</td>
<td class="desc-cell">{{ order.problemDesc }}</td>
<td>
<span :class="`status-tag ${order.status}`">
{{ formatStatus(order.status) }}
</span>
</td>
<td>{{ order.createTime }}</td>
<td class="operation-buttons">
<button class="btn-view" @click="viewOrderDetail(order.id)"></button>
</td>
</tr>
<tr v-if="filteredOrders.length === 0">
<td colspan="7" class="no-data">暂无处理中工单</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
<!-- 详情弹窗 -->
<div v-if="showDetailModal" class="modal-overlay">
<div class="modal-container">
<div class="modal-header">
<h3>工单详情</h3>
<button class="modal-close" @click="showDetailModal = false">×</button>
</div>
<div class="modal-body">
<div class="detail-item">
<span class="detail-label">工单号</span>
<span class="detail-value">{{ currentOrder?.orderNo }}</span>
</div>
<div class="detail-item">
<span class="detail-label">建单时间</span>
<span class="detail-value">{{ currentOrder?.createTime }}</span>
</div>
<div class="detail-item">
<span class="detail-label">设备机型</span>
<span class="detail-value">{{ currentOrder?.deviceType }}</span>
</div>
<div class="detail-item">
<span class="detail-label">设备ID</span>
<span class="detail-value">{{ currentOrder?.deviceId }}</span>
</div>
<div class="detail-item">
<span class="detail-label">警告内容</span>
<span class="detail-value">{{ currentOrder?.problemDesc }}</span>
</div>
<div class="detail-item">
<span class="detail-label">设备最后上传时间</span>
<span class="detail-value">{{ currentOrder?.lastUploadTime }}</span>
</div>
<div class="detail-item">
<span class="detail-label">位置</span>
<span class="detail-value">{{ currentOrder?.location }}</span>
</div>
<div class="detail-item">
<span class="detail-label">接单师傅</span>
<span class="detail-value">{{ currentOrder?.maintenanceName }}</span>
</div>
<div class="detail-item">
<span class="detail-label">师傅电话</span>
<span class="detail-value">{{ currentOrder?.maintenancePhone }}</span>
</div>
<div class="detail-item">
<span class="detail-label">当前状态</span>
<span :class="`detail-value status-tag ${currentOrder?.status}`">
{{ currentOrder ? formatStatus(currentOrder.status) : '' }}
</span>
</div>
</div>
<div class="modal-footer">
<button class="btn-close" @click="showDetailModal = false">关闭</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
//
type OrderStatus = 'timeout' | 'pending' | 'processing' | 'reviewing' | 'completed'
//
interface ProcessingOrder {
id: string
orderNo: string
deviceType: string // /
deviceId: string // ID
area: string //
problemDesc: string //
status: OrderStatus //
createTime: string //
lastUploadTime: string //
location: string //
maintenanceName: string //
maintenancePhone: string //
}
//
const orderList: ProcessingOrder[] = [
{
id: '9',
orderNo: 'ORD-20231025-007',
deviceType: '制水机',
deviceId: 'WM-2023-003',
area: '市区',
problemDesc: '正在更换一级滤芯预计1小时内完成',
status: 'processing',
createTime: '2023-10-25 15:10:08',
lastUploadTime: '2023-10-25 15:45:22',
location: '市区商业广场B区3号柜位',
maintenanceName: '李师傅',
maintenancePhone: '13800138000'
},
{
id: '10',
orderNo: 'ORD-20231025-008',
deviceType: '供水机',
deviceId: 'WS-2023-004',
area: '校区',
problemDesc: '正在检查网络连接,设备离线问题排查中',
status: 'processing',
createTime: '2023-10-25 16:05:11',
lastUploadTime: '2023-10-25 13:30:05',
location: '校区图书馆2楼大厅',
maintenanceName: '王师傅',
maintenancePhone: '13900139000'
}
]
//
const orders = ref<ProcessingOrder[]>(orderList)
const currentPage = ref(1)
const pageSize = 10 //
const router = useRouter()
// /ID
const searchKeyword = ref('')
//
const filterForm = ref({
area: '', //
createDate: '' //
})
//
const showDetailModal = ref(false)
const currentOrder = ref<ProcessingOrder | null>(null)
//
const formatStatus = (status: OrderStatus): string => {
const statusMap = {
timeout: '超时未抢',
pending: '待抢单',
processing: '处理中',
reviewing: '待审核',
completed: '已结单'
}
return statusMap[status]
}
//
const filteredOrders = computed(() => {
return orders.value.filter(order => {
// /ID
const keywordMatch = searchKeyword.value.trim() === '' ||
order.orderNo.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
order.deviceId.toLowerCase().includes(searchKeyword.value.toLowerCase())
//
const areaMatch = filterForm.value.area === '' || order.area === filterForm.value.area
//
const dateMatch = filterForm.value.createDate === '' ||
order.createTime.split(' ')[0] === filterForm.value.createDate
return keywordMatch && areaMatch && dateMatch
})
})
//
const totalPages = computed(() => {
return Math.ceil(filteredOrders.value.length / pageSize)
})
//
const handleSearch = () => {
currentPage.value = 1 //
}
// /
const handleFilter = () => {
currentPage.value = 1 //
}
//
const resetFilter = () => {
searchKeyword.value = '' //
filterForm.value = {
area: '',
createDate: ''
}
currentPage.value = 1
}
//
const viewOrderDetail = (id: string) => {
const order = orders.value.find(item => item.id === id)
if (order) {
currentOrder.value = order
showDetailModal.value = true
}
}
</script>
<style scoped>
/* 原有样式保持不变 */
.order-processing-page {
padding: 20px;
}
.page-header { margin-bottom: 24px; }
.page-header h2 { font-size: 24px; font-weight: 600; color: #333; margin-bottom: 8px; }
.breadcrumb { color: #666; font-size: 14px; }
.filter-bar { display: flex; align-items: center; gap: 20px; padding: 16px; background-color: #f8f9fa; border-radius: 4px; margin-bottom: 16px; flex-wrap: wrap; }
.filter-item { display: flex; align-items: center; gap: 8px; }
.filter-item label { font-size: 14px; color: #4e5969; font-weight: 500; }
.search-input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 240px; }
.filter-select, .filter-input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 160px; }
.btn-reset { padding: 8px 16px; border: 1px solid #ddd; background-color: white; border-radius: 4px; cursor: pointer; font-size: 14px; color: #666; transition: all 0.3s; }
.btn-reset:hover { background-color: #f0f0f0; }
.order-table { width: 100%; border-collapse: collapse; }
.order-table th, .order-table td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #f0f0f0; }
.order-table th { background-color: #f8f9fa; font-weight: 600; color: #4e5969; font-size: 14px; }
.order-table tbody tr:hover { background-color: #f8f9fa; }
.device-info { display: flex; flex-direction: column; gap: 4px; }
.device-type { font-weight: 500; color: #333; }
.device-id { font-size: 12px; color: #666; }
.desc-cell { max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
.desc-cell:hover { white-space: normal; overflow: visible; background-color: white; z-index: 10; position: relative; }
.status-tag { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
.status-tag.timeout { background-color: #ffebe6; color: #cf1322; }
.status-tag.pending { background-color: #fff7e6; color: #d48806; }
.status-tag.processing { background-color: #e6f7ff; color: #1890ff; }
.status-tag.reviewing { background-color: #f6f7ff; color: #667eea; }
.status-tag.completed { background-color: #e6f7ee; color: #00875a; }
.operation-buttons { display: flex; gap: 8px; }
.btn-view { padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer; border: none; transition: opacity 0.3s; background-color: #e6f7ff; color: #1890ff; }
.btn-view:hover { opacity: 0.9; }
.no-data { text-align: center; padding: 40px 0; color: #8c8c8c; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 24px; color: #666; font-size: 14px; }
.page-btn { padding: 4px 12px; border: 1px solid #ddd; background: white; border-radius: 4px; cursor: pointer; }
.page-btn:disabled { opacity: 0.6; cursor: not-allowed; }
/* 弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-container {
background-color: white;
border-radius: 8px;
width: 500px;
max-width: 90%;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.modal-header {
padding: 16px 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
color: #333;
}
.modal-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
padding: 0;
line-height: 1;
}
.modal-close:hover {
color: #333;
}
.modal-body {
padding: 20px;
}
.detail-item {
margin-bottom: 16px;
display: flex;
align-items: flex-start;
}
.detail-label {
width: 120px;
flex-shrink: 0;
color: #666;
font-size: 14px;
}
.detail-value {
flex-grow: 1;
font-size: 14px;
color: #333;
}
.modal-footer {
padding: 12px 20px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
}
.btn-close {
padding: 6px 16px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-close:hover {
background-color: #096dd9;
}
/* 响应式调整 */
@media (max-width: 768px) {
.filter-bar { flex-direction: column; align-items: flex-start; }
.filter-item { width: 100%; }
.search-input, .filter-select, .filter-input { width: 100%; }
.detail-item {
flex-direction: column;
}
.detail-label {
width: auto;
margin-bottom: 4px;
font-weight: 500;
}
}
</style>

@ -0,0 +1,281 @@
<!-- src/views/order/OrderReview.vue -->
<template>
<div class="order-review-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>待审核工单</h2>
<div class="breadcrumb">校园矿化水平台 / 工单管理 / 待审核</div>
</div>
<!-- 检索筛选区 -->
<div class="filter-bar">
<!-- 工单号/设备ID搜索 -->
<div class="filter-item search-item">
<label>搜索</label>
<input
type="text"
v-model="searchKeyword"
class="search-input"
placeholder="输入工单号或设备ID搜索"
@input="handleSearch"
>
</div>
<!-- 片区筛选 -->
<div class="filter-item">
<label>所属片区</label>
<select v-model="filterForm.area" class="filter-select" @change="handleFilter">
<option value="">全部片区</option>
<option value="市区">市区</option>
<option value="校区">校区</option>
</select>
</div>
<!-- 日期筛选 -->
<div class="filter-item">
<label>创建日期</label>
<input
type="date"
v-model="filterForm.createDate"
class="filter-input"
@change="handleFilter"
>
</div>
<!-- 重置按钮 -->
<button class="btn-reset" @click="resetFilter"></button>
</div>
<!-- 工单表格 -->
<div class="card">
<table class="order-table">
<thead>
<tr>
<th>工单号</th>
<th>设备</th>
<th>片区</th>
<th>问题描述</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="order in filteredOrders" :key="order.id">
<td>{{ order.orderNo }}</td>
<td>
<div class="device-info">
<div class="device-type">{{ order.deviceType }}</div>
<div class="device-id">{{ order.deviceId }}</div>
</div>
</td>
<td>{{ order.area }}</td>
<td class="desc-cell">{{ order.problemDesc }}</td>
<td>
<span :class="`status-tag ${order.status}`">
{{ formatStatus(order.status) }}
</span>
</td>
<td>{{ order.createTime }}</td>
<td class="operation-buttons">
<button class="btn-view" @click="viewOrderDetail(order.id)"></button>
</td>
</tr>
<tr v-if="filteredOrders.length === 0">
<td colspan="7" class="no-data">暂无待审核工单</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
//
type OrderStatus = 'timeout' | 'pending' | 'processing' | 'reviewing' | 'completed'
//
interface ReviewOrder {
id: string
orderNo: string
deviceType: string // /
deviceId: string // ID
area: string //
problemDesc: string //
status: OrderStatus //
createTime: string //
}
//
const orderList: ReviewOrder[] = [
{
id: '7',
orderNo: 'ORD-20231025-005',
deviceType: '制水机',
deviceId: 'WM-2023-002',
area: '校区',
problemDesc: '已完成水泵检修,出水速度恢复正常',
status: 'reviewing',
createTime: '2023-10-25 11:30:15'
},
{
id: '8',
orderNo: 'ORD-20231025-006',
deviceType: '供水机',
deviceId: 'WS-2023-005',
area: '市区',
problemDesc: '已修复故障代码E12设备出水正常',
status: 'reviewing',
createTime: '2023-10-25 14:20:22'
}
]
//
const orders = ref<ReviewOrder[]>(orderList)
const currentPage = ref(1)
const pageSize = 10 //
const router = useRouter()
// /ID
const searchKeyword = ref('')
//
const filterForm = ref({
area: '', //
createDate: '' //
})
//
const formatStatus = (status: OrderStatus): string => {
const statusMap = {
timeout: '超时未抢',
pending: '待抢单',
processing: '处理中',
reviewing: '待审核',
completed: '已结单'
}
return statusMap[status]
}
//
const filteredOrders = computed(() => {
return orders.value.filter(order => {
// /ID
const keywordMatch = searchKeyword.value.trim() === '' ||
order.orderNo.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
order.deviceId.toLowerCase().includes(searchKeyword.value.toLowerCase())
//
const areaMatch = filterForm.value.area === '' || order.area === filterForm.value.area
//
const dateMatch = filterForm.value.createDate === '' ||
order.createTime.split(' ')[0] === filterForm.value.createDate
return keywordMatch && areaMatch && dateMatch
})
})
//
const totalPages = computed(() => {
return Math.ceil(filteredOrders.value.length / pageSize)
})
//
const handleSearch = () => {
currentPage.value = 1 //
}
// /
const handleFilter = () => {
currentPage.value = 1 //
}
//
const resetFilter = () => {
searchKeyword.value = '' //
filterForm.value = {
area: '',
createDate: ''
}
currentPage.value = 1
}
//
const viewOrderDetail = (id: string) => {
//
router.push(`/home/work-order/review/${id}`)
}
</script>
<style scoped>
/* 样式与超时未抢页面完全一致,仅修改页面容器类名 */
.order-review-page {
padding: 20px;
}
/* 以下样式与OrderTimeout.vue完全相同仅保留状态标签样式示例 */
.page-header { margin-bottom: 24px; }
.page-header h2 { font-size: 24px; font-weight: 600; color: #333; margin-bottom: 8px; }
.breadcrumb { color: #666; font-size: 14px; }
.filter-bar { display: flex; align-items: center; gap: 20px; padding: 16px; background-color: #f8f9fa; border-radius: 4px; margin-bottom: 16px; flex-wrap: wrap; }
.filter-item { display: flex; align-items: center; gap: 8px; }
.filter-item label { font-size: 14px; color: #4e5969; font-weight: 500; }
.search-input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 240px; }
.filter-select, .filter-input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 160px; }
.btn-reset { padding: 8px 16px; border: 1px solid #ddd; background-color: white; border-radius: 4px; cursor: pointer; font-size: 14px; color: #666; transition: all 0.3s; }
.btn-reset:hover { background-color: #f0f0f0; }
.order-table { width: 100%; border-collapse: collapse; }
.order-table th, .order-table td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #f0f0f0; }
.order-table th { background-color: #f8f9fa; font-weight: 600; color: #4e5969; font-size: 14px; }
.order-table tbody tr:hover { background-color: #f8f9fa; }
.device-info { display: flex; flex-direction: column; gap: 4px; }
.device-type { font-weight: 500; color: #333; }
.device-id { font-size: 12px; color: #666; }
.desc-cell { max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
.desc-cell:hover { white-space: normal; overflow: visible; background-color: white; z-index: 10; position: relative; }
.status-tag { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
.status-tag.timeout { background-color: #ffebe6; color: #cf1322; }
.status-tag.pending { background-color: #fff7e6; color: #d48806; }
.status-tag.processing { background-color: #e6f7ff; color: #1890ff; }
.status-tag.reviewing { background-color: #f6f7ff; color: #667eea; }
.status-tag.completed { background-color: #e6f7ee; color: #00875a; }
.operation-buttons { display: flex; gap: 8px; }
.btn-view { padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer; border: none; transition: opacity 0.3s; background-color: #e6f7ff; color: #1890ff; }
.btn-view:hover { opacity: 0.9; }
.no-data { text-align: center; padding: 40px 0; color: #8c8c8c; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 24px; color: #666; font-size: 14px; }
.page-btn { padding: 4px 12px; border: 1px solid #ddd; background: white; border-radius: 4px; cursor: pointer; }
.page-btn:disabled { opacity: 0.6; cursor: not-allowed; }
/* 响应式调整 */
@media (max-width: 768px) {
.filter-bar { flex-direction: column; align-items: flex-start; }
.filter-item { width: 100%; }
.search-input, .filter-select, .filter-input { width: 100%; }
}
</style>

@ -0,0 +1,779 @@
<!-- src/views/order/OrderTimeout.vue -->
<template>
<div class="order-timeout-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>超时未抢工单</h2>
<div class="breadcrumb">校园矿化水平台 / 工单管理 / 超时未抢</div>
</div>
<!-- 检索筛选区 -->
<div class="filter-bar">
<!-- 工单号/设备ID搜索 -->
<div class="filter-item search-item">
<label>搜索</label>
<input
type="text"
v-model="searchKeyword"
class="search-input"
placeholder="输入工单号或设备ID搜索"
@input="handleSearch"
>
</div>
<!-- 片区筛选 -->
<div class="filter-item">
<label>所属片区</label>
<select v-model="filterForm.area" class="filter-select" @change="handleFilter">
<option value="">全部片区</option>
<option value="市区">市区</option>
<option value="校区">校区</option>
</select>
</div>
<!-- 日期筛选 -->
<div class="filter-item">
<label>创建日期</label>
<input
type="date"
v-model="filterForm.createDate"
class="filter-input"
@change="handleFilter"
>
</div>
<!-- 重置按钮 -->
<button class="btn-reset" @click="resetFilter"></button>
</div>
<!-- 工单表格 -->
<div class="card">
<table class="order-table">
<thead>
<tr>
<th>工单号</th>
<th>设备</th>
<th>片区</th>
<th>问题描述</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="order in filteredOrders" :key="order.id">
<td>{{ order.orderNo }}</td>
<td>
<div class="device-info">
<div class="device-type">{{ order.deviceType }}</div>
<div class="device-id">{{ order.deviceId }}</div>
</div>
</td>
<td>{{ order.area }}</td>
<td class="desc-cell">{{ order.problemDesc }}</td>
<td>
<span :class="`status-tag ${order.status}`">
{{ formatStatus(order.status) }}
</span>
</td>
<td>{{ order.createTime }}</td>
<td class="operation-buttons">
<button class="btn-assign" @click="openAssignDialog(order)"></button>
</td>
</tr>
<tr v-if="filteredOrders.length === 0">
<td colspan="7" class="no-data">暂无超时未抢工单</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
<!-- 人工派单弹窗 -->
<div class="dialog-mask" v-if="assignDialogVisible">
<div class="dialog-container">
<div class="dialog-header">
<h3>人工派单</h3>
<button class="dialog-close" @click="closeAssignDialog">×</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label class="form-label">工单号</label>
<div class="form-value">{{ currentOrder?.orderNo || '' }}</div>
</div>
<div class="form-group">
<label class="form-label">建单时间</label>
<div class="form-value">{{ currentOrder?.createTime || '' }}</div>
</div>
<div class="form-group">
<label class="form-label">设备机型与ID</label>
<div class="form-value">
{{ currentOrder?.deviceType || '' }}({{ currentOrder?.deviceId || '' }})
</div>
</div>
<div class="form-group">
<label class="form-label">警告内容</label>
<div class="form-value">{{ currentOrder?.problemDesc || '' }}</div>
</div>
<div class="form-group">
<label class="form-label">设备最后上传时间</label>
<div class="form-value">{{ currentOrder?.lastUploadTime || '未知' }}</div>
</div>
<div class="form-group">
<label class="form-label">设备位置</label>
<div class="form-value">{{ currentOrder?.location || '未知' }}</div>
</div>
<div class="form-group">
<label class="form-label">当前状态</label>
<div class="form-value">
<span :class="`status-tag ${currentOrder?.status}`">
{{ currentOrder ? formatStatus(currentOrder.status) : '' }}
</span>
</div>
</div>
<div class="form-group">
<label class="form-label required">选择维修人员</label>
<select
v-model="selectedStaffId"
class="form-select"
@change="handleStaffChange"
>
<option value="">请选择维修人员</option>
<option
v-for="staff in filteredStaff"
:key="staff.id"
:value="staff.id"
>
{{ staff.name }} ({{ staff.phone }})
</option>
</select>
</div>
<div class="form-group">
<label class="form-label">派单备注</label>
<textarea
v-model="assignRemark"
class="form-textarea"
placeholder="请输入派单备注信息"
rows="3"
></textarea>
</div>
</div>
<div class="dialog-footer">
<button class="btn-cancel" @click="closeAssignDialog"></button>
<button
class="btn-confirm"
@click="confirmAssign"
:disabled="!selectedStaffId"
>
确认派单
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
//
type OrderStatus = 'timeout' | 'pending' | 'processing' | 'reviewing' | 'completed'
//
interface TimeoutOrder {
id: string
orderNo: string
deviceType: string // /
deviceId: string // ID
area: string //
problemDesc: string //
status: OrderStatus //
createTime: string //
lastUploadTime?: string //
location?: string //
}
//
interface MaintenanceStaff {
id: string
name: string
phone: string
area: string //
status: 'active' | 'disabled' //
}
//
const orderList: TimeoutOrder[] = [
{
id: '5',
orderNo: 'ORD-20231024-001',
deviceType: '制水机',
deviceId: 'WM-2023-001',
area: '市区',
problemDesc: '设备漏水,需要更换密封垫',
status: 'timeout',
createTime: '2023-10-24 09:10:05',
lastUploadTime: '2023-10-24 08:50:12',
location: '市区图书馆一楼大厅'
},
{
id: '6',
orderNo: 'ORD-20231024-002',
deviceType: '供水机',
deviceId: 'WS-2023-001',
area: '校区',
problemDesc: '出水口感异常,需要检测水质',
status: 'timeout',
createTime: '2023-10-24 10:20:33',
lastUploadTime: '2023-10-24 10:15:30',
location: '校区宿舍3号楼一层'
}
]
//
const staffList: MaintenanceStaff[] = [
{ id: 's1', name: '张三', phone: '13800138000', area: '市区', status: 'active' },
{ id: 's2', name: '李四', phone: '13900139000', area: '校区', status: 'active' },
{ id: 's3', name: '王五', phone: '13700137000', area: '市区', status: 'active' },
{ id: 's4', name: '赵六', phone: '13600136000', area: '校区', status: 'disabled' }
]
//
const orders = ref<TimeoutOrder[]>(orderList)
const currentPage = ref(1)
const pageSize = 10 //
const router = useRouter()
// /ID
const searchKeyword = ref('')
//
const filterForm = ref({
area: '', //
createDate: '' //
})
//
const assignDialogVisible = ref(false)
const currentOrder = ref<TimeoutOrder | null>(null)
const selectedStaffId = ref('')
const assignRemark = ref('')
const allStaff = ref<MaintenanceStaff[]>(staffList)
//
const formatStatus = (status: OrderStatus): string => {
const statusMap = {
timeout: '超时未抢',
pending: '待抢单',
processing: '处理中',
reviewing: '待审核',
completed: '已结单'
}
return statusMap[status]
}
//
const filteredOrders = computed(() => {
return orders.value.filter(order => {
// /ID
const keywordMatch = searchKeyword.value.trim() === '' ||
order.orderNo.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
order.deviceId.toLowerCase().includes(searchKeyword.value.toLowerCase())
//
const areaMatch = filterForm.value.area === '' || order.area === filterForm.value.area
//
const dateMatch = filterForm.value.createDate === '' ||
order.createTime.split(' ')[0] === filterForm.value.createDate
return keywordMatch && areaMatch && dateMatch
})
})
//
const filteredStaff = computed(() => {
if (!currentOrder.value) return []
return allStaff.value.filter(staff =>
staff.area === currentOrder.value!.area && staff.status === 'active'
)
})
//
const totalPages = computed(() => {
return Math.ceil(filteredOrders.value.length / pageSize)
})
//
const handleSearch = () => {
currentPage.value = 1 //
}
// /
const handleFilter = () => {
currentPage.value = 1 //
}
//
const resetFilter = () => {
searchKeyword.value = '' //
filterForm.value = {
area: '',
createDate: ''
}
currentPage.value = 1
}
//
const openAssignDialog = (order: TimeoutOrder) => {
currentOrder.value = order
selectedStaffId.value = ''
assignRemark.value = ''
assignDialogVisible.value = true
}
//
const closeAssignDialog = () => {
assignDialogVisible.value = false
currentOrder.value = null
selectedStaffId.value = ''
assignRemark.value = ''
}
//
const handleStaffChange = () => {
//
}
//
const confirmAssign = () => {
if (!currentOrder.value || !selectedStaffId.value) return
// API
console.log('派单信息:', {
orderId: currentOrder.value.id,
staffId: selectedStaffId.value,
remark: assignRemark.value,
assignTime: new Date().toLocaleString()
})
//
closeAssignDialog()
//
}
</script>
<style scoped>
/* 基础页面样式 */
.order-timeout-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
/* 筛选区样式 */
.filter-bar {
display: flex;
align-items: center;
gap: 20px;
padding: 16px;
background-color: #f8f9fa;
border-radius: 4px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filter-item {
display: flex;
align-items: center;
gap: 8px;
}
.filter-item label {
font-size: 14px;
color: #4e5969;
font-weight: 500;
}
/* 搜索输入框样式 */
.search-input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 240px;
}
.filter-select, .filter-input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 160px;
}
.btn-reset {
padding: 8px 16px;
border: 1px solid #ddd;
background-color: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.3s;
}
.btn-reset:hover {
background-color: #f0f0f0;
}
/* 表格样式 */
.order-table {
width: 100%;
border-collapse: collapse;
}
.order-table th,
.order-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.order-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.order-table tbody tr:hover {
background-color: #f8f9fa;
}
/* 设备信息样式 */
.device-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.device-type {
font-weight: 500;
color: #333;
}
.device-id {
font-size: 12px;
color: #666;
}
/* 问题描述单元格 */
.desc-cell {
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.desc-cell:hover {
white-space: normal;
overflow: visible;
background-color: white;
z-index: 10;
position: relative;
}
/* 状态标签样式 */
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.timeout {
background-color: #ffebe6;
color: #cf1322;
}
.status-tag.pending {
background-color: #fff7e6;
color: #d48806;
}
.status-tag.processing {
background-color: #e6f7ff;
color: #1890ff;
}
.status-tag.reviewing {
background-color: #f6f7ff;
color: #667eea;
}
.status-tag.completed {
background-color: #e6f7ee;
color: #00875a;
}
/* 操作按钮样式 */
.operation-buttons {
display: flex;
gap: 8px;
}
.btn-assign {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
background-color: #e6f7ff;
color: #1890ff;
}
.btn-assign:hover {
opacity: 0.9;
}
/* 空数据样式 */
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
/* 分页样式 */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 弹窗样式 */
.dialog-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.dialog-container {
width: 600px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.dialog-header {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-header h3 {
margin: 0;
font-size: 18px;
color: #333;
}
.dialog-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
transition: color 0.2s;
}
.dialog-close:hover {
color: #333;
}
.dialog-body {
padding: 20px;
}
.form-group {
margin-bottom: 16px;
display: flex;
align-items: flex-start;
}
.form-label {
width: 120px;
flex-shrink: 0;
font-size: 14px;
color: #4e5969;
padding-top: 6px;
}
.form-label.required::after {
content: '*';
color: #cf1322;
margin-left: 4px;
}
.form-value {
flex-grow: 1;
font-size: 14px;
color: #333;
padding-top: 4px;
}
.form-select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
resize: vertical;
box-sizing: border-box;
}
.dialog-footer {
padding: 16px 20px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.btn-cancel {
padding: 8px 16px;
border: 1px solid #ddd;
background-color: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.3s;
}
.btn-cancel:hover {
background-color: #f0f0f0;
}
.btn-confirm {
padding: 8px 16px;
border: none;
background-color: #1890ff;
color: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.btn-confirm:hover {
background-color: #096dd9;
}
.btn-confirm:disabled {
background-color: #8cc5ff;
cursor: not-allowed;
}
/* 响应式调整 */
@media (max-width: 768px) {
.filter-bar {
flex-direction: column;
align-items: flex-start;
}
.filter-item {
width: 100%;
}
.search-input, .filter-select, .filter-input {
width: 100%;
}
.dialog-container {
width: 90%;
max-width: 500px;
}
.form-group {
flex-direction: column;
}
.form-label {
width: 100%;
margin-bottom: 8px;
padding-top: 0;
}
}
</style>

@ -0,0 +1,7 @@
<!-- src/views/workorder/WorkOrderView.vue -->
<template>
<div class="page-container">
<h1>工单管理</h1>
<p>请选择左侧子菜单查看具体工单信息</p>
</div>
</template>

@ -6,7 +6,11 @@
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"lib": ["ES2015", "DOM"], // ES2015 Promise
"target": "ES2015", // ES2015
"module": "ESNext", //
"moduleResolution": "NodeNext"
}
}
}

@ -1,8 +1,5 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
@ -11,7 +8,7 @@ export default defineConfig({
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
'@': '/src'
},
},
})

Loading…
Cancel
Save