feat:新增设备概括页面
parent
d2ce549c2a
commit
cdb9107e3f
@ -0,0 +1,17 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getIotDevicePage(params = {}) {
|
||||
return request({
|
||||
url: '/admin-api/iot/device/page',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getDeviceRunOverview(params = {}) {
|
||||
return request({
|
||||
url: '/admin-api/iot/device-operation-record/runOverview',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,512 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<NavBar :title="pageTitle" />
|
||||
|
||||
<view class="filter-bar">
|
||||
<view class="keyword-wrap">
|
||||
<input
|
||||
id="overview-device-keyword-input"
|
||||
v-model="keyword"
|
||||
class="keyword-input"
|
||||
type="text"
|
||||
placeholder="搜索设备编号/名称"
|
||||
confirm-type="search"
|
||||
@input="handleKeywordInput"
|
||||
@confirm="handleSearch"
|
||||
/>
|
||||
</view>
|
||||
<view class="icon-btn" @click="resetSearch">
|
||||
<uni-icons type="refresh" size="23" color="#64748b"></uni-icons>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="list-scroll"
|
||||
:scroll-top="scrollTop"
|
||||
@scroll="onScroll"
|
||||
@scrolltolower="loadMore"
|
||||
:lower-threshold="80"
|
||||
>
|
||||
<view class="list-wrap">
|
||||
<view v-for="item in list" :key="item.id || item.deviceCode" class="device-card">
|
||||
<view class="card-header">
|
||||
<view class="device-main">
|
||||
<text class="device-name">{{ textValue(item.deviceName) }}</text>
|
||||
<text class="device-code">{{ textValue(item.deviceCode) }}</text>
|
||||
</view>
|
||||
<view :class="['status-chip', statusClass(item.operatingStatus)]">
|
||||
<view class="status-dot"></view>
|
||||
<text class="status-text">{{ operatingStatusLabel(item.operatingStatus) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card-body">
|
||||
<view class="info-row">
|
||||
<text class="info-label">采集协议</text>
|
||||
<text class="info-value">{{ textValue(item.protocol) }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">采集时间</text>
|
||||
<text class="info-value">{{ formatDateTime(item.collectionTime) }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">是否启用</text>
|
||||
<text :class="['enable-text', isEnabled(item) ? 'enabled' : 'disabled']">
|
||||
{{ isEnabled(item) ? '已启用' : '未启用' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="hint">{{ t('functionCommon.loading') }}</view>
|
||||
<view v-else-if="!list.length" class="hint">暂无设备数据</view>
|
||||
<view v-else-if="loadingMore" class="hint">{{ t('functionCommon.loadingMore') }}</view>
|
||||
<view v-else-if="finished" class="hint">{{ t('functionCommon.noMore') }}</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view v-if="showGoTop" class="go-top-btn" @click="goTop">
|
||||
<uni-icons type="top" size="22" color="#ffffff"></uni-icons>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import NavBar from '@/components/common/NavBar.vue'
|
||||
import { getIotDevicePage } from '@/api/iot/device'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const statusKey = ref('total')
|
||||
const titleText = ref('')
|
||||
const keyword = ref('')
|
||||
const allDevices = ref([])
|
||||
const filteredDevices = ref([])
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const finished = ref(false)
|
||||
const pageNo = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const scrollTop = ref(0)
|
||||
const showGoTop = ref(false)
|
||||
|
||||
let searchTimer = null
|
||||
|
||||
const pageTitle = computed(() => titleText.value || statusTitle(statusKey.value))
|
||||
|
||||
onLoad(async (options = {}) => {
|
||||
statusKey.value = String(options.status || 'total')
|
||||
titleText.value = options.title ? decodeURIComponent(String(options.title)) : statusTitle(statusKey.value)
|
||||
await fetchDevices()
|
||||
})
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
await fetchDevices(false)
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
onReachBottom(loadMore)
|
||||
|
||||
async function fetchDevices(showLoading = true) {
|
||||
if (loading.value) return
|
||||
if (showLoading) loading.value = true
|
||||
try {
|
||||
const firstPage = await getDeviceRows({ pageNo: 1, pageSize: 10 })
|
||||
let rows = firstPage.list
|
||||
const total = Number(firstPage.total || rows.length || 0)
|
||||
if (total > rows.length) {
|
||||
const allPage = await getDeviceRows({
|
||||
pageNo: 1,
|
||||
pageSize: Math.max(total, 1)
|
||||
})
|
||||
rows = allPage.list
|
||||
}
|
||||
allDevices.value = rows
|
||||
applyFilters(true)
|
||||
} catch (error) {
|
||||
allDevices.value = []
|
||||
applyFilters(true)
|
||||
uni.showToast({ title: t('functionCommon.loadFailed'), icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getDeviceRows(params) {
|
||||
const res = await getIotDevicePage(params)
|
||||
return normalizePageData(res)
|
||||
}
|
||||
|
||||
function normalizePageData(res) {
|
||||
const root = res && res.data !== undefined ? res.data : res
|
||||
const rows =
|
||||
root?.list ||
|
||||
root?.rows ||
|
||||
root?.records ||
|
||||
root?.data?.list ||
|
||||
root?.data?.rows ||
|
||||
root?.data?.records ||
|
||||
[]
|
||||
const total =
|
||||
root?.total ??
|
||||
root?.data?.total ??
|
||||
(Array.isArray(rows) ? rows.length : 0)
|
||||
return {
|
||||
list: Array.isArray(rows) ? rows : [],
|
||||
total: Number(total || 0)
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters(reset = false) {
|
||||
if (reset) {
|
||||
pageNo.value = 1
|
||||
finished.value = false
|
||||
}
|
||||
const text = keyword.value.trim().toLowerCase()
|
||||
const rows = allDevices.value.filter((item) => {
|
||||
const matchedStatus =
|
||||
statusKey.value === 'total' ||
|
||||
normalizeDeviceStatus(item?.operatingStatus) === statusKey.value
|
||||
const matchedKeyword =
|
||||
!text ||
|
||||
String(item?.deviceCode ?? '').toLowerCase().includes(text) ||
|
||||
String(item?.deviceName ?? '').toLowerCase().includes(text)
|
||||
return matchedStatus && matchedKeyword
|
||||
})
|
||||
filteredDevices.value = rows
|
||||
const nextList = rows.slice(0, pageNo.value * pageSize.value)
|
||||
list.value = nextList
|
||||
finished.value = nextList.length >= rows.length
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (loading.value || loadingMore.value || finished.value) return
|
||||
loadingMore.value = true
|
||||
pageNo.value += 1
|
||||
applyFilters(false)
|
||||
loadingMore.value = false
|
||||
}
|
||||
|
||||
function handleKeywordInput() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
applyFilters(true)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
applyFilters(true)
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
keyword.value = ''
|
||||
applyFilters(true)
|
||||
}
|
||||
|
||||
function onScroll(e) {
|
||||
showGoTop.value = (e?.detail?.scrollTop || 0) > 500
|
||||
}
|
||||
|
||||
function goTop() {
|
||||
scrollTop.value = 0
|
||||
setTimeout(() => {
|
||||
scrollTop.value = 1
|
||||
}, 80)
|
||||
}
|
||||
|
||||
function statusTitle(key) {
|
||||
const map = {
|
||||
total: t('deviceOverview.totalDevices'),
|
||||
running: t('deviceOverview.runningCount'),
|
||||
standby: t('deviceOverview.standbyCount'),
|
||||
fault: t('deviceOverview.faultCount'),
|
||||
alarm: '报警',
|
||||
offline: t('deviceOverview.offlineCount')
|
||||
}
|
||||
return map[key] || t('deviceOverview.title')
|
||||
}
|
||||
|
||||
function normalizeDeviceStatus(value) {
|
||||
const text = String(value ?? '').trim().toLowerCase()
|
||||
if (['运行', '运行中', 'running', 'run', '1'].includes(text)) return 'running'
|
||||
if (['待机', '待机中', 'standby', 'idle', '2'].includes(text)) return 'standby'
|
||||
if (['故障', '故障中', 'fault', 'error', '3'].includes(text)) return 'fault'
|
||||
if (['报警', '报警中', 'alarm', 'warning', '4', '5'].includes(text)) return 'alarm'
|
||||
return 'offline'
|
||||
}
|
||||
|
||||
function operatingStatusLabel(value) {
|
||||
const text = String(value ?? '').trim()
|
||||
if (text) return text
|
||||
const map = {
|
||||
running: '运行',
|
||||
standby: '待机',
|
||||
fault: '故障',
|
||||
alarm: '报警',
|
||||
offline: '离线'
|
||||
}
|
||||
return map[normalizeDeviceStatus(value)]
|
||||
}
|
||||
|
||||
function statusClass(value) {
|
||||
return `status-${normalizeDeviceStatus(value)}`
|
||||
}
|
||||
|
||||
function isEnabled(item) {
|
||||
const value = item?.isEnable
|
||||
if (typeof value === 'boolean') return value
|
||||
const text = String(value ?? '').trim().toLowerCase()
|
||||
return ['true', '1', 'yes', 'y'].includes(text)
|
||||
}
|
||||
|
||||
function textValue(value) {
|
||||
if (value === 0) return '0'
|
||||
if (value === false) return t('functionCommon.no')
|
||||
if (value === true) return t('functionCommon.yes')
|
||||
if (value === null || value === undefined) return '-'
|
||||
const text = String(value).trim()
|
||||
return text || '-'
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '-'
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
const [y, m, d, h = 0, mi = 0, s = 0] = value
|
||||
return `${y}-${pad(m)}-${pad(d)} ${pad(h)}:${pad(mi)}:${pad(s)}`
|
||||
}
|
||||
const text = String(value).trim()
|
||||
if (!text) return '-'
|
||||
if (/^\d{10,13}$/.test(text)) {
|
||||
const timestamp = Number(text.length === 10 ? `${text}000` : text)
|
||||
const date = new Date(timestamp)
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
padding: 18rpx 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.keyword-wrap {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
height: 68rpx;
|
||||
border: 1rpx solid #d9dde5;
|
||||
border-radius: 8rpx;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.keyword-input {
|
||||
width: 100%;
|
||||
height: 66rpx;
|
||||
padding: 0 22rpx;
|
||||
font-size: 26rpx;
|
||||
color: #1f2937;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 68rpx;
|
||||
height: 68rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.list-scroll {
|
||||
height: calc(100vh - 180rpx - var(--status-bar-height));
|
||||
}
|
||||
|
||||
.list-wrap {
|
||||
padding: 0 20rpx 30rpx;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
margin-bottom: 18rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 6rpx 20rpx rgba(15, 23, 42, 0.06);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.device-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: 31rpx;
|
||||
line-height: 1.35;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-code {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.25;
|
||||
color: #64748b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
max-width: 190rpx;
|
||||
height: 46rpx;
|
||||
padding: 0 14rpx;
|
||||
border-radius: 8rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex: 0 0 12rpx;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
min-width: 0;
|
||||
font-size: 23rpx;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
color: #15803d;
|
||||
background: #ecfdf3;
|
||||
}
|
||||
|
||||
.status-standby {
|
||||
color: #475569;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.status-fault {
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.status-alarm {
|
||||
color: #b45309;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
color: #64748b;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
margin-top: 22rpx;
|
||||
border-top: 1rpx solid #edf0f5;
|
||||
padding-top: 18rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
flex: 0 0 auto;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.3;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.info-value,
|
||||
.enable-text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 25rpx;
|
||||
line-height: 1.35;
|
||||
color: #1f2937;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.enable-text.enabled {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.enable-text.disabled {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.hint {
|
||||
padding: 30rpx 0;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.go-top-btn {
|
||||
position: fixed;
|
||||
right: 28rpx;
|
||||
bottom: 44rpx;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
background: #1f4f7a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(31, 79, 122, 0.24);
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue