This commit is contained in:
suguo 2026-03-23 15:57:48 +08:00
parent aca88d1214
commit 14f130f2c7
3 changed files with 885 additions and 1 deletions

546
src/pages/point/list.vue Normal file
View File

@ -0,0 +1,546 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { apiFetch } from '../../lib/api'
const points = ref([])
const loading = ref(false)
const error = ref('')
const searchKeyword = ref('')
const statusFilter = ref('')
const typeFilter = ref('')
const showAddModal = ref(false)
const showEditModal = ref(false)
const editingPoint = ref(null)
const formData = ref({
name: '',
code: '',
type: '',
projectId: '',
deviceId: '',
status: 'active'
})
const pointTypes = [
{ label: '结构监测', value: 'structure' },
{ label: '环境监测', value: 'environment' },
{ label: '视频监控', value: 'video' },
{ label: '位移监测', value: 'displacement' },
{ label: '温湿度监测', value: 'temperature' }
]
const statusOptions = [
{ label: '全部', value: '' },
{ label: '在线', value: 'active' },
{ label: '离线', value: 'inactive' },
{ label: '维护中', value: 'maintenance' }
]
async function loadPoints() {
loading.value = true
error.value = ''
try {
const res = await apiFetch('/api/points')
if (res?.items) {
points.value = res.items
}
} catch (err) {
error.value = err.message || '加载监测点位失败'
} finally {
loading.value = false
}
}
const filteredPoints = computed(() => {
return points.value.filter(point => {
const matchKeyword = !searchKeyword.value ||
point.name.includes(searchKeyword.value) ||
point.code.includes(searchKeyword.value)
const matchStatus = !statusFilter.value || point.status === statusFilter.value
const matchType = !typeFilter.value || point.type === typeFilter.value
return matchKeyword && matchStatus && matchType
})
})
function getStatusLabel(status) {
const statusMap = {
active: '在线',
inactive: '离线',
maintenance: '维护中'
}
return statusMap[status] || status
}
function getStatusClass(status) {
const classMap = {
active: 'status-active',
inactive: 'status-inactive',
maintenance: 'status-maintenance'
}
return classMap[status] || ''
}
function getTypeLabel(type) {
const typeItem = pointTypes.find(t => t.value === type)
return typeItem ? typeItem.label : type
}
function openAddModal() {
formData.value = {
name: '',
code: '',
type: '',
projectId: '',
deviceId: '',
status: 'active'
}
showAddModal.value = true
}
function openEditModal(point) {
editingPoint.value = point
formData.value = {
name: point.name,
code: point.code,
type: point.type,
projectId: point.projectId,
deviceId: point.deviceId,
status: point.status
}
showEditModal.value = true
}
async function handleAdd() {
try {
await apiFetch('/api/points', {
method: 'POST',
body: JSON.stringify(formData.value)
})
showAddModal.value = false
loadPoints()
} catch (err) {
error.value = err.message || '添加失败'
}
}
async function handleEdit() {
try {
await apiFetch(`/api/points/${editingPoint.value.id}`, {
method: 'PUT',
body: JSON.stringify(formData.value)
})
showEditModal.value = false
loadPoints()
} catch (err) {
error.value = err.message || '更新失败'
}
}
async function handleDelete(point) {
if (!confirm(`确定要删除点位「${point.name}」吗?`)) return
try {
await apiFetch(`/api/points/${point.id}`, {
method: 'DELETE'
})
loadPoints()
} catch (err) {
error.value = err.message || '删除失败'
}
}
onMounted(() => {
loadPoints()
})
</script>
<template>
<div class="points-page">
<div class="page-header">
<h1>监测点位管理</h1>
<button class="btn2 btn2--primary" @click="openAddModal">添加点位</button>
</div>
<div class="filters">
<input
v-model="searchKeyword"
type="text"
class="search-input"
placeholder="搜索点位名称或编号"
/>
<select v-model="typeFilter" class="filter-select">
<option value="">全部类型</option>
<option v-for="type in pointTypes" :key="type.value" :value="type.value">
{{ type.label }}
</option>
</select>
<select v-model="statusFilter" class="filter-select">
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="points-list">
<div v-for="point in filteredPoints" :key="point.id" class="point-item">
<div class="point-main">
<div class="point-header">
<div class="point-name">{{ point.name }}</div>
<div :class="['status-badge', getStatusClass(point.status)]">
{{ getStatusLabel(point.status) }}
</div>
</div>
<div class="point-code">编号{{ point.code }}</div>
<div class="point-details">
<span class="detail-item">类型{{ getTypeLabel(point.type) }}</span>
<span class="detail-item">项目{{ point.projectName || '-' }}</span>
<span class="detail-item">设备{{ point.deviceName || '-' }}</span>
</div>
<div v-if="point.lastUpdate" class="point-update">
最后更新{{ point.lastUpdate }}
</div>
</div>
<div class="point-actions">
<button class="btn2" @click="openEditModal(point)">编辑</button>
<button class="btn2" @click="handleDelete(point)">删除</button>
</div>
</div>
<div v-if="filteredPoints.length === 0" class="empty">暂无监测点位</div>
</div>
<div v-if="showAddModal" class="modal-overlay" @click.self="showAddModal = false">
<div class="modal">
<div class="modal-header">添加监测点位</div>
<div class="modal-body">
<div class="form-group">
<label>点位名称</label>
<input v-model="formData.name" type="text" class="form-input" />
</div>
<div class="form-group">
<label>点位编号</label>
<input v-model="formData.code" type="text" class="form-input" />
</div>
<div class="form-group">
<label>点位类型</label>
<select v-model="formData.type" class="form-select">
<option value="">请选择</option>
<option v-for="type in pointTypes" :key="type.value" :value="type.value">
{{ type.label }}
</option>
</select>
</div>
<div class="form-group">
<label>所属项目</label>
<input v-model="formData.projectId" type="text" class="form-input" placeholder="项目ID" />
</div>
<div class="form-group">
<label>关联设备</label>
<input v-model="formData.deviceId" type="text" class="form-input" placeholder="设备ID" />
</div>
<div class="form-group">
<label>状态</label>
<select v-model="formData.status" class="form-select">
<option value="active">在线</option>
<option value="inactive">离线</option>
<option value="maintenance">维护中</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn2" @click="showAddModal = false">取消</button>
<button class="btn2 btn2--primary" @click="handleAdd">确定</button>
</div>
</div>
</div>
<div v-if="showEditModal" class="modal-overlay" @click.self="showEditModal = false">
<div class="modal">
<div class="modal-header">编辑监测点位</div>
<div class="modal-body">
<div class="form-group">
<label>点位名称</label>
<input v-model="formData.name" type="text" class="form-input" />
</div>
<div class="form-group">
<label>点位编号</label>
<input v-model="formData.code" type="text" class="form-input" />
</div>
<div class="form-group">
<label>点位类型</label>
<select v-model="formData.type" class="form-select">
<option value="">请选择</option>
<option v-for="type in pointTypes" :key="type.value" :value="type.value">
{{ type.label }}
</option>
</select>
</div>
<div class="form-group">
<label>所属项目</label>
<input v-model="formData.projectId" type="text" class="form-input" placeholder="项目ID" />
</div>
<div class="form-group">
<label>关联设备</label>
<input v-model="formData.deviceId" type="text" class="form-input" placeholder="设备ID" />
</div>
<div class="form-group">
<label>状态</label>
<select v-model="formData.status" class="form-select">
<option value="active">在线</option>
<option value="inactive">离线</option>
<option value="maintenance">维护中</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn2" @click="showEditModal = false">取消</button>
<button class="btn2 btn2--primary" @click="handleEdit">确定</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.points-page {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h1 {
font-size: 20px;
font-weight: 800;
margin: 0;
}
.filters {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 200px;
height: 36px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
font-size: 14px;
}
.filter-select {
height: 36px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
font-size: 14px;
cursor: pointer;
}
.loading,
.error,
.empty {
padding: 40px;
text-align: center;
color: var(--muted);
}
.error {
color: rgba(180, 44, 44, 0.95);
}
.points-list {
display: grid;
gap: 12px;
}
.point-item {
padding: 16px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.point-main {
flex: 1;
}
.point-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.point-name {
font-weight: 800;
font-size: 16px;
}
.status-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 700;
}
.status-active {
background: rgba(34, 197, 94, 0.15);
color: rgba(34, 197, 94, 0.95);
}
.status-inactive {
background: rgba(239, 68, 68, 0.15);
color: rgba(239, 68, 68, 0.95);
}
.status-maintenance {
background: rgba(234, 179, 8, 0.15);
color: rgba(234, 179, 8, 0.95);
}
.point-code {
color: var(--muted);
font-size: 13px;
margin-bottom: 8px;
}
.point-details {
display: flex;
gap: 16px;
margin-bottom: 8px;
}
.detail-item {
color: var(--muted);
font-size: 13px;
}
.point-update {
color: var(--muted);
font-size: 12px;
}
.point-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn2 {
height: 32px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid rgba(31, 35, 40, 0.12);
background: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-weight: 700;
color: rgba(31, 35, 40, 0.85);
}
.btn2:hover {
border-color: rgba(180, 128, 64, 0.35);
}
.btn2--primary {
border-color: rgba(180, 128, 64, 0.35);
background: rgba(180, 128, 64, 0.16);
}
.btn2--primary:hover {
background: rgba(180, 128, 64, 0.22);
}
.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;
}
.modal {
background: var(--surface);
border-radius: 14px;
width: 90%;
max-width: 480px;
max-height: 90vh;
overflow: auto;
}
.modal-header {
padding: 16px;
font-weight: 800;
font-size: 18px;
border-bottom: 1px solid var(--border);
}
.modal-body {
padding: 16px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 700;
font-size: 14px;
}
.form-input,
.form-select {
width: 100%;
height: 36px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
font-size: 14px;
box-sizing: border-box;
}
.modal-footer {
padding: 16px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 720px) {
.point-item {
flex-direction: column;
}
.point-actions {
width: 100%;
margin-top: 12px;
justify-content: flex-end;
}
.point-details {
flex-direction: column;
gap: 4px;
}
}
</style>

336
src/pages/record/list.vue Normal file
View File

@ -0,0 +1,336 @@
<script setup>
import { ref, onMounted } from 'vue'
//
const records = ref([
{
id: '1',
deviceId: 'DEV001',
deviceName: '温湿度传感器',
projectId: 'PROJ001',
projectName: '故宫文物保护',
pointId: 'POINT001',
pointName: '太和殿东配殿',
value: '25.5°C, 45%RH',
timestamp: '2024-03-20 14:30:00',
status: '正常'
},
{
id: '2',
deviceId: 'DEV002',
deviceName: '振动传感器',
projectId: 'PROJ001',
projectName: '故宫文物保护',
pointId: 'POINT002',
pointName: '太和殿西配殿',
value: '0.02mm/s',
timestamp: '2024-03-20 14:35:00',
status: '正常'
},
{
id: '3',
deviceId: 'DEV003',
deviceName: '光照传感器',
projectId: 'PROJ002',
projectName: '长城保护',
pointId: 'POINT003',
pointName: '八达岭长城南段',
value: '1200 lux',
timestamp: '2024-03-20 14:40:00',
status: '警告'
},
{
id: '4',
deviceId: 'DEV004',
deviceName: '温湿度传感器',
projectId: 'PROJ002',
projectName: '长城保护',
pointId: 'POINT004',
pointName: '八达岭长城北段',
value: '28.0°C, 30%RH',
timestamp: '2024-03-20 14:45:00',
status: '警告'
},
{
id: '5',
deviceId: 'DEV005',
deviceName: '振动传感器',
projectId: 'PROJ003',
projectName: '秦始皇兵马俑保护',
pointId: 'POINT005',
pointName: '一号坑',
value: '0.01mm/s',
timestamp: '2024-03-20 14:50:00',
status: '正常'
}
])
//
const page = ref(1)
const pageSize = ref(10)
const total = ref(records.value.length)
//
async function loadRecords() {
// API
console.log('加载数据...')
}
//
onMounted(() => {
loadRecords()
})
</script>
<template>
<div class="record-page">
<div class="page-header">
<h1>数据记录管理</h1>
</div>
<div class="record-list">
<table class="record-table">
<thead>
<tr>
<th>设备名称</th>
<th>项目名称</th>
<th>监测点位</th>
<th>数据值</th>
<th>时间戳</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="record in records" :key="record.id">
<td>{{ record.deviceName }}</td>
<td>{{ record.projectName }}</td>
<td>{{ record.pointName }}</td>
<td>{{ record.value }}</td>
<td>{{ record.timestamp }}</td>
<td>
<span :class="['status-badge', record.status === '正常' ? 'status-normal' : 'status-warning']">
{{ record.status }}
</span>
</td>
</tr>
</tbody>
</table>
<div v-if="records.length === 0" class="empty">暂无数据记录</div>
</div>
<!-- 分页组件 -->
<div v-if="total > 0" class="pagination">
<div class="pagination-info">
{{ total }} 条记录每页 {{ pageSize }} {{ page }} / {{ Math.ceil(total / pageSize) }}
</div>
<div class="pagination-controls">
<button
class="pagination-btn"
@click="page = 1; loadRecords()"
:disabled="page === 1"
>
首页
</button>
<button
class="pagination-btn"
@click="page--; loadRecords()"
:disabled="page === 1"
>
上一页
</button>
<button
class="pagination-btn"
@click="page++; loadRecords()"
:disabled="page >= Math.ceil(total / pageSize)"
>
下一页
</button>
<button
class="pagination-btn"
@click="page = Math.ceil(total / pageSize); loadRecords()"
:disabled="page >= Math.ceil(total / pageSize)"
>
末页
</button>
<select v-model="pageSize" @change="page = 1; loadRecords()" class="pagination-select">
<option value="10">10/</option>
<option value="20">20/</option>
<option value="50">50/</option>
</select>
</div>
</div>
</div>
</template>
<style scoped>
.record-page {
padding: 24px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-header h1 {
font-size: 20px;
font-weight: 900;
margin: 0;
}
.record-list {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.record-table {
width: 100%;
border-collapse: collapse;
}
.record-table th {
background: #f9fafb;
padding: 12px 16px;
text-align: left;
font-weight: 800;
font-size: 14px;
color: #374151;
border-bottom: 2px solid #e5e7eb;
}
.record-table td {
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
font-size: 14px;
}
.record-table tr:last-child td {
border-bottom: none;
}
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 800;
}
.status-normal {
background: #d1fae5;
color: #065f46;
}
.status-warning {
background: #fef3c7;
color: #92400e;
}
.empty {
padding: 48px;
text-align: center;
color: #6b7280;
font-weight: 800;
}
/* 分页样式 */
.pagination {
margin-top: 24px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.pagination-info {
font-size: 14px;
color: #6b7280;
font-weight: 800;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 8px;
}
.pagination-btn {
height: 32px;
padding: 0 12px;
border-radius: 6px;
border: 1px solid #d1d5db;
background: white;
color: #374151;
font-weight: 800;
cursor: pointer;
transition: all 0.2s ease;
}
.pagination-btn:hover:not(:disabled) {
background: #f3f4f6;
border-color: #b48040;
}
.pagination-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.pagination-select {
height: 32px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid #d1d5db;
background: white;
color: #374151;
font-weight: 800;
cursor: pointer;
}
.pagination-select:focus {
outline: none;
border-color: #b48040;
box-shadow: 0 0 0 3px rgba(180, 128, 64, 0.1);
}
@media (max-width: 768px) {
.record-page {
padding: 16px;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.record-table {
font-size: 12px;
}
.record-table th,
.record-table td {
padding: 8px 12px;
}
.pagination {
flex-direction: column;
align-items: flex-start;
}
.pagination-controls {
width: 100%;
justify-content: space-between;
}
.pagination-btn {
flex: 1;
min-width: 80px;
}
}
</style>

View File

@ -59,7 +59,9 @@ const router = createRouter({
{ path: 'device/list', name: 'admin-device-list', component: () => import('../pages/device/list.vue') },
{ path: 'project/list', name: 'admin-project-list', component: () => import('../pages/project/list.vue') },
{ path: 'project/detail', name: 'admin-project-detail', component: () => import('../pages/project/detail.vue') },
{path:'task/list',name:'admin-task-list',component:()=> import('../pages/task/list.vue')}
{path:'task/list',name:'admin-task-list',component:()=> import('../pages/task/list.vue')},
{path:'data/list',name:'admin-record-list',component:()=> import('../pages/record/list.vue')},
{path:'point/list',name:'admin-point-list',component:()=> import('../pages/point/list.vue')}
],
},
],