Compare commits

...

1 Commits

Author SHA1 Message Date
梁晨旭 d4b953b636 web端原型
3 months ago

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="9c052fa2-877c-4191-94f3-f62ad42d1e2e" name="更改" comment="第五周会议纪要" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 0
}</component>
<component name="ProjectId" id="34H6gyG9l2ZY5ipgc3TKqAY36ls" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;develop&quot;,
&quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
&quot;kotlin-language-version-configured&quot;: &quot;true&quot;,
&quot;last_opened_file_path&quot;: &quot;D:/软件工程导论/团队项目/WaterManager&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-jdk-9823dce3aa75-bf35d07a577b-intellij.indexing.shared.core-IU-252.23892.409" />
<option value="bundled-js-predefined-d6986cc7102b-e03c56caf84a-JavaScript-IU-252.23892.409" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="默认任务">
<changelist id="9c052fa2-877c-4191-94f3-f62ad42d1e2e" name="更改" comment="" />
<created>1760858265187</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1760858265187</updated>
<workItem from="1760858266545" duration="750000" />
<workItem from="1761132012625" duration="34000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State />
</value>
</entry>
</map>
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="第五周会议纪要" />
<option name="LAST_COMMIT_MESSAGE" value="第五周会议纪要" />
</component>
</project>

@ -0,0 +1,32 @@
# Dependencies
node_modules
/.pnp
.pnp.js
# Testing
/coverage
# Production
/build
/dist
# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor
.idea
.vscode
*.swp
*.swo
*~
# Vite
.vite

@ -0,0 +1,96 @@
# 数智水管家 Web端
一个现代化的水资源管理系统Web端原型界面。
## 功能特性
- 📊 **仪表盘** - 系统概览和关键指标展示
- 🔍 **实时监控** - 实时查看设备状态和用水数据
- 📈 **用水统计** - 详细的用水数据分析报告
- ⚙️ **设备管理** - 管理所有智能设备的状态和信息
## 技术栈
- React 18
- React Router
- Recharts (数据可视化)
- Vite (构建工具)
## 快速开始
### 安装依赖
```bash
npm install
```
### 启动开发服务器
```bash
npm run dev
```
访问 http://localhost:3000 查看应用
### 构建生产版本
```bash
npm run build
```
## 项目结构
```
WaterManager_Web/
├── src/
│ ├── pages/ # 页面组件
│ │ ├── Dashboard.jsx # 仪表盘
│ │ ├── Monitoring.jsx # 实时监控
│ │ ├── Statistics.jsx # 用水统计
│ │ └── Devices.jsx # 设备管理
│ ├── App.jsx # 主应用组件
│ ├── App.css # 应用样式
│ ├── index.css # 全局样式
│ └── main.jsx # 入口文件
├── index.html
├── package.json
└── vite.config.js
```
## 页面说明
### 仪表盘
- 显示今日用水量、在线设备、本月总用水、告警数量等关键指标
- 24小时用水趋势图表
- 本周用水统计柱状图
- 最近告警列表
### 实时监控
- 实时流量、系统压力、活跃设备等数据
- 实时数据趋势折线图
- 设备监控列表,显示各设备的实时状态
### 用水统计
- 支持日/月/年统计切换
- 月度用水趋势分析
- 用水分类占比饼图
- 24小时用水分布
- 详细统计数据表格
### 设备管理
- 设备总数、在线/离线/告警设备统计
- 设备列表展示
- 支持按状态筛选和关键词搜索
- 显示设备电池电量和信号强度
## 特性
- 🎨 现代化UI设计
- 📱 响应式布局,支持移动端
- 🚀 快速加载和流畅交互
- 📊 丰富的数据可视化
- 🔔 实时状态监控
## License
MIT

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数智水管家</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,23 @@
{
"name": "water-manager-web",
"version": "1.0.0",
"description": "数智水管家Web端原型界面",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"recharts": "^2.10.3"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.8"
}
}

@ -0,0 +1,482 @@
.app {
display: flex;
height: 100vh;
overflow: hidden;
}
/* 侧边栏样式 */
.sidebar {
width: 260px;
background: linear-gradient(180deg, #1e3a8a 0%, #3b82f6 100%);
color: white;
display: flex;
flex-direction: column;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
}
.sidebar-header {
padding: 24px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebar-header h1 {
font-size: 20px;
font-weight: 600;
margin: 0;
}
.sidebar-nav {
flex: 1;
padding: 20px 0;
}
.nav-item {
display: flex;
align-items: center;
padding: 14px 20px;
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
transition: all 0.3s;
border-left: 3px solid transparent;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
border-left-color: white;
}
.nav-item.active {
background: rgba(255, 255, 255, 0.15);
color: white;
border-left-color: white;
font-weight: 500;
}
.nav-icon {
font-size: 20px;
margin-right: 12px;
width: 24px;
text-align: center;
}
.nav-label {
font-size: 15px;
}
.sidebar-footer {
padding: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.user-details {
flex: 1;
}
.user-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 2px;
}
.user-role {
font-size: 12px;
opacity: 0.7;
}
/* 主内容区域 */
.main-content {
flex: 1;
overflow-y: auto;
background: #f5f7fa;
}
/* 页面容器 */
.page-container {
padding: 32px;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
margin-bottom: 32px;
}
.page-title {
font-size: 28px;
font-weight: 600;
color: #1f2937;
margin-bottom: 8px;
}
.page-subtitle {
font-size: 14px;
color: #6b7280;
}
/* 卡片样式 */
.card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 24px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card-title {
font-size: 18px;
font-weight: 600;
color: #1f2937;
}
.card-body {
color: #6b7280;
}
/* 统计卡片网格 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.stat-label {
font-size: 14px;
color: #6b7280;
font-weight: 500;
}
.stat-icon {
font-size: 32px;
opacity: 0.8;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: #1f2937;
margin-bottom: 8px;
}
.stat-change {
font-size: 13px;
display: flex;
align-items: center;
gap: 4px;
}
.stat-change.positive {
color: #10b981;
}
.stat-change.negative {
color: #ef4444;
}
/* 按钮样式 */
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover {
background: #d1d5db;
}
/* 表格样式 */
.table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
}
.table th,
.table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.table th {
background: #f9fafb;
font-weight: 600;
color: #374151;
font-size: 13px;
text-transform: uppercase;
}
.table td {
color: #6b7280;
font-size: 14px;
}
.table tr:hover {
background: #f9fafb;
}
/* 状态标签 */
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.status-online {
background: #d1fae5;
color: #065f46;
}
.status-offline {
background: #fee2e2;
color: #991b1b;
}
.status-warning {
background: #fef3c7;
color: #92400e;
}
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-content {
background: white;
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 32px;
color: #6b7280;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.modal-close:hover {
background: #f3f4f6;
color: #374151;
}
.modal-body {
padding: 24px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 24px;
border-top: 1px solid #e5e7eb;
}
/* 侧边栏退出按钮 */
.logout-button {
margin-top: 12px;
width: 100%;
padding: 10px 20px;
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.logout-button:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
}
/* 加载状态 */
.loading-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
flex-direction: column;
gap: 16px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.sidebar {
width: 80px;
}
.nav-label {
display: none;
}
.sidebar-header h1 {
font-size: 16px;
}
.user-info {
flex-direction: column;
text-align: center;
}
.user-details {
display: none;
}
.page-container {
padding: 16px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.modal-content {
width: 95%;
margin: 20px;
}
}

@ -0,0 +1,128 @@
import React from 'react'
import { BrowserRouter as Router, Routes, Route, Link, useLocation, Navigate, Outlet, useNavigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import Dashboard from './pages/Dashboard'
import Monitoring from './pages/Monitoring'
import Statistics from './pages/Statistics'
import Devices from './pages/Devices'
import UserManagement from './pages/UserManagement'
import Login from './pages/Login'
import DeviceDetails from './pages/DeviceDetails'
import Home from './pages/Home'
import Orders from './pages/Orders'
import WorkOrders from './pages/WorkOrders'
import Dispensers from './pages/Dispensers'
import FilterDetails from './pages/FilterDetails'
import './App.css'
function Sidebar() {
const location = useLocation()
const { user, logout } = useAuth()
const navigate = useNavigate()
const menuItems = [
{ path: '/home', icon: '🏠', label: '首页' },
{ path: '/', icon: '📊', label: '仪表盘' },
{ path: '/monitoring', icon: '🔍', label: '实时监控' },
{ path: '/statistics', icon: '📈', label: '用水统计' },
{ path: '/devices', icon: '⚙️', label: '设备管理' },
{ path: '/dispensers', icon: '🚰', label: '滤芯总览' },
{ path: '/orders', icon: '🧾', label: '历史订单' },
{ path: '/work-orders', icon: '🧰', label: '工单记录' },
{ path: '/users', icon: '👥', label: '用户管理' },
]
const handleLogout = () => {
if (window.confirm('确定要退出登录吗?')) {
logout()
navigate('/login')
}
}
return (
<aside className="sidebar">
<div className="sidebar-header">
<h1>💧 数智水管家</h1>
</div>
<nav className="sidebar-nav">
{menuItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`nav-item ${location.pathname === item.path || location.pathname.startsWith(item.path + '/') ? 'active' : ''}`}
>
<span className="nav-icon">{item.icon}</span>
<span className="nav-label">{item.label}</span>
</Link>
))}
</nav>
<div className="sidebar-footer">
<div className="user-info">
<div className="user-avatar">👤</div>
<div className="user-details">
<div className="user-name">{user?.name || '管理员'}</div>
<div className="user-role">系统管理员</div>
</div>
</div>
<button className="logout-button" onClick={handleLogout}>
<span>🚪</span>
<span>退出登录</span>
</button>
</div>
</aside>
)
}
function ProtectedRoute({ children }) {
const { isAuthenticated, loading } = useAuth()
if (loading) {
return (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>加载中...</p>
</div>
)
}
return isAuthenticated ? children : <Navigate to="/login" replace />
}
function Layout() {
return (
<div className="app">
<Sidebar />
<main className="main-content">
<Outlet />
</main>
</div>
)
}
function App() {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<Layout />}>
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/home" element={<ProtectedRoute><Home /></ProtectedRoute>} />
<Route path="/monitoring" element={<ProtectedRoute><Monitoring /></ProtectedRoute>} />
<Route path="/statistics" element={<ProtectedRoute><Statistics /></ProtectedRoute>} />
<Route path="/devices" element={<ProtectedRoute><Devices /></ProtectedRoute>} />
<Route path="/devices/:id" element={<ProtectedRoute><DeviceDetails /></ProtectedRoute>} />
<Route path="/dispensers" element={<ProtectedRoute><Dispensers /></ProtectedRoute>} />
<Route path="/filters/:id" element={<ProtectedRoute><FilterDetails /></ProtectedRoute>} />
<Route path="/orders" element={<ProtectedRoute><Orders /></ProtectedRoute>} />
<Route path="/work-orders" element={<ProtectedRoute><WorkOrders /></ProtectedRoute>} />
<Route path="/users" element={<ProtectedRoute><UserManagement /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</Router>
</AuthProvider>
)
}
export default App

@ -0,0 +1,58 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
const AuthContext = createContext(null)
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
//
const savedUser = localStorage.getItem('user')
if (savedUser) {
setUser(JSON.parse(savedUser))
}
setLoading(false)
}, [])
const login = (username, password) => {
// API
if (username === 'admin' && password === 'admin123') {
const userData = {
id: '1',
username: 'admin',
name: '管理员',
role: 'admin',
email: 'admin@watermanager.com'
}
setUser(userData)
localStorage.setItem('user', JSON.stringify(userData))
return { success: true }
}
return { success: false, message: '用户名或密码错误' }
}
const logout = () => {
setUser(null)
localStorage.removeItem('user')
}
const value = {
user,
login,
logout,
loading,
isAuthenticated: !!user,
isAdmin: user?.role === 'admin'
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

@ -0,0 +1,20 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f7fa;
color: #333;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

@ -0,0 +1,173 @@
import React from 'react'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts'
const dailyData = [
{ time: '00:00', usage: 45 },
{ time: '04:00', usage: 32 },
{ time: '08:00', usage: 78 },
{ time: '12:00', usage: 95 },
{ time: '16:00', usage: 88 },
{ time: '20:00', usage: 72 },
{ time: '24:00', usage: 55 },
]
const weeklyData = [
{ day: '周一', usage: 320 },
{ day: '周二', usage: 345 },
{ day: '周三', usage: 298 },
{ day: '周四', usage: 365 },
{ day: '周五', usage: 388 },
{ day: '周六', usage: 275 },
{ day: '周日', usage: 290 },
]
export default function Dashboard() {
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">仪表盘</h1>
<p className="page-subtitle">查看系统概览和关键指标</p>
</div>
{/* 统计卡片 */}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">今日用水量</div>
<div className="stat-value">3,245 L</div>
<div className="stat-change positive">
<span></span>
<span>较昨日增加 12.5%</span>
</div>
</div>
<div className="stat-icon">💧</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">在线设备</div>
<div className="stat-value">48 / 52</div>
<div className="stat-change positive">
<span></span>
<span>设备正常率 92.3%</span>
</div>
</div>
<div className="stat-icon">🔌</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">本月总用水</div>
<div className="stat-value">95,680 L</div>
<div className="stat-change negative">
<span></span>
<span>较上月减少 8.2%</span>
</div>
</div>
<div className="stat-icon">📊</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">告警数量</div>
<div className="stat-value">3</div>
<div className="stat-change negative">
<span></span>
<span>需要处理</span>
</div>
</div>
<div className="stat-icon"></div>
</div>
</div>
</div>
{/* 图表区域 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px', marginBottom: '32px' }}>
<div className="card">
<div className="card-header">
<h2 className="card-title">24小时用水趋势</h2>
</div>
<div className="card-body">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={dailyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="usage" stroke="#3b82f6" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="card">
<div className="card-header">
<h2 className="card-title">本周用水统计</h2>
</div>
<div className="card-body">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={weeklyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="day" />
<YAxis />
<Tooltip />
<Bar dataKey="usage" fill="#3b82f6" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* 最近告警 */}
<div className="card">
<div className="card-header">
<h2 className="card-title">最近告警</h2>
<button className="btn btn-secondary">查看全部</button>
</div>
<div className="card-body">
<table className="table">
<thead>
<tr>
<th>时间</th>
<th>设备</th>
<th>告警类型</th>
<th>严重程度</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr>
<td>2024-01-15 14:23</td>
<td>水表 #A001</td>
<td>流量异常</td>
<td><span className="status-badge status-warning">中等</span></td>
<td><span className="status-badge status-online">已处理</span></td>
</tr>
<tr>
<td>2024-01-15 10:15</td>
<td>水表 #B045</td>
<td>设备离线</td>
<td><span className="status-badge status-warning">中等</span></td>
<td><span className="status-badge status-warning">处理中</span></td>
</tr>
<tr>
<td>2024-01-14 18:45</td>
<td>水表 #C123</td>
<td>压力异常</td>
<td><span className="status-badge status-online"></span></td>
<td><span className="status-badge status-online">已处理</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
)
}

@ -0,0 +1,219 @@
import React from 'react'
import { useParams, useNavigate } from 'react-router-dom'
//
const deviceDetailsMap = {
A001: {
name: '智能水表-A001',
type: '饮水机',
drinkingDispenserModel: 'AquaPro-D300',
filterModel: 'CF-RO-75G',
installDate: '2023-01-15',
expectedLifetimeMonths: 24,
},
A002: {
name: '智能水表-A002',
type: '饮水机',
drinkingDispenserModel: 'AquaPro-D300',
filterModel: 'CF-RO-75G',
installDate: '2023-01-15',
expectedLifetimeMonths: 24,
},
A003: {
name: '智能水表-A003',
type: '饮水机',
drinkingDispenserModel: 'Hydra-Pure X2',
filterModel: 'CF-RO-100G',
installDate: '2023-01-20',
expectedLifetimeMonths: 18,
},
A004: {
name: '智能水表-A004',
type: '饮水机',
drinkingDispenserModel: 'Hydra-Pure X2',
filterModel: 'CF-RO-100G',
installDate: '2023-02-01',
expectedLifetimeMonths: 18,
},
A005: {
name: '智能水表-A005',
type: '饮水机',
drinkingDispenserModel: 'ClearFlow S1',
filterModel: 'CF-PP-5',
installDate: '2023-02-01',
expectedLifetimeMonths: 12,
},
B001: {
name: '压力传感器-B001',
type: '滤芯组件',
drinkingDispenserModel: 'N/A',
filterModel: 'CF-PP-1',
installDate: '2023-01-10',
expectedLifetimeMonths: 12,
},
B002: {
name: '流量计-B002',
type: '滤芯组件',
drinkingDispenserModel: 'N/A',
filterModel: 'CF-CTO-10',
installDate: '2023-01-12',
expectedLifetimeMonths: 12,
},
C001: {
name: '水质监测-C001',
type: '滤芯组件',
drinkingDispenserModel: 'N/A',
filterModel: 'CF-UF-0.01',
installDate: '2023-03-01',
expectedLifetimeMonths: 24,
},
}
function formatDate(date) {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const d = `${date.getDate()}`.padStart(2, '0')
return `${y}-${m}-${d}`
}
function addMonths(date, months) {
const d = new Date(date)
d.setMonth(d.getMonth() + months)
return d
}
export default function DeviceDetails() {
const { id } = useParams()
const navigate = useNavigate()
const details = deviceDetailsMap[id]
if (!details) {
return (
<div className="page-container">
<div className="card">
<div className="card-body">
<h2 className="card-title">未找到设备</h2>
<p style={{ color: '#6b7280' }}>设备 ID {id} 的详情不存在</p>
<button className="btn btn-primary" onClick={() => navigate(-1)}>返回</button>
</div>
</div>
</div>
)
}
const install = new Date(details.installDate)
const expectedReplace = addMonths(install, details.expectedLifetimeMonths)
const today = new Date()
const remainingDays = Math.ceil((expectedReplace.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
const riskLevel = remainingDays <= 0 ? 'expired' : remainingDays <= 30 ? 'warning' : 'normal'
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">设备详情</h1>
<p className="page-subtitle">查看饮水机与滤芯的型号安装与寿命信息</p>
</div>
<div className="card">
<div className="card-header">
<h2 className="card-title">基本信息</h2>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-secondary" onClick={() => navigate(-1)}>返回</button>
<button className="btn btn-primary" onClick={() => navigate('/devices')}>设备列表</button>
</div>
</div>
<div className="card-body">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '16px' }}>
<div>
<div className="stat-label">设备 ID</div>
<div className="stat-value" style={{ fontSize: '18px' }}>{id}</div>
</div>
<div>
<div className="stat-label">设备名称</div>
<div className="stat-value" style={{ fontSize: '18px' }}>{details.name}</div>
</div>
<div>
<div className="stat-label">设备类型</div>
<div className="stat-value" style={{ fontSize: '18px' }}>{details.type}</div>
</div>
</div>
</div>
</div>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">饮水机型号</div>
<div className="stat-value">{details.drinkingDispenserModel}</div>
</div>
<div className="stat-icon">🚰</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">滤芯型号</div>
<div className="stat-value">{details.filterModel}</div>
</div>
<div className="stat-icon">🧪</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">安装日期</div>
<div className="stat-value">{details.installDate}</div>
</div>
<div className="stat-icon">📅</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">预计使用寿命</div>
<div className="stat-value">{details.expectedLifetimeMonths} 个月</div>
</div>
<div className="stat-icon"></div>
</div>
</div>
</div>
<div className="card">
<div className="card-header">
<h2 className="card-title">寿命与更换建议</h2>
</div>
<div className="card-body">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '16px' }}>
<div>
<div className="stat-label">预计更换日期</div>
<div className="stat-value" style={{ fontSize: '18px' }}>{formatDate(expectedReplace)}</div>
</div>
<div>
<div className="stat-label">剩余天数</div>
<div className="stat-value" style={{ fontSize: '18px' }}>{remainingDays >= 0 ? remainingDays : 0} </div>
</div>
<div>
<div className="stat-label">健康状态</div>
<div>
<span className={`status-badge ${riskLevel === 'normal' ? 'status-online' : riskLevel === 'warning' ? 'status-warning' : 'status-offline'}`}>
{riskLevel === 'normal' ? '正常' : riskLevel === 'warning' ? '即将到期' : '已过期'}
</span>
</div>
</div>
</div>
<div style={{ marginTop: '16px', color: '#6b7280' }}>
建议在预计更换日期前 2 周准备更换耗材保障饮水安全与水质稳定
</div>
</div>
</div>
</div>
)
}

@ -0,0 +1,217 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
const devices = [
{ id: 'A001', name: '智能水表-A001', type: '智能水表', location: '1号楼-101', installDate: '2023-01-15', status: 'online', battery: 85, signal: 92 },
{ id: 'A002', name: '智能水表-A002', type: '智能水表', location: '1号楼-102', installDate: '2023-01-15', status: 'online', battery: 78, signal: 88 },
{ id: 'A003', name: '智能水表-A003', type: '智能水表', location: '1号楼-201', installDate: '2023-01-20', status: 'online', battery: 92, signal: 95 },
{ id: 'A004', name: '智能水表-A004', type: '智能水表', location: '2号楼-101', installDate: '2023-02-01', status: 'offline', battery: 15, signal: 0 },
{ id: 'A005', name: '智能水表-A005', type: '智能水表', location: '2号楼-102', installDate: '2023-02-01', status: 'online', battery: 88, signal: 90 },
{ id: 'B001', name: '压力传感器-B001', type: '压力传感器', location: '主供水管道', installDate: '2023-01-10', status: 'online', battery: 65, signal: 85 },
{ id: 'B002', name: '流量计-B002', type: '流量计', location: '1号入口', installDate: '2023-01-12', status: 'online', battery: 70, signal: 88 },
{ id: 'C001', name: '水质监测-C001', type: '水质监测器', location: '净水站', installDate: '2023-03-01', status: 'warning', battery: 45, signal: 75 },
]
export default function Devices() {
const [filter, setFilter] = useState('all')
const [searchTerm, setSearchTerm] = useState('')
const navigate = useNavigate()
const filteredDevices = devices.filter(device => {
const matchFilter = filter === 'all' || device.status === filter
const matchSearch = device.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
device.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
device.location.toLowerCase().includes(searchTerm.toLowerCase())
return matchFilter && matchSearch
})
const stats = {
total: devices.length,
online: devices.filter(d => d.status === 'online').length,
offline: devices.filter(d => d.status === 'offline').length,
warning: devices.filter(d => d.status === 'warning').length,
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">设备管理</h1>
<p className="page-subtitle">管理所有智能设备的状态和信息</p>
</div>
{/* 设备统计 */}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">设备总数</div>
<div className="stat-value">{stats.total}</div>
</div>
<div className="stat-icon">📱</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">在线设备</div>
<div className="stat-value">{stats.online}</div>
<div className="stat-change positive">
<span>正常</span>
</div>
</div>
<div className="stat-icon"></div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">离线设备</div>
<div className="stat-value">{stats.offline}</div>
<div className="stat-change negative">
<span>需检查</span>
</div>
</div>
<div className="stat-icon"></div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">告警设备</div>
<div className="stat-value">{stats.warning}</div>
<div className="stat-change negative">
<span>需处理</span>
</div>
</div>
<div className="stat-icon"></div>
</div>
</div>
</div>
{/* 筛选和搜索 */}
<div className="card">
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className={`btn ${filter === 'all' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setFilter('all')}
>
全部
</button>
<button
className={`btn ${filter === 'online' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setFilter('online')}
>
在线
</button>
<button
className={`btn ${filter === 'offline' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setFilter('offline')}
>
离线
</button>
<button
className={`btn ${filter === 'warning' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setFilter('warning')}
>
告警
</button>
</div>
<input
type="text"
placeholder="搜索设备ID、名称或位置..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
padding: '10px 16px',
border: '1px solid #e5e7eb',
borderRadius: '8px',
fontSize: '14px',
flex: '1',
minWidth: '200px',
}}
/>
<button className="btn btn-primary">添加设备</button>
</div>
</div>
{/* 设备列表 */}
<div className="card">
<div className="card-header">
<h2 className="card-title">设备列表</h2>
<button className="btn btn-secondary">批量操作</button>
</div>
<div className="card-body">
<table className="table">
<thead>
<tr>
<th>设备ID</th>
<th>设备名称</th>
<th>设备类型</th>
<th>安装位置</th>
<th>安装日期</th>
<th>状态</th>
<th>信号强度</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{filteredDevices.map((device) => (
<tr key={device.id}>
<td>{device.id}</td>
<td>{device.name}</td>
<td>{device.type}</td>
<td>{device.location}</td>
<td>{device.installDate}</td>
<td>
<span className={`status-badge ${
device.status === 'online' ? 'status-online' :
device.status === 'offline' ? 'status-offline' :
'status-warning'
}`}>
{device.status === 'online' ? '在线' :
device.status === 'offline' ? '离线' :
'告警'}
</span>
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{
width: '60px',
height: '8px',
background: '#e5e7eb',
borderRadius: '4px',
overflow: 'hidden'
}}>
<div style={{
width: `${device.signal}%`,
height: '100%',
background: '#3b82f6',
transition: 'width 0.3s'
}} />
</div>
<span style={{ fontSize: '12px', color: '#6b7280' }}>{device.signal}%</span>
</div>
</td>
<td>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-secondary" style={{ padding: '4px 12px', fontSize: '12px' }} onClick={() => navigate(`/devices/${device.id}`)}>
详情
</button>
<button className="btn btn-secondary" style={{ padding: '4px 12px', fontSize: '12px' }}>
配置
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

@ -0,0 +1,193 @@
import React, { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { addBatchWorkOrders } from '../utils/workOrdersStore'
//
const dispensers = [
{ id: 'D0001', name: '饮水机-1号楼-101', location: '1号楼-1层-101', installDate: '2024-02-15', filterModel: 'CF-RO-75G', dispenserModel: 'AquaPro-D300', expectedLifetimeMonths: 12 },
{ id: 'D0002', name: '饮水机-1号楼-201', location: '1号楼-2层-201', installDate: '2024-05-06', filterModel: 'CF-PP-5', dispenserModel: 'ClearFlow S1', expectedLifetimeMonths: 12 },
{ id: 'D0003', name: '饮水机-2号楼-大厅', location: '2号楼-1层-大厅', installDate: '2023-12-20', filterModel: 'CF-CTO-10', dispenserModel: 'Hydra-Pure X2', expectedLifetimeMonths: 18 },
{ id: 'D0004', name: '饮水机-2号楼-305', location: '2号楼-3层-305', installDate: '2023-11-01', filterModel: 'CF-UF-0.01', dispenserModel: 'Hydra-Pure X2', expectedLifetimeMonths: 18 },
{ id: 'D0005', name: '饮水机-3号楼-101', location: '3号楼-1层-101', installDate: '2023-08-10', filterModel: 'CF-RO-100G', dispenserModel: 'AquaPro-D300', expectedLifetimeMonths: 24 },
{ id: 'D0006', name: '饮水机-3号楼-201', location: '3号楼-2层-201', installDate: '2024-01-12', filterModel: 'CF-PP-5', dispenserModel: 'ClearFlow S1', expectedLifetimeMonths: 12 },
{ id: 'D0007', name: '饮水机-4号楼-大厅', location: '4号楼-1层-大厅', installDate: '2023-10-05', filterModel: 'CF-CTO-10', dispenserModel: 'Hydra-Pure X2', expectedLifetimeMonths: 18 },
{ id: 'D0008', name: '饮水机-4号楼-210', location: '4号楼-2层-210', installDate: '2024-03-08', filterModel: 'CF-RO-75G', dispenserModel: 'AquaPro-D300', expectedLifetimeMonths: 12 },
{ id: 'D0009', name: '饮水机-5号楼-101', location: '5号楼-1层-101', installDate: '2023-09-28', filterModel: 'CF-RO-100G', dispenserModel: 'AquaPro-D300', expectedLifetimeMonths: 24 },
{ id: 'D0010', name: '饮水机-5号楼-305', location: '5号楼-3层-305', installDate: '2024-06-01', filterModel: 'CF-PP-5', dispenserModel: 'ClearFlow S1', expectedLifetimeMonths: 12 },
{ id: 'D0011', name: '饮水机-教学楼A-大厅', location: '教学楼A-1层-大厅', installDate: '2023-07-15', filterModel: 'CF-UF-0.01', dispenserModel: 'Hydra-Pure X2', expectedLifetimeMonths: 18 },
{ id: 'D0012', name: '饮水机-图书馆-2F', location: '图书馆-2层-202', installDate: '2024-04-18', filterModel: 'CF-RO-75G', dispenserModel: 'AquaPro-D300', expectedLifetimeMonths: 12 },
]
function addMonths(date, months) { const d = new Date(date); d.setMonth(d.getMonth() + months); return d }
function daysBetween(a, b) { return Math.ceil((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24)) }
export default function Dispensers() {
const navigate = useNavigate()
const [tab, setTab] = useState('need') // need | all | ok
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(25)
const [selectedIds, setSelectedIds] = useState([])
const data = useMemo(() => {
const today = new Date()
const enriched = dispensers.map(d => {
const install = new Date(d.installDate)
const replaceAt = addMonths(install, d.expectedLifetimeMonths)
const remainingDays = daysBetween(today, replaceAt)
const needReplace = remainingDays <= 0 || remainingDays <= 14
return { ...d, replaceAt, remainingDays, needReplace }
})
//
enriched.sort((a, b) => {
if (a.needReplace !== b.needReplace) return a.needReplace ? -1 : 1
return a.remainingDays - b.remainingDays
})
return enriched
}, [])
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
return data.filter(x => {
const byTab = tab === 'all' ? true : tab === 'need' ? x.needReplace : !x.needReplace
const bySearch = !q || [x.id, x.name, x.location, x.filterModel, x.dispenserModel].some(v => String(v).toLowerCase().includes(q))
return byTab && bySearch
})
}, [data, tab, search])
const stats = useMemo(() => ({
total: data.length,
need: data.filter(x => x.needReplace).length,
ok: data.filter(x => !x.needReplace).length,
}), [data])
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize))
const currentPage = Math.min(page, totalPages)
const paged = useMemo(() => {
const start = (currentPage - 1) * pageSize
return filtered.slice(start, start + pageSize)
}, [filtered, currentPage, pageSize])
const allPageNeedIds = useMemo(() => paged.filter(x => x.needReplace).map(x => x.id), [paged])
const isAllSelected = allPageNeedIds.length > 0 && allPageNeedIds.every(id => selectedIds.includes(id))
const toggleSelectAllPage = () => {
if (isAllSelected) {
setSelectedIds(prev => prev.filter(id => !allPageNeedIds.includes(id)))
} else {
setSelectedIds(prev => Array.from(new Set([...prev, ...allPageNeedIds])))
}
}
const toggleOne = (id) => {
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}
const dispatchSelected = () => {
const items = data.filter(x => selectedIds.includes(x.id) && x.needReplace)
if (items.length === 0) {
alert('请先勾选需更换的滤芯')
return
}
addBatchWorkOrders(items)
alert(`已派发 ${items.length} 条工单`)
setSelectedIds([])
navigate('/work-orders')
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">滤芯总览</h1>
<p className="page-subtitle">快速定位即将或已到期的滤芯</p>
</div>
<div className="card">
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8 }}>
<button className={`btn ${tab === 'need' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('need')}>
需更换 <span style={{ marginLeft: 6, fontSize: 12, opacity: .8 }}>({stats.need})</span>
</button>
<button className={`btn ${tab === 'all' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('all')}>
全部 <span style={{ marginLeft: 6, fontSize: 12, opacity: .8 }}>({stats.total})</span>
</button>
<button className={`btn ${tab === 'ok' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('ok')}>
正常 <span style={{ marginLeft: 6, fontSize: 12, opacity: .8 }}>({stats.ok})</span>
</button>
</div>
<input
type="text"
placeholder="搜索设备ID/名称/位置/型号..."
value={search}
onChange={e => setSearch(e.target.value)}
style={{ padding: '10px 16px', border: '1px solid #e5e7eb', borderRadius: 8, minWidth: 240 }}
/>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
<span className="stat-label">每页</span>
<select className="btn btn-secondary" value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setPage(1) }}>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
</select>
</div>
</div>
</div>
<div className="card">
<div className="card-header">
<h2 className="card-title">滤芯列表</h2>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button className="btn btn-secondary" onClick={() => setPage(p => Math.max(1, p - 1))}>上一页</button>
<div className="stat-label"> {currentPage} / {totalPages} </div>
<button className="btn btn-secondary" onClick={() => setPage(p => Math.min(totalPages, p + 1))}>下一页</button>
<button className="btn btn-primary" onClick={dispatchSelected}>派单</button>
</div>
</div>
<div className="card-body">
<table className="table">
<thead>
<tr>
<th>
<input type="checkbox" checked={isAllSelected} onChange={toggleSelectAllPage} />
</th>
<th>设备ID</th>
<th>名称 / 位置</th>
<th>滤芯型号</th>
<th>安装日期</th>
<th>剩余天数</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{paged.map(item => (
<tr key={item.id} style={{ background: item.needReplace ? '#fef2f2' : undefined }}>
<td>
<input type="checkbox" disabled={!item.needReplace} checked={selectedIds.includes(item.id)} onChange={() => toggleOne(item.id)} />
</td>
<td>{item.id}</td>
<td>
<div>{item.name}</div>
<div style={{ fontSize: 12, color: '#6b7280' }}>{item.location}</div>
</td>
<td>{item.filterModel}</td>
<td>{item.installDate}</td>
<td style={{ color: item.needReplace ? '#ef4444' : '#111827', fontWeight: 600 }}>{Math.max(0, item.remainingDays)}</td>
<td>
<span className={`status-badge ${item.needReplace ? 'status-offline' : 'status-online'}`}>
{item.needReplace ? '需更换' : '正常'}
</span>
</td>
<td>
<button className="btn btn-secondary" style={{ padding: '4px 12px', fontSize: 12 }} onClick={() => navigate(`/filters/${item.id}`)}>
查看
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

@ -0,0 +1,113 @@
import React, { useMemo } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
// ID
const filters = [
{ id: 'D0001', name: '饮水机-1号楼-101', location: '1号楼-1层-101', installDate: '2024-02-15', filterModel: 'CF-RO-75G', dispenserModel: 'AquaPro-D300', expectedLifetimeMonths: 12 },
{ id: 'D0002', name: '饮水机-1号楼-201', location: '1号楼-2层-201', installDate: '2024-05-06', filterModel: 'CF-PP-5', dispenserModel: 'ClearFlow S1', expectedLifetimeMonths: 12 },
{ id: 'D0003', name: '饮水机-2号楼-大厅', location: '2号楼-1层-大厅', installDate: '2023-12-20', filterModel: 'CF-CTO-10', dispenserModel: 'Hydra-Pure X2', expectedLifetimeMonths: 18 },
{ id: 'D0004', name: '饮水机-2号楼-305', location: '2号楼-3层-305', installDate: '2023-11-01', filterModel: 'CF-UF-0.01', dispenserModel: 'Hydra-Pure X2', expectedLifetimeMonths: 18 },
{ id: 'D0005', name: '饮水机-3号楼-101', location: '3号楼-1层-101', installDate: '2023-08-10', filterModel: 'CF-RO-100G', dispenserModel: 'AquaPro-D300', expectedLifetimeMonths: 24 },
{ id: 'D0006', name: '饮水机-3号楼-201', location: '3号楼-2层-201', installDate: '2024-01-12', filterModel: 'CF-PP-5', dispenserModel: 'ClearFlow S1', expectedLifetimeMonths: 12 },
]
function addMonths(date, months) { const d = new Date(date); d.setMonth(d.getMonth() + months); return d }
function formatDate(date) { const y = date.getFullYear(); const m = `${date.getMonth()+1}`.padStart(2,'0'); const d = `${date.getDate()}`.padStart(2,'0'); return `${y}-${m}-${d}` }
export default function FilterDetails() {
const { id } = useParams()
const navigate = useNavigate()
const detail = useMemo(() => filters.find(f => f.id === id), [id])
if (!detail) {
return (
<div className="page-container">
<div className="card">
<div className="card-body">
<h2 className="card-title">未找到滤芯</h2>
<p style={{ color: '#6b7280' }}>ID {id} 的滤芯记录不存在</p>
<button className="btn btn-primary" onClick={() => navigate(-1)}>返回</button>
</div>
</div>
</div>
)
}
const install = new Date(detail.installDate)
const replaceAt = addMonths(install, detail.expectedLifetimeMonths)
const today = new Date()
const remainingDays = Math.ceil((replaceAt.getTime() - today.getTime()) / (1000*60*60*24))
const needReplace = remainingDays <= 14
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">滤芯详情</h1>
<p className="page-subtitle">查看滤芯型号安装信息与更换建议</p>
</div>
<div className="card">
<div className="card-header">
<h2 className="card-title">基本信息</h2>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-secondary" onClick={() => navigate(-1)}>返回</button>
<button className="btn btn-primary" onClick={() => navigate('/dispensers')}>返回总览</button>
</div>
</div>
<div className="card-body">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
<div><div className="stat-label">滤芯ID</div><div className="stat-value" style={{ fontSize: 18 }}>{detail.id}</div></div>
<div><div className="stat-label">所属饮水机</div><div className="stat-value" style={{ fontSize: 18 }}>{detail.name}</div></div>
<div><div className="stat-label">安装位置</div><div className="stat-value" style={{ fontSize: 18 }}>{detail.location}</div></div>
<div><div className="stat-label">饮水机型号</div><div className="stat-value" style={{ fontSize: 18 }}>{detail.dispenserModel}</div></div>
<div><div className="stat-label">滤芯型号</div><div className="stat-value" style={{ fontSize: 18 }}>{detail.filterModel}</div></div>
<div><div className="stat-label">安装日期</div><div className="stat-value" style={{ fontSize: 18 }}>{detail.installDate}</div></div>
</div>
</div>
</div>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">预计寿命</div>
<div className="stat-value">{detail.expectedLifetimeMonths} 个月</div>
</div>
<div className="stat-icon"></div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">预计更换日期</div>
<div className="stat-value">{formatDate(replaceAt)}</div>
</div>
<div className="stat-icon">📅</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">剩余天数</div>
<div className="stat-value" style={{ color: needReplace ? '#ef4444' : '#111827' }}>{Math.max(0, remainingDays)} </div>
</div>
<div className="stat-icon">{needReplace ? '🟥' : '🟩'}</div>
</div>
</div>
</div>
<div className="card">
<div className="card-header">
<h2 className="card-title">更换建议</h2>
<span className={`status-badge ${needReplace ? 'status-offline' : 'status-online'}`}>{needReplace ? '需尽快更换' : '状态正常'}</span>
</div>
<div className="card-body" style={{ color: '#6b7280' }}>
建议在预计更换日前 1-2 周备货并安排人员保证饮水安全与水质稳定
</div>
</div>
</div>
)
}

@ -0,0 +1,109 @@
import React, { useEffect, useMemo, useState } from 'react'
export default function Home() {
const slides = useMemo(() => ([
{ id: 1, title: '智慧供水升级', subtitle: '数字化运维,保障用水安全', image: 'https://images.unsplash.com/photo-1528821154947-1aa3d1e25e83?q=80&w=1600&auto=format&fit=crop' },
{ id: 2, title: '节水从我做起', subtitle: '实时监测,精细化管理', image: 'https://images.unsplash.com/photo-1502741338009-cac2772e18bc?q=80&w=1600&auto=format&fit=crop' },
{ id: 3, title: '饮水更健康', subtitle: '滤芯寿命可视化管理', image: 'https://images.unsplash.com/photo-1516826957135-700dedea698c?q=80&w=1600&auto=format&fit=crop' },
]), [])
const news = [
{ id: 'n1', title: '校园直饮水安全专项检查启动', date: '2025-10-20', desc: '联合后勤与安保部门开展用水设备安全巡检。' },
{ id: 'n2', title: '冬季供水保障方案发布', date: '2025-10-12', desc: '低温防冻、重点区域冗余供水切换演练完成。' },
{ id: 'n3', title: '滤芯更换计划提醒', date: '2025-10-05', desc: '对即将到期点位发出更换通知并安排人员。' },
]
const [active, setActive] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
setActive((prev) => (prev + 1) % slides.length)
}, 4000)
return () => clearInterval(timer)
}, [slides.length])
const goTo = (idx) => setActive(idx)
const prev = () => setActive((active - 1 + slides.length) % slides.length)
const next = () => setActive((active + 1) % slides.length)
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">首页</h1>
<p className="page-subtitle">新闻动态与重点信息速览</p>
</div>
<div className="card" style={{ overflow: 'hidden' }}>
<div className="card-body" style={{ padding: 0 }}>
<div style={{ position: 'relative', width: '100%', height: '320px' }}>
{slides.map((s, idx) => (
<div
key={s.id}
style={{
position: 'absolute',
inset: 0,
backgroundImage: `url(${s.image})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
opacity: idx === active ? 1 : 0,
transition: 'opacity 600ms ease',
}}
>
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient( to top, rgba(0,0,0,.45), rgba(0,0,0,.15) )' }} />
<div style={{ position: 'absolute', left: 24, bottom: 24, color: '#fff' }}>
<div style={{ fontSize: 24, fontWeight: 700 }}>{s.title}</div>
<div style={{ marginTop: 6, fontSize: 14, opacity: .9 }}>{s.subtitle}</div>
</div>
</div>
))}
<button className="btn btn-secondary" onClick={prev} style={{ position: 'absolute', top: '50%', left: 12, transform: 'translateY(-50%)' }}></button>
<button className="btn btn-secondary" onClick={next} style={{ position: 'absolute', top: '50%', right: 12, transform: 'translateY(-50%)' }}></button>
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 10, display: 'flex', justifyContent: 'center', gap: 8 }}>
{slides.map((_, idx) => (
<div
key={idx}
onClick={() => goTo(idx)}
style={{
width: 10,
height: 10,
borderRadius: 999,
background: idx === active ? '#fff' : 'rgba(255,255,255,.6)',
cursor: 'pointer',
border: '1px solid rgba(0,0,0,.1)'
}}
/>
))}
</div>
</div>
</div>
</div>
<div className="card">
<div className="card-header">
<h2 className="card-title">最新新闻</h2>
<div className="stat-label">近期更新</div>
</div>
<div className="card-body">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16 }}>
{news.map(item => (
<div key={item.id} className="stat-card" style={{ padding: 16 }}>
<div className="stat-header" style={{ alignItems: 'flex-start' }}>
<div>
<div className="stat-label" style={{ marginBottom: 6 }}>{item.date}</div>
<div className="stat-value" style={{ fontSize: 18 }}>{item.title}</div>
</div>
<div className="stat-icon">📰</div>
</div>
<div style={{ marginTop: 8, color: '#6b7280', lineHeight: 1.6 }}>{item.desc}</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}

@ -0,0 +1,230 @@
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.login-wave {
position: absolute;
width: 200%;
height: 200%;
background: rgba(255, 255, 255, 0.1);
border-radius: 45%;
animation: wave 15s infinite linear;
}
.login-wave:nth-child(2) {
animation-delay: -5s;
opacity: 0.5;
}
.login-wave:nth-child(3) {
animation-delay: -10s;
opacity: 0.3;
}
@keyframes wave {
0% {
transform: translate(-50%, -50%) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg);
}
}
.login-card {
background: white;
border-radius: 20px;
padding: 48px 40px;
width: 100%;
max-width: 420px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
position: relative;
z-index: 1;
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-logo {
font-size: 64px;
margin-bottom: 16px;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
.login-header h1 {
font-size: 28px;
font-weight: 700;
color: #1f2937;
margin-bottom: 8px;
}
.login-header p {
font-size: 14px;
color: #6b7280;
}
.login-form {
margin-bottom: 24px;
}
.error-message {
background: #fee2e2;
border: 1px solid #fecaca;
color: #991b1b;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 500;
color: #374151;
margin-bottom: 8px;
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 15px;
transition: all 0.2s;
background: #fff;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-group input::placeholder {
color: #9ca3af;
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
font-size: 14px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
color: #6b7280;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
cursor: pointer;
}
.forgot-password {
color: #3b82f6;
text-decoration: none;
font-weight: 500;
transition: color 0.2s;
}
.forgot-password:hover {
color: #2563eb;
}
.login-button {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.login-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
}
.login-button:active:not(:disabled) {
transform: translateY(0);
}
.login-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-footer {
text-align: center;
padding-top: 24px;
border-top: 1px solid #e5e7eb;
}
.login-footer p {
font-size: 13px;
color: #6b7280;
margin: 4px 0;
}
.login-footer .hint {
font-size: 12px;
color: #9ca3af;
}
@media (max-width: 480px) {
.login-card {
padding: 32px 24px;
margin: 20px;
}
.login-header h1 {
font-size: 24px;
}
.login-logo {
font-size: 48px;
}
}

@ -0,0 +1,108 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import './Login.css'
export default function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login, isAuthenticated, loading: authLoading } = useAuth()
const navigate = useNavigate()
//
useEffect(() => {
if (!authLoading && isAuthenticated) {
navigate('/')
}
}, [isAuthenticated, authLoading, navigate])
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const result = login(username, password)
if (result.success) {
navigate('/')
} else {
setError(result.message || '登录失败,请检查用户名和密码')
}
} catch (err) {
setError('登录失败,请稍后重试')
} finally {
setLoading(false)
}
}
return (
<div className="login-container">
<div className="login-background">
<div className="login-wave"></div>
<div className="login-wave"></div>
<div className="login-wave"></div>
</div>
<div className="login-card">
<div className="login-header">
<div className="login-logo">💧</div>
<h1>数智水管家</h1>
<p>管理员登录</p>
</div>
<form onSubmit={handleSubmit} className="login-form">
{error && (
<div className="error-message">
<span></span>
<span>{error}</span>
</div>
)}
<div className="form-group">
<label htmlFor="username">用户名</label>
<input
id="username"
type="text"
placeholder="请输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="password">密码</label>
<input
id="password"
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<div className="form-options">
<label className="checkbox-label">
<input type="checkbox" />
<span>记住我</span>
</label>
<a href="#" className="forgot-password">忘记密码</a>
</div>
<button type="submit" className="login-button" disabled={loading}>
{loading ? '登录中...' : '登录'}
</button>
</form>
<div className="login-footer">
<p>默认账号admin / admin123</p>
<p className="hint">© 2024 数智水管家系统</p>
</div>
</div>
</div>
)
}

@ -0,0 +1,297 @@
import React, { useState } from 'react'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
const realtimeData = [
{ time: '14:00', flow: 45, pressure: 0.35 },
{ time: '14:05', flow: 52, pressure: 0.38 },
{ time: '14:10', flow: 48, pressure: 0.36 },
{ time: '14:15', flow: 55, pressure: 0.40 },
{ time: '14:20', flow: 50, pressure: 0.37 },
{ time: '14:25', flow: 58, pressure: 0.42 },
]
//
const waterQuality = {
ph: 7.2, // 6.5 - 8.5
tds: 180, // < 500 mg/L
turbidity: 0.3, // < 1 NTU
residualChlorine: 0.28, // 0.2 - 0.5 mg/L
temperature: 22, // 5 - 30
orp: 660, // > 650 mV
}
function assessWaterQuality(q) {
const alerts = []
const status = {
ph: q.ph >= 6.5 && q.ph <= 8.5,
tds: q.tds < 500,
turbidity: q.turbidity < 1,
residualChlorine: q.residualChlorine >= 0.2 && q.residualChlorine <= 0.5,
temperature: q.temperature >= 5 && q.temperature <= 30,
orp: q.orp >= 650,
}
if (!status.ph) alerts.push(`pH 超出范围 (6.5-8.5):当前 ${q.ph}`)
if (!status.tds) alerts.push(`TDS 过高:当前 ${q.tds} mg/L`)
if (!status.turbidity) alerts.push(`浊度超标:当前 ${q.turbidity} NTU`)
if (!status.residualChlorine) alerts.push(`余氯不达标 (0.2-0.5 mg/L):当前 ${q.residualChlorine} mg/L`)
if (!status.temperature) alerts.push(`温度异常 (5-30℃):当前 ${q.temperature}`)
if (!status.orp) alerts.push(`ORP 偏低:当前 ${q.orp} mV`)
return { status, alerts }
}
const devices = [
{ id: 'A001', name: '主进水管道', location: '1号楼', flow: '52.3 L/min', pressure: '0.38 MPa', status: 'online', lastUpdate: '刚刚' },
{ id: 'A002', name: '2楼供水', location: '2号楼', flow: '28.5 L/min', pressure: '0.35 MPa', status: 'online', lastUpdate: '1分钟前' },
{ id: 'A003', name: '3楼供水', location: '3号楼', flow: '35.2 L/min', pressure: '0.36 MPa', status: 'online', lastUpdate: '刚刚' },
{ id: 'A004', name: '4楼供水', location: '4号楼', flow: '0 L/min', pressure: '0 MPa', status: 'offline', lastUpdate: '5分钟前' },
{ id: 'A005', name: '5楼供水', location: '5号楼', flow: '42.1 L/min', pressure: '0.39 MPa', status: 'online', lastUpdate: '刚刚' },
{ id: 'B001', name: '消防水管', location: '1楼', flow: '0 L/min', pressure: '0.65 MPa', status: 'online', lastUpdate: '刚刚' },
]
export default function Monitoring() {
const [selectedDevice, setSelectedDevice] = useState(null)
const { status: qStatus, alerts } = assessWaterQuality(waterQuality)
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">实时监控</h1>
<p className="page-subtitle">实时查看设备状态和用水数据</p>
</div>
{/* 实时数据展示 */}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">实时流量</div>
<div className="stat-value">52.3 L/min</div>
<div className="stat-change positive">
<span></span>
<span>正常</span>
</div>
</div>
<div className="stat-icon">🌊</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">系统压力</div>
<div className="stat-value">0.38 MPa</div>
<div className="stat-change positive">
<span></span>
<span>正常</span>
</div>
</div>
<div className="stat-icon">📊</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">活跃设备</div>
<div className="stat-value">5 / 6</div>
<div className="stat-change negative">
<span></span>
<span>1个设备离线</span>
</div>
</div>
<div className="stat-icon">🔌</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">数据更新</div>
<div className="stat-value">刚刚</div>
<div className="stat-change positive">
<span></span>
<span>实时同步中</span>
</div>
</div>
<div className="stat-icon">🔄</div>
</div>
</div>
</div>
{/* 水质指标与报警 */}
<div className="card">
<div className="card-header">
<h2 className="card-title">水质指标</h2>
{alerts.length > 0 ? (
<span className="status-badge status-warning">{`⚠️ ${alerts.length} 项不达标`}</span>
) : (
<span className="status-badge status-online"> 全部达标</span>
)}
</div>
<div className="card-body">
{alerts.length > 0 && (
<div className="card" style={{ background: '#fff7ed', borderColor: '#fdba74', marginBottom: 16 }}>
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{alerts.map((a, idx) => (
<div key={idx} style={{ color: '#b45309' }}> {a}</div>
))}
</div>
</div>
)}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">pH</div>
<div className="stat-value">{waterQuality.ph}</div>
<div className={`stat-change ${qStatus.ph ? 'positive' : 'negative'}`}>
<span>{qStatus.ph ? '正常' : '异常'}</span>
</div>
</div>
<div className="stat-icon"></div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">TDS</div>
<div className="stat-value">{waterQuality.tds} mg/L</div>
<div className={`stat-change ${qStatus.tds ? 'positive' : 'negative'}`}>
<span>{qStatus.tds ? '正常' : '偏高'}</span>
</div>
</div>
<div className="stat-icon">🧪</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">浊度</div>
<div className="stat-value">{waterQuality.turbidity} NTU</div>
<div className={`stat-change ${qStatus.turbidity ? 'positive' : 'negative'}`}>
<span>{qStatus.turbidity ? '正常' : '超标'}</span>
</div>
</div>
<div className="stat-icon">💧</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">余氯</div>
<div className="stat-value">{waterQuality.residualChlorine} mg/L</div>
<div className={`stat-change ${qStatus.residualChlorine ? 'positive' : 'negative'}`}>
<span>{qStatus.residualChlorine ? '正常' : '异常'}</span>
</div>
</div>
<div className="stat-icon">🧼</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">温度</div>
<div className="stat-value">{waterQuality.temperature} </div>
<div className={`stat-change ${qStatus.temperature ? 'positive' : 'negative'}`}>
<span>{qStatus.temperature ? '正常' : '异常'}</span>
</div>
</div>
<div className="stat-icon">🌡</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">ORP</div>
<div className="stat-value">{waterQuality.orp} mV</div>
<div className={`stat-change ${qStatus.orp ? 'positive' : 'negative'}`}>
<span>{qStatus.orp ? '正常' : '偏低'}</span>
</div>
</div>
<div className="stat-icon"></div>
</div>
</div>
</div>
</div>
</div>
{/* 实时趋势图 */}
<div className="card">
<div className="card-header">
<h2 className="card-title">实时数据趋势</h2>
</div>
<div className="card-body">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={realtimeData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" />
<YAxis yAxisId="left" />
<YAxis yAxisId="right" orientation="right" />
<Tooltip />
<Line yAxisId="left" type="monotone" dataKey="flow" stroke="#3b82f6" strokeWidth={2} name="流量 (L/min)" />
<Line yAxisId="right" type="monotone" dataKey="pressure" stroke="#10b981" strokeWidth={2} name="压力 (MPa)" />
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* 设备列表 */}
<div className="card">
<div className="card-header">
<h2 className="card-title">设备监控列表</h2>
<button className="btn btn-primary">刷新数据</button>
</div>
<div className="card-body">
<table className="table">
<thead>
<tr>
<th>设备ID</th>
<th>设备名称</th>
<th>位置</th>
<th>流量</th>
<th>压力</th>
<th>状态</th>
<th>最后更新</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{devices.map((device) => (
<tr
key={device.id}
onClick={() => setSelectedDevice(device)}
style={{ cursor: 'pointer' }}
>
<td>{device.id}</td>
<td>{device.name}</td>
<td>{device.location}</td>
<td>{device.flow}</td>
<td>{device.pressure}</td>
<td>
<span className={`status-badge ${device.status === 'online' ? 'status-online' : 'status-offline'}`}>
{device.status === 'online' ? '在线' : '离线'}
</span>
</td>
<td>{device.lastUpdate}</td>
<td>
<button className="btn btn-secondary" style={{ padding: '4px 12px', fontSize: '12px' }}>
详情
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

@ -0,0 +1,107 @@
import React, { useMemo, useState } from 'react'
const allOrders = [
{ id: 'O202510-0001', user: '张三', userId: 'U001', deviceId: 'A001', item: 'RO-75G 滤芯套装', qty: 1, amount: 239, status: '已完成', createdAt: '2025-10-01 09:12', finishedAt: '2025-10-03 14:22' },
{ id: 'O202510-0002', user: '李四', userId: 'U002', deviceId: 'A003', item: 'CTO-10 碳棒滤芯', qty: 2, amount: 160, status: '已完成', createdAt: '2025-10-05 11:05', finishedAt: '2025-10-06 16:10' },
{ id: 'O202510-0003', user: '王五', userId: 'U003', deviceId: 'A005', item: 'UF-0.01 超滤膜', qty: 1, amount: 299, status: '待配送', createdAt: '2025-10-20 15:33', finishedAt: '-' },
{ id: 'O202510-0004', user: '赵六', userId: 'U004', deviceId: 'C001', item: 'PP-5 微孔滤芯', qty: 4, amount: 120, status: '已取消', createdAt: '2025-10-22 10:08', finishedAt: '-' },
]
export default function Orders() {
const [statusFilter, setStatusFilter] = useState('全部')
const [search, setSearch] = useState('')
const orders = useMemo(() => {
return allOrders.filter(o => {
const okStatus = statusFilter === '全部' || o.status === statusFilter
const q = search.trim().toLowerCase()
const okSearch = !q || [o.id, o.user, o.userId, o.deviceId, o.item].some(v => String(v).toLowerCase().includes(q))
return okStatus && okSearch
})
}, [statusFilter, search])
const stats = {
total: allOrders.length,
finished: allOrders.filter(o => o.status === '已完成').length,
shipping: allOrders.filter(o => o.status === '待配送').length,
cancelled: allOrders.filter(o => o.status === '已取消').length,
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">用户历史订单</h1>
<p className="page-subtitle">查看滤芯采购与配送历史</p>
</div>
<div className="stats-grid">
<div className="stat-card"><div className="stat-header"><div><div className="stat-label">订单总数</div><div className="stat-value">{stats.total}</div></div><div className="stat-icon">🧾</div></div></div>
<div className="stat-card"><div className="stat-header"><div><div className="stat-label">已完成</div><div className="stat-value">{stats.finished}</div></div><div className="stat-icon"></div></div></div>
<div className="stat-card"><div className="stat-header"><div><div className="stat-label">待配送</div><div className="stat-value">{stats.shipping}</div></div><div className="stat-icon">📦</div></div></div>
<div className="stat-card"><div className="stat-header"><div><div className="stat-label">已取消</div><div className="stat-value">{stats.cancelled}</div></div><div className="stat-icon"></div></div></div>
</div>
<div className="card">
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<select className="btn btn-secondary" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
<option>全部</option>
<option>已完成</option>
<option>待配送</option>
<option>已取消</option>
</select>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="搜索订单号/用户/设备/物品..."
style={{ padding: '10px 16px', border: '1px solid #e5e7eb', borderRadius: 8, minWidth: 240 }}
/>
</div>
</div>
<div className="card">
<div className="card-header">
<h2 className="card-title">订单列表</h2>
</div>
<div className="card-body">
<table className="table">
<thead>
<tr>
<th>订单号</th>
<th>用户</th>
<th>设备ID</th>
<th>物品</th>
<th>数量</th>
<th>金额(¥)</th>
<th>状态</th>
<th>下单时间</th>
<th>完成时间</th>
</tr>
</thead>
<tbody>
{orders.map(o => (
<tr key={o.id}>
<td>{o.id}</td>
<td>{o.user}{o.userId}</td>
<td>{o.deviceId}</td>
<td>{o.item}</td>
<td>{o.qty}</td>
<td>{o.amount}</td>
<td>
<span className={`status-badge ${o.status === '已完成' ? 'status-online' : o.status === '待配送' ? 'status-warning' : 'status-offline'}`}>
{o.status}
</span>
</td>
<td>{o.createdAt}</td>
<td>{o.finishedAt}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

@ -0,0 +1,233 @@
import React, { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line } from 'recharts'
const monthlyData = [
{ month: '1月', usage: 8520 },
{ month: '2月', usage: 7890 },
{ month: '3月', usage: 9210 },
{ month: '4月', usage: 8850 },
{ month: '5月', usage: 9560 },
{ month: '6月', usage: 9120 },
]
const categoryData = [
{ name: '生活用水', value: 45680, color: '#3b82f6' },
{ name: '工业用水', value: 28950, color: '#10b981' },
{ name: '绿化用水', value: 15230, color: '#f59e0b' },
{ name: '其他', value: 5820, color: '#8b5cf6' },
]
const hourlyData = [
{ hour: 0, usage: 120 },
{ hour: 2, usage: 85 },
{ hour: 4, usage: 65 },
{ hour: 6, usage: 95 },
{ hour: 8, usage: 280 },
{ hour: 10, usage: 320 },
{ hour: 12, usage: 385 },
{ hour: 14, usage: 350 },
{ hour: 16, usage: 310 },
{ hour: 18, usage: 420 },
{ hour: 20, usage: 380 },
{ hour: 22, usage: 250 },
]
export default function Statistics() {
const [timeRange, setTimeRange] = useState('month')
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">用水统计</h1>
<p className="page-subtitle">查看详细的用水数据分析报告</p>
</div>
{/* 时间范围选择 */}
<div style={{ marginBottom: '24px', display: 'flex', gap: '12px' }}>
<button
className={`btn ${timeRange === 'day' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setTimeRange('day')}
>
日统计
</button>
<button
className={`btn ${timeRange === 'month' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setTimeRange('month')}
>
月统计
</button>
<button
className={`btn ${timeRange === 'year' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setTimeRange('year')}
>
年统计
</button>
</div>
{/* 统计摘要 */}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">总用水量</div>
<div className="stat-value">95,680 L</div>
<div className="stat-change">
<span>本月累计</span>
</div>
</div>
<div className="stat-icon">💧</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">日均用水</div>
<div className="stat-value">3,189 L</div>
<div className="stat-change positive">
<span></span>
<span>较上月减少 8.2%</span>
</div>
</div>
<div className="stat-icon">📊</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">峰值时段</div>
<div className="stat-value">18:00</div>
<div className="stat-change">
<span>420 L/h</span>
</div>
</div>
<div className="stat-icon"></div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">节省水量</div>
<div className="stat-value">8,520 L</div>
<div className="stat-change positive">
<span></span>
<span>较同期节省 8.2%</span>
</div>
</div>
<div className="stat-icon">🌿</div>
</div>
</div>
</div>
{/* 月度趋势 */}
<div className="card">
<div className="card-header">
<h2 className="card-title">月度用水趋势</h2>
</div>
<div className="card-body">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={monthlyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Bar dataKey="usage" fill="#3b82f6" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* 图表网格 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px', marginBottom: '32px' }}>
{/* 用水分类 */}
<div className="card">
<div className="card-header">
<h2 className="card-title">用水分类占比</h2>
</div>
<div className="card-body">
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={categoryData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={100}
fill="#8884d8"
dataKey="value"
>
{categoryData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
</div>
{/* 24小时分布 */}
<div className="card">
<div className="card-header">
<h2 className="card-title">24小时用水分布</h2>
</div>
<div className="card-body">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={hourlyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hour" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="usage" stroke="#10b981" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* 详细数据表格 */}
<div className="card">
<div className="card-header">
<h2 className="card-title">详细统计数据</h2>
<button className="btn btn-secondary">导出报表</button>
</div>
<div className="card-body">
<table className="table">
<thead>
<tr>
<th>日期</th>
<th>用水量 (L)</th>
<th>日均 (L)</th>
<th>峰值 (L/h)</th>
<th>同比增长</th>
<th>环比增长</th>
</tr>
</thead>
<tbody>
{monthlyData.map((item, index) => (
<tr key={index}>
<td>{item.month}</td>
<td>{item.usage.toLocaleString()}</td>
<td>{(item.usage / 30).toFixed(0)}</td>
<td>{Math.floor(item.usage / 30 * 1.2)}</td>
<td className="stat-change positive">+{(Math.random() * 10).toFixed(1)}%</td>
<td className={index > 0 ? (item.usage > monthlyData[index - 1].usage ? 'stat-change positive' : 'stat-change negative') : ''}>
{index > 0 && (
<>
{item.usage > monthlyData[index - 1].usage ? '+' : ''}
{(((item.usage - monthlyData[index - 1].usage) / monthlyData[index - 1].usage) * 100).toFixed(1)}%
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

@ -0,0 +1,396 @@
import React, { useState } from 'react'
//
const initialUsers = [
{ id: '1', username: 'user001', name: '张三', email: 'zhangsan@example.com', phone: '13800138001', role: 'user', status: 'active', createTime: '2024-01-01', lastLogin: '2024-01-15 10:30' },
{ id: '2', username: 'user002', name: '李四', email: 'lisi@example.com', phone: '13800138002', role: 'user', status: 'active', createTime: '2024-01-02', lastLogin: '2024-01-15 09:20' },
{ id: '3', username: 'worker001', name: '王五', email: 'wangwu@example.com', phone: '13800138003', role: 'worker', status: 'active', createTime: '2024-01-03', lastLogin: '2024-01-15 11:15' },
{ id: '4', username: 'worker002', name: '赵六', email: 'zhaoliu@example.com', phone: '13800138004', role: 'worker', status: 'inactive', createTime: '2024-01-04', lastLogin: '2024-01-10 14:45' },
{ id: '5', username: 'user003', name: '钱七', email: 'qianqi@example.com', phone: '13800138005', role: 'user', status: 'active', createTime: '2024-01-05', lastLogin: '2024-01-14 16:20' },
{ id: '6', username: 'worker003', name: '孙八', email: 'sunba@example.com', phone: '13800138006', role: 'worker', status: 'active', createTime: '2024-01-06', lastLogin: '2024-01-15 08:30' },
]
export default function UserManagement() {
const [users, setUsers] = useState(initialUsers)
const [filterRole, setFilterRole] = useState('all')
const [filterStatus, setFilterStatus] = useState('all')
const [searchTerm, setSearchTerm] = useState('')
const [showModal, setShowModal] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const [formData, setFormData] = useState({
username: '',
name: '',
email: '',
phone: '',
role: 'user',
status: 'active'
})
//
const filteredUsers = users.filter(user => {
const matchRole = filterRole === 'all' || user.role === filterRole
const matchStatus = filterStatus === 'all' || user.status === filterStatus
const matchSearch =
user.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.phone.includes(searchTerm)
return matchRole && matchStatus && matchSearch
})
//
const stats = {
total: users.length,
users: users.filter(u => u.role === 'user').length,
workers: users.filter(u => u.role === 'worker').length,
active: users.filter(u => u.status === 'active').length,
inactive: users.filter(u => u.status === 'inactive').length,
}
// /
const openModal = (user = null) => {
if (user) {
setEditingUser(user)
setFormData(user)
} else {
setEditingUser(null)
setFormData({
username: '',
name: '',
email: '',
phone: '',
role: 'user',
status: 'active'
})
}
setShowModal(true)
}
//
const closeModal = () => {
setShowModal(false)
setEditingUser(null)
}
//
const handleSave = () => {
if (editingUser) {
//
setUsers(users.map(u => u.id === editingUser.id ? { ...formData, id: editingUser.id } : u))
} else {
//
const newUser = {
...formData,
id: String(users.length + 1),
createTime: new Date().toISOString().split('T')[0],
lastLogin: '-'
}
setUsers([...users, newUser])
}
closeModal()
}
//
const handleDelete = (id) => {
if (window.confirm('确定要删除该用户吗?')) {
setUsers(users.filter(u => u.id !== id))
}
}
//
const toggleStatus = (id) => {
setUsers(users.map(u =>
u.id === id ? { ...u, status: u.status === 'active' ? 'inactive' : 'active' } : u
))
}
const roleLabels = {
user: '普通用户',
worker: '工作人员',
admin: '管理员'
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">用户管理</h1>
<p className="page-subtitle">管理系统用户和工作人员</p>
</div>
{/* 统计卡片 */}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">用户总数</div>
<div className="stat-value">{stats.total}</div>
</div>
<div className="stat-icon">👥</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">普通用户</div>
<div className="stat-value">{stats.users}</div>
</div>
<div className="stat-icon">👤</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">工作人员</div>
<div className="stat-value">{stats.workers}</div>
</div>
<div className="stat-icon">👷</div>
</div>
</div>
<div className="stat-card">
<div className="stat-header">
<div>
<div className="stat-label">活跃用户</div>
<div className="stat-value">{stats.active}</div>
<div className="stat-change positive">
<span>活跃率 {((stats.active / stats.total) * 100).toFixed(1)}%</span>
</div>
</div>
<div className="stat-icon"></div>
</div>
</div>
</div>
{/* 筛选和搜索 */}
<div className="card">
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
className={`btn ${filterRole === 'all' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setFilterRole('all')}
>
全部
</button>
<button
className={`btn ${filterRole === 'user' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setFilterRole('user')}
>
普通用户
</button>
<button
className={`btn ${filterRole === 'worker' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setFilterRole('worker')}
>
工作人员
</button>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
className={`btn ${filterStatus === 'all' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setFilterStatus('all')}
style={{ fontSize: '12px', padding: '8px 16px' }}
>
全部状态
</button>
<button
className={`btn ${filterStatus === 'active' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setFilterStatus('active')}
style={{ fontSize: '12px', padding: '8px 16px' }}
>
活跃
</button>
<button
className={`btn ${filterStatus === 'inactive' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setFilterStatus('inactive')}
style={{ fontSize: '12px', padding: '8px 16px' }}
>
禁用
</button>
</div>
<input
type="text"
placeholder="搜索用户名、姓名、邮箱或手机号..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
padding: '10px 16px',
border: '1px solid #e5e7eb',
borderRadius: '8px',
fontSize: '14px',
flex: '1',
minWidth: '200px',
}}
/>
<button className="btn btn-primary" onClick={() => openModal()}>
+ 添加用户
</button>
</div>
</div>
{/* 用户列表 */}
<div className="card">
<div className="card-header">
<h2 className="card-title">用户列表 ({filteredUsers.length})</h2>
</div>
<div className="card-body">
<table className="table">
<thead>
<tr>
<th>用户名</th>
<th>姓名</th>
<th>邮箱</th>
<th>手机号</th>
<th>角色</th>
<th>状态</th>
<th>创建时间</th>
<th>最后登录</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((user) => (
<tr key={user.id}>
<td>{user.username}</td>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.phone}</td>
<td>
<span className={`status-badge ${user.role === 'worker' ? 'status-online' : 'status-warning'}`}>
{roleLabels[user.role]}
</span>
</td>
<td>
<span className={`status-badge ${user.status === 'active' ? 'status-online' : 'status-offline'}`}>
{user.status === 'active' ? '活跃' : '禁用'}
</span>
</td>
<td>{user.createTime}</td>
<td>{user.lastLogin}</td>
<td>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
className="btn btn-secondary"
style={{ padding: '4px 12px', fontSize: '12px' }}
onClick={() => openModal(user)}
>
编辑
</button>
<button
className="btn btn-secondary"
style={{ padding: '4px 12px', fontSize: '12px' }}
onClick={() => toggleStatus(user.id)}
>
{user.status === 'active' ? '禁用' : '启用'}
</button>
<button
className="btn btn-secondary"
style={{ padding: '4px 12px', fontSize: '12px', background: '#fee2e2', color: '#991b1b' }}
onClick={() => handleDelete(user.id)}
>
删除
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 添加/编辑模态框 */}
{showModal && (
<div className="modal-overlay" onClick={closeModal}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{editingUser ? '编辑用户' : '添加用户'}</h2>
<button className="modal-close" onClick={closeModal}>×</button>
</div>
<div className="modal-body">
<div className="form-group">
<label>用户名 *</label>
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
placeholder="请输入用户名"
disabled={!!editingUser}
/>
</div>
<div className="form-group">
<label>姓名 *</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="请输入姓名"
/>
</div>
<div className="form-group">
<label>邮箱 *</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="请输入邮箱"
/>
</div>
<div className="form-group">
<label>手机号 *</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
placeholder="请输入手机号"
/>
</div>
<div className="form-group">
<label>角色 *</label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
style={{
width: '100%',
padding: '12px 16px',
border: '2px solid #e5e7eb',
borderRadius: '8px',
fontSize: '15px',
}}
>
<option value="user">普通用户</option>
<option value="worker">工作人员</option>
</select>
</div>
<div className="form-group">
<label>状态 *</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
style={{
width: '100%',
padding: '12px 16px',
border: '2px solid #e5e7eb',
borderRadius: '8px',
fontSize: '15px',
}}
>
<option value="active">活跃</option>
<option value="inactive">禁用</option>
</select>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={closeModal}>取消</button>
<button className="btn btn-primary" onClick={handleSave}>保存</button>
</div>
</div>
</div>
)}
</div>
)
}

@ -0,0 +1,121 @@
import React, { useEffect, useMemo, useState } from 'react'
import { getStoredWorkOrders } from '../utils/workOrdersStore'
const allWorkOrders = [
{ id: 'W202510-1001', assignTo: '工程师-刘强', phone: '13800000001', deviceId: 'A003', location: '3号楼-201', task: '更换 CTO-10 滤芯', status: '已完成', createdAt: '2025-10-02 10:20', acceptedAt: '2025-10-02 10:35', finishedAt: '2025-10-02 12:10' },
{ id: 'W202510-1002', assignTo: '工程师-陈敏', phone: '13800000002', deviceId: 'A005', location: '5号楼-大厅', task: '更换 RO-75G 套装', status: '进行中', createdAt: '2025-10-21 15:00', acceptedAt: '2025-10-21 15:10', finishedAt: '-' },
{ id: 'W202510-1003', assignTo: '工程师-王凯', phone: '13800000003', deviceId: 'C001', location: '净水站', task: '更换 PP-5+UF-0.01', status: '待接单', createdAt: '2025-10-23 09:12', acceptedAt: '-', finishedAt: '-' },
]
export default function WorkOrders() {
const [statusFilter, setStatusFilter] = useState('全部')
const [search, setSearch] = useState('')
const [extra, setExtra] = useState([])
useEffect(() => {
setExtra(getStoredWorkOrders())
}, [])
const baseMerged = useMemo(() => [...extra, ...allWorkOrders], [extra])
const workOrders = useMemo(() => {
return baseMerged.filter(w => {
const okStatus = statusFilter === '全部' || w.status === statusFilter
const q = search.trim().toLowerCase()
const okSearch = !q || [w.id, w.assignTo, w.deviceId, w.location, w.task].some(v => String(v).toLowerCase().includes(q))
return okStatus && okSearch
})
}, [statusFilter, search, baseMerged])
const stats = {
total: baseMerged.length,
pending: baseMerged.filter(w => w.status === '待接单').length,
running: baseMerged.filter(w => w.status === '进行中').length,
finished: baseMerged.filter(w => w.status === '已完成').length,
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">工单记录</h1>
<p className="page-subtitle">工作人员接单与滤芯更换记录</p>
</div>
<div className="stats-grid">
<div className="stat-card"><div className="stat-header"><div><div className="stat-label">工单总数</div><div className="stat-value">{stats.total}</div></div><div className="stat-icon">🗂</div></div></div>
<div className="stat-card"><div className="stat-header"><div><div className="stat-label">待接单</div><div className="stat-value">{stats.pending}</div></div><div className="stat-icon"></div></div></div>
<div className="stat-card"><div className="stat-header"><div><div className="stat-label">进行中</div><div className="stat-value">{stats.running}</div></div><div className="stat-icon">🔧</div></div></div>
<div className="stat-card"><div className="stat-header"><div><div className="stat-label">已完成</div><div className="stat-value">{stats.finished}</div></div><div className="stat-icon"></div></div></div>
</div>
<div className="card">
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<select className="btn btn-secondary" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
<option>全部</option>
<option>待接单</option>
<option>进行中</option>
<option>已完成</option>
</select>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="搜索工单/设备/位置/任务..."
style={{ padding: '10px 16px', border: '1px solid #e5e7eb', borderRadius: 8, minWidth: 240 }}
/>
</div>
</div>
<div className="card">
<div className="card-header">
<h2 className="card-title">工单列表</h2>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-secondary" onClick={() => setExtra(getStoredWorkOrders())}>刷新</button>
<button className="btn btn-primary">导出</button>
</div>
</div>
<div className="card-body">
<table className="table">
<thead>
<tr>
<th>工单号</th>
<th>指派给</th>
<th>联系电话</th>
<th>设备ID</th>
<th>位置</th>
<th>任务</th>
<th>状态</th>
<th>创建时间</th>
<th>接单时间</th>
<th>完成时间</th>
</tr>
</thead>
<tbody>
{workOrders.map(w => (
<tr key={w.id}>
<td>{w.id}</td>
<td>{w.assignTo}</td>
<td>{w.phone}</td>
<td>{w.deviceId}</td>
<td>{w.location}</td>
<td>{w.task}</td>
<td>
<span className={`status-badge ${w.status === '已完成' ? 'status-online' : w.status === '进行中' ? 'status-warning' : 'status-offline'}`}>
{w.status}
</span>
</td>
<td>{w.createdAt}</td>
<td>{w.acceptedAt}</td>
<td>{w.finishedAt}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

@ -0,0 +1,42 @@
const STORAGE_KEY = 'work_orders_extra'
export function getStoredWorkOrders() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch (_) {
return []
}
}
export function saveStoredWorkOrders(list) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list || []))
} catch (_) {}
}
export function addBatchWorkOrders(items) {
const existing = getStoredWorkOrders()
const now = new Date()
const ts = `${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}`
let counter = existing.length + 1
const batch = items.map(it => ({
id: `W${ts}-${String(counter++).padStart(4,'0')}`,
assignTo: '待分配',
phone: '-',
deviceId: it.id,
location: it.location,
task: `更换 ${it.filterModel}`,
status: '待接单',
createdAt: `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`,
acceptedAt: '-',
finishedAt: '-',
}))
const merged = [...batch, ...existing]
saveStoredWorkOrders(merged)
return batch
}

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
open: true
}
})
Loading…
Cancel
Save