Files
akmon/pages/location/gateway.uvue
2026-01-20 08:04:15 +08:00

2088 lines
60 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="gateway-page-root">
<scroll-view direction="vertical" class="gateway-scroll" show-scrollbar :scroll-into-view="scrollTargetId"
:scroll-with-animation="true" :enable-flex="true">
<view class="page-header">
<view class="title-group">
<text class="page-title">网关管理</text>
<text class="page-subtitle">管理 24G 网关注册、位置参数与运行状态</text>
</view>
<view class="header-actions">
<button class="refresh-btn" @click="refreshAll" :disabled="loadingList">刷新</button>
<button class="primary-btn" @click="openCreate">新增网关</button>
</view>
</view>
<view class="summary-grid">
<view class="summary-card" v-for="card in summaryCards" :key="card.key">
<text class="summary-label">{{ card.label }}</text>
<view class="summary-value-row">
<text class="summary-value">{{ card.value }}</text>
<view v-if="loadingSummary" class="loading-dot"></view>
</view>
<text class="summary-sub">{{ card.desc }}</text>
</view>
</view>
<view class="filters">
<view class="filter-field">
<text class="filter-label">关键字</text>
<input class="filter-input" v-model="searchKeyword" placeholder="名称 / 编号 / IP / MAC"
@confirm="applyFilter" />
</view>
<view class="filter-field">
<text class="filter-label">校区/区域</text>
<input class="filter-input" v-model="campusKeyword" placeholder="校区 / 楼栋 / 房间"
@confirm="applyFilter" />
</view>
<view class="filter-field picker-field">
<text class="filter-label">状态</text>
<picker class="status-picker" :range="statusLabels" :value="selectedStatusIndex"
@change="onStatusChange">
<view class="picker-display">{{ statusLabels[selectedStatusIndex] }}</view>
</picker>
</view>
<view class="filter-actions">
<button class="filter-btn primary" @click="applyFilter">筛选</button>
<button class="filter-btn" @click="resetFilters">重置</button>
</view>
</view>
<view class="list-section">
<view v-if="loadingList" class="state-block">
<text>正在加载网关数据...</text>
</view>
<view v-else-if="errorMessage" class="state-block error">
<text>{{ errorMessage }}</text>
<button class="filter-btn" @click="fetchGateways">重试</button>
</view>
<view v-else>
<view v-if="gatewayList.length === 0" class="state-block empty">
<text>暂无符合条件的网关</text>
<text class="hint">尝试调整筛选条件或新增一个网关。</text>
</view>
<view v-else class="content-split">
<scroll-view scroll-y class="list-panel" show-scrollbar="false">
<view class="gateway-table" id="gateway-list">
<view class="table-header">
<text class="col name">名称</text>
<text class="col code">资产编号</text>
<text class="col status">状态</text>
<text class="col location">位置</text>
<text class="col coords">坐标</text>
<text class="col heartbeat">心跳</text>
<text class="col online">最近在线</text>
<text class="col actions">操作</text>
</view>
<view class="table-row" v-for="gateway in gatewayList" :key="gateway.id"
:class="{ selected: selectedGateway != null && selectedGateway.id === gateway.id }"
@tap="handleRowClick(gateway)">
<view class="col name">
<text class="name-text">{{ gateway.name }}</text>
<text v-if="gateway.serial_number" class="serial">SN:
{{ gateway.serial_number }}</text>
</view>
<text class="col code">{{ gateway.system_code }}</text>
<view class="col status">
<text
:class="['status-pill', gateway.status]">{{ statusLabel(gateway.status) }}</text>
</view>
<text class="col location">{{ formatLocation(gateway) }}</text>
<text class="col coords">{{ formatCoordinates(gateway) }}</text>
<text
class="col heartbeat">{{ formatHeartbeat(gateway.heartbeat_interval_s) }}</text>
<text class="col online">{{ formatTimestamp(gateway.last_online_at) }}</text>
<view class="col actions">
<button class="link-btn" @click.stop="openEdit(gateway)">编辑</button>
<button class="link-btn" @click.stop="openStatusSheet(gateway)">状态</button>
<button class="link-btn danger" @click.stop="deleteGateway(gateway)">删除</button>
</view>
</view>
</view>
<view class="card-list">
<view class="gateway-card" v-for="gateway in gatewayList" :key="'card-' + gateway.id"
@tap="handleCardClick(gateway)" hover-class="card-hover">
<view class="card-header">
<text class="card-name">{{ gateway.name }}</text>
<text
:class="['status-pill', gateway.status]">{{ statusLabel(gateway.status) }}</text>
</view>
<view class="card-row">
<text class="card-label">编号</text>
<text class="card-value">{{ gateway.system_code }}</text>
</view>
<view class="card-row">
<text class="card-label">位置</text>
<text class="card-value">{{ formatLocation(gateway) }}</text>
</view>
<view class="card-row">
<text class="card-label">坐标</text>
<text class="card-value">{{ formatCoordinates(gateway) }}</text>
</view>
<view class="card-row">
<text class="card-label">最近在线</text>
<text class="card-value">{{ formatTimestamp(gateway.last_online_at) }}</text>
</view>
<view class="card-actions">
<button class="primary-btn" @tap.stop="handleCardClick(gateway)">编辑</button>
<button class="secondary-btn" @tap.stop="openStatusSheet(gateway)">更改状态</button>
<button class="danger-btn" @tap.stop="deleteGateway(gateway)">删除</button>
</view>
</view>
</view>
</scroll-view>
<view class="detail-panel" v-if="selectedGateway">
<view class="detail-header">
<view>
<text class="detail-title">{{ selectedGateway?.name ?? '' }}</text>
<text class="detail-sub">资产编号:{{ selectedGateway?.system_code ?? '' }}</text>
</view>
<text
:class="['status-pill', selectedGateway?.status ?? '']">{{ statusLabel(selectedGateway?.status ?? '') }}</text>
</view>
<view class="detail-grid">
<view class="detail-item">
<text class="label">位置</text>
<text
class="value">{{ selectedGateway != null ? formatLocation(selectedGateway) : '' }}</text>
</view>
<view class="detail-item">
<text class="label">坐标</text>
<text
class="value">{{ selectedGateway != null ? formatCoordinates(selectedGateway) : '' }}</text>
</view>
<view class="detail-item">
<text class="label">覆盖</text>
<text
class="value">{{ selectedGateway != null ? formatCoverage(selectedGateway) : '' }}</text>
</view>
<view class="detail-item">
<text class="label">最新在线</text>
<text
class="value">{{ selectedGateway != null ? formatTimestamp(selectedGateway.last_online_at) : '' }}</text>
</view>
<view class="detail-item">
<text class="label">心跳周期</text>
<text
class="value">{{ selectedGateway != null ? formatHeartbeat(selectedGateway.heartbeat_interval_s) : '' }}</text>
</view>
<view class="detail-item">
<text class="label">固件版本</text>
<text class="value">{{ selectedGateway?.firmware_version ?? '—' }}</text>
</view>
<view class="detail-item">
<text class="label">硬件版本</text>
<text class="value">{{ selectedGateway?.hardware_version ?? '—' }}</text>
</view>
<view class="detail-item">
<text class="label">IP 地址</text>
<text class="value">{{ selectedGateway?.ip_address ?? '—' }}</text>
</view>
<view class="detail-item">
<text class="label">LAN MAC</text>
<text class="value">{{ selectedGateway?.lan_mac ?? '—' }}</text>
</view>
<view class="detail-item">
<text class="label">上行 MAC</text>
<text class="value">{{ selectedGateway?.upstream_mac ?? '—' }}</text>
</view>
<view class="detail-item">
<text class="label">最近维护</text>
<text
class="value">{{ selectedGateway != null ? formatTimestamp(selectedGateway.last_maintenance_at) : '' }}</text>
</view>
<view class="detail-item">
<text class="label">备注</text>
<text
class="value">{{ selectedGateway != null && selectedGateway.description != null ? selectedGateway.description : '—' }}</text>
</view>
</view>
<view class="detail-section"
v-if="selectedGateway != null && selectedGateway.tags != null && selectedGateway.tags.length > 0">
<text class="section-title">标签</text>
<view class="tag-list">
<text class="tag" v-for="tag in selectedGateway!!.tags" :key="tag">{{ tag }}</text>
</view>
</view>
<view class="detail-section" v-if="selectedGateway?.extra != null">
<text class="section-title">扩展信息</text>
<scroll-view scroll-y class="extra-box" show-scrollbar="false">
<text class="extra-json">{{ formatExtra(selectedGateway!!.extra) }}</text>
</scroll-view>
</view>
<view class="detail-actions">
<button class="secondary-btn" @click="openEdit(selectedGateway!!)">编辑网关</button>
<button class="secondary-btn" @click="openStatusSheet(selectedGateway!!)">调整状态</button>
</view>
</view>
</view>
<view class="pagination" v-if="gatewayList.length > 0">
<button class="filter-btn" :disabled="!hasPrevPage" @click="goPrevPage">上一页</button>
<text class="page-info">{{ listRangeText }}</text>
<button class="filter-btn" :disabled="!hasNextPage" @click="goNextPage">下一页</button>
</view>
</view>
</view>
</scroll-view>
<view v-if="showFormModal" class="modal-overlay">
<view class="modal">
<view class="modal-header">
<text class="modal-title">{{ formMode === 'create' ? '新增网关' : '编辑网关' }}</text>
<text class="modal-close" @click="closeForm">✕</text>
</view>
<scroll-view scroll-y class="modal-body" show-scrollbar="false" :style="modalBodyStyle">
<view class="form-grid">
<view class="form-field required">
<text class="label">名称</text>
<input class="input" v-model="form.name" placeholder="如 东区-教学楼A-01" />
<text v-if="formErrors['name']" class="error-text">{{ formErrors['name'] }}</text>
</view>
<view class="form-field required">
<text class="label">资产编号</text>
<input class="input" v-model="form.system_code" placeholder="唯一编号" />
<text v-if="formErrors['system_code']"
class="error-text">{{ formErrors['system_code'] }}</text>
</view>
<view class="form-field">
<text class="label">序列号</text>
<input class="input" v-model="form.serial_number" placeholder="硬件序列号" />
</view>
<view class="form-field">
<text class="label">状态</text>
<picker class="input" :range="statusLabelsWithoutAll" :value="formStatusIndex"
@change="onFormStatusChange">
<view class="picker-display">{{ statusLabel(form.status) }}</view>
</picker>
</view>
<view class="form-field">
<text class="label">校区</text>
<input class="input" v-model="form.campus_code" placeholder="如 主校区" />
</view>
<view class="form-field">
<text class="label">区域</text>
<input class="input" v-model="form.area_name" placeholder="如 体育馆" />
</view>
<view class="form-field">
<text class="label">楼栋</text>
<input class="input" v-model="form.building_name" placeholder="如 A栋" />
</view>
<view class="form-field">
<text class="label">楼层</text>
<input class="input" v-model="form.floor_label" placeholder="如 2F" />
</view>
<view class="form-field">
<text class="label">房间</text>
<input class="input" v-model="form.room_label" placeholder="如 201" />
</view>
<view class="form-field required">
<text class="label">纬度</text>
<input class="input" type="number" v-model="form.latitude" placeholder="-90 ~ 90" />
<text v-if="formErrors.latitude" class="error-text">{{ formErrors.latitude }}</text>
</view>
<view class="form-field required">
<text class="label">经度</text>
<input class="input" type="number" v-model="form.longitude" placeholder="-180 ~ 180" />
<text v-if="formErrors.longitude" class="error-text">{{ formErrors.longitude }}</text>
</view>
<view class="form-field span-2 location-tools" v-if="formMode === 'create'">
<button class="secondary-btn" :disabled="locating" @click="useCurrentLocation">
{{ locating ? '定位中...' : '使用当前位置填充坐标' }}
</button>
<text class="location-hint">将请求定位权限,并自动填充经纬度与定位信息。</text>
</view>
<view class="form-field">
<text class="label">海拔 (米)</text>
<input class="input" type="number" v-model="form.altitude_m" placeholder="可选" />
</view>
<view class="form-field">
<text class="label">安装高度 (米)</text>
<input class="input" type="number" v-model="form.install_height_m" placeholder="如 2.8" />
</view>
<view class="form-field">
<text class="label">天线朝向 (°)</text>
<input class="input" type="number" v-model="form.orientation_deg" placeholder="0-360" />
</view>
<view class="form-field">
<text class="label">覆盖半径 (米)</text>
<input class="input" type="number" v-model="form.coverage_radius_m" placeholder="如 30" />
</view>
<view class="form-field">
<text class="label">心跳周期 (秒)</text>
<input class="input" type="number" v-model="form.heartbeat_interval_s"
placeholder="网关定期上报间隔" />
</view>
<view class="form-field">
<text class="label">IP 地址</text>
<input class="input" v-model="form.ip_address" placeholder="10.0.0.1" />
</view>
<view class="form-field">
<text class="label">LAN MAC</text>
<input class="input" v-model="form.lan_mac" placeholder="AA:BB:CC:DD:EE:FF" />
</view>
<view class="form-field">
<text class="label">上行 MAC</text>
<input class="input" v-model="form.upstream_mac" placeholder="AA:BB:CC:DD:EE:FF" />
</view>
<view class="form-field">
<text class="label">固件版本</text>
<input class="input" v-model="form.firmware_version" placeholder="如 v1.2.3" />
</view>
<view class="form-field">
<text class="label">硬件版本</text>
<input class="input" v-model="form.hardware_version" placeholder="如 HW-2024" />
</view>
<view class="form-field span-2">
<text class="label">标签 (逗号分隔)</text>
<input class="input" v-model="form.tagsText" placeholder="教学楼, 东区" />
</view>
<view class="form-field span-2">
<text class="label">描述备注</text>
<textarea class="textarea" v-model="form.description" placeholder="安装说明、特殊注意事项..." />
</view>
<view class="form-field span-2">
<text class="label">扩展 JSON</text>
<textarea class="textarea mono" v-model="form.extraJson"
placeholder='{"vendor":"acme"}'></textarea>
<text v-if="formErrors.extraJson" class="error-text">{{ formErrors.extraJson }}</text>
</view>
</view>
</scroll-view>
<view class="modal-footer">
<button class="filter-btn" @click="closeForm">取消</button>
<button class="primary-btn" :disabled="isSubmitting"
@click="saveGateway">{{ isSubmitting ? '保存中...' : '保存' }}</button>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import type { AkSupaSelectOptions } from '@/components/supadb/aksupa.uts'
type GatewayStatus = 'active' | 'maintenance' | 'inactive' | 'retired' | ''
type LocationGateway = {
id : string
name : string
system_code : string
serial_number : string | null
status : GatewayStatus
campus_code : string | null
area_name : string | null
building_name : string | null
floor_label : string | null
room_label : string | null
latitude : number | null
longitude : number | null
altitude_m : number | null
install_height_m : number | null
orientation_deg : number | null
coverage_radius_m : number | null
heartbeat_interval_s : number | null
ip_address : string | null
lan_mac : string | null
upstream_mac : string | null
firmware_version : string | null
hardware_version : string | null
description : string | null
tags : Array<string>
extra : any
last_online_at : string | null
last_maintenance_at : string | null
coverage_notes : string | null
}
type GatewayForm = {
id : string | null
name : string
system_code : string
serial_number : string
status : GatewayStatus
campus_code : string
area_name : string
building_name : string
floor_label : string
room_label : string
latitude : string
longitude : string
altitude_m : string
install_height_m : string
orientation_deg : string
coverage_radius_m : string
heartbeat_interval_s : string
ip_address : string
lan_mac : string
upstream_mac : string
firmware_version : string
hardware_version : string
description : string
tagsText : string
extraJson : string
}
type StatusOption = {
label : string
value : GatewayStatus
desc : string
}
type SummaryCard = {
key : string
label : string
value : number
desc : string
}
type SummaryCardItem = {
key : string
label : string
value : number
desc : string
}
type SummaryStats = {
total : number,
active : number,
maintenance : number,
inactive : number,
retired : number
}
function createEmptyForm() : GatewayForm {
return {
id: null,
name: '',
system_code: '',
serial_number: '',
status: 'active',
campus_code: '',
area_name: '',
building_name: '',
floor_label: '',
room_label: '',
latitude: '',
longitude: '',
altitude_m: '',
install_height_m: '',
orientation_deg: '',
coverage_radius_m: '',
heartbeat_interval_s: '',
ip_address: '',
lan_mac: '',
upstream_mac: '',
firmware_version: '',
hardware_version: '',
description: '',
tagsText: '',
extraJson: '{}'
}
}
function cloneFilter(filter : any) : any | null {
const jsonStr = JSON.stringify(filter ?? {})
if (jsonStr == null) {
return null
}
return JSON.parse(jsonStr)
}
export default {
name: 'GatewayManagementPage',
data() {
return {
loadingList: false,
errorMessage: '',
gatewayList: [] as Array<LocationGateway>,
pageIndex: 1,
pageSize: 10,
totalCount: 0,
hasNextPage: false,
hasPrevPage: false,
searchKeyword: '',
campusKeyword: '',
statusOptions: [
{ label: '全部状态', value: '', desc: '包含所有状态的网关' },
{ label: '在线', value: 'active', desc: '正常运行并持续上报心跳' },
{ label: '维护中', value: 'maintenance', desc: '正在检修或调试' },
{ label: '离线', value: 'inactive', desc: '暂时离线,需要关注' },
{ label: '退役', value: 'retired', desc: '已移除或不再使用' }
] as Array<StatusOption>,
selectedStatusIndex: 0,
summary: {
total: 0,
active: 0,
maintenance: 0,
inactive: 0,
retired: 0
} as SummaryStats,
loadingSummary: false,
selectedGateway: null as LocationGateway | null,
showFormModal: false,
formMode: 'create' as 'create' | 'edit',
form: createEmptyForm() as GatewayForm,
formErrors: {} as UTSJSONObject,
isSubmitting: false,
locating: false,
modalBodyMaxHeight: 640,
scrollTargetId: '' as string,
queryColumns: 'id,name,system_code,serial_number,status,campus_code,area_name,building_name,floor_label,room_label,latitude,longitude,altitude_m,install_height_m,orientation_deg,coverage_radius_m,coverage_notes,heartbeat_interval_s,ip_address,lan_mac,upstream_mac,firmware_version,hardware_version,description,tags,extra,last_online_at,last_maintenance_at,created_at,updated_at'
}
},
computed: {
statusLabels() : Array<string> {
return this.statusOptions.map(item => item.label)
},
statusLabelsWithoutAll() : Array<string> {
return this.statusOptions.filter(item => item.value !== '').map(item => item.label)
},
formStatusIndex() : number {
const index = this.statusOptions.findIndex(item => item.value === this.form.status)
return index >= 0 ? Math.max(index - 1, 0) : 0
},
summaryCards() : Array<SummaryCardItem> {
const summary = this.summary as SummaryStats
return [
{ key: 'total', label: '网关总数', value: summary.total, desc: '当前筛选条件下的总量' },
{ key: 'active', label: '在线', value: summary.active, desc: '状态为 Online 的数量' },
{ key: 'maintenance', label: '维护中', value: summary.maintenance, desc: '正在维护的网关' },
{ key: 'inactive', label: '离线', value: summary.inactive, desc: '心跳缺失或未上线' },
{ key: 'retired', label: '退役', value: summary.retired, desc: '已退役设备' }
]
},
modalBodyStyle() : any {
return {
maxHeight: `${this.modalBodyMaxHeight}px`,
overflowY: 'auto',
boxSizing: 'border-box'
}
},
listRangeText() : string {
if (this.totalCount <= 0) {
return '0 / 0'
}
const start = (this.pageIndex - 1) * this.pageSize + 1
const end = Math.min(this.pageIndex * this.pageSize, this.totalCount)
return `${start}-${end} / ${this.totalCount}`
}
},
onLoad(op) {
console.log(op)
this.updateModalScrollHeight()
this.fetchAllData()
},
methods: {
async refreshAll() {
this.pageIndex = 1
await this.fetchAllData()
},
async fetchAllData() {
await this.fetchGateways()
this.fetchSummaryCounts()
},
buildBaseFilter() : UTSJSONObject {
const filter = new UTSJSONObject()
const searchParts : Array<string> = []
const keyword = this.searchKeyword.trim()
if (keyword.length > 0) {
const kw = keyword.replace(/,/g, ' ')
searchParts.push(`name.ilike.%${kw}%`)
searchParts.push(`system_code.ilike.%${kw}%`)
searchParts.push(`serial_number.ilike.%${kw}%`)
searchParts.push(`ip_address.ilike.%${kw}%`)
searchParts.push(`lan_mac.ilike.%${kw}%`)
searchParts.push(`upstream_mac.ilike.%${kw}%`)
}
const campus = this.campusKeyword.trim()
if (campus.length > 0) {
searchParts.push(`campus_code.ilike.%${campus}%`)
searchParts.push(`area_name.ilike.%${campus}%`)
searchParts.push(`building_name.ilike.%${campus}%`)
searchParts.push(`room_label.ilike.%${campus}%`)
}
if (searchParts.length > 0) {
filter.set('or', searchParts.join(','))
}
return filter
},
buildFilterForList() : any {
const filter = this.buildBaseFilter()
const status = this.statusOptions[this.selectedStatusIndex].value
if (status != null && status.length > 0) {
const statusObj = new UTSJSONObject()
statusObj.set('eq', status)
filter.set('status', statusObj)
} else if (status != null && status === '') {
// 当status为空字符串时不设置任何过滤条件
return filter
}
return filter
},
async fetchGateways() {
this.loadingList = true
this.errorMessage = ''
try {
const filter = this.buildFilterForList()
const rangeFrom = (this.pageIndex - 1) * this.pageSize
const rangeTo = rangeFrom + this.pageSize - 1
const options : AkSupaSelectOptions = {
order: 'created_at.desc',
columns: this.queryColumns,
rangeFrom,
rangeTo,
count: 'exact'
}
const res = await supa.select_uts('location_gateways', filter as UTSJSONObject, options)
const data = res?.data
const items = data != null ? this.extractArray(data) : []
this.gatewayList = this.normalizeGateways(items)
const totalFromHeader = this.extractTotalFromHeaders(res.headers)
if (totalFromHeader != null) {
this.totalCount = totalFromHeader
} else {
const computedTotal = rangeFrom + this.gatewayList.length
this.totalCount = Math.max(this.totalCount, computedTotal)
}
this.hasPrevPage = this.pageIndex > 1
this.hasNextPage = this.pageIndex * this.pageSize < this.totalCount
this.syncSelectedGateway()
} catch (error) {
console.error('fetchGateways error', error)
this.errorMessage = this.describeError(error)
} finally {
this.loadingList = false
}
},
extractArray(source : any) : Array<any> {
if (source == null) {
return []
}
if (Array.isArray(source)) {
return source
}
if (typeof source === 'object') {
const maybeArray = (source as UTSJSONObject).get('data')
if (Array.isArray(maybeArray)) {
return maybeArray
}
if (Array.isArray((source as UTSJSONObject).get('data'))) {
return (source as UTSJSONObject).get('data') as Array<any>
}
}
return []
},
readHeader(headers : any, key : string) : string {
if (headers == null) {
return ''
}
const lower = key.toLowerCase()
if (headers != null && typeof headers === 'object') {
if (headers instanceof UTSJSONObject) {
const direct = headers.get(key) as string
if (direct != null && direct.length > 0) {
return direct
}
const alt = headers.get(lower) as string
if (alt != null && alt.length > 0) {
return alt
}
} else {
// For plain objects
const directVal = (headers as UTSJSONObject).get(key)
if (directVal != null) {
return `${directVal}`
}
const altVal = (headers as UTSJSONObject).get(lower) as string
if (altVal != null && altVal.length > 0) {
return altVal
}
}
}
return ''
},
extractTotalFromHeaders(headers : any) : number | null {
const contentRange = this.readHeader(headers, 'content-range')
if (contentRange.length === 0) {
return null
}
const parts = contentRange.split('/')
if (parts.length !== 2) {
return null
}
const totalPart = parts[1].trim()
if (totalPart === '*' || totalPart.length === 0) {
return null
}
const totalNum = parseInt(totalPart)
return isNaN(totalNum) ? null : totalNum
},
normalizeGateways(items : Array<any>) : Array<LocationGateway> {
const list : Array<LocationGateway> = []
for (let i = 0; i < items.length; i++) {
list.push(this.createGatewayFromRecord(items[i]))
}
return list
},
createGatewayFromRecord(record : any) : LocationGateway {
const getter = (key : string) : any | null => {
if (record == null) {
return null
}
if (record instanceof UTSJSONObject) {
const value = record.get(key)
return value != null ? value : null
}
if (typeof record === 'object' && !Array.isArray(record)) {
const value = (record as Record<string, any>)[key]
return value != null ? value : null
}
return null
}
const getString = (key : string) : string | null => {
const value = getter(key)
if (value == null) {
return null
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'number') {
return value.toString()
}
return `${value}`
}
const getNumber = (key : string) : number | null => {
const value = getter(key)
if (value == null) {
return null
}
if (typeof value === 'number') {
return value
}
if (typeof value === 'string') {
const parsed = parseFloat(value)
return isNaN(parsed) ? null : parsed
}
return null
}
const getTags = (key : string) : Array<string> => {
const value = getter(key)
if (value == null) {
return []
}
if (Array.isArray(value)) {
return value.map(item => `${item}`.trim()).filter(item => item.length > 0)
}
if (typeof value === 'string' && value.length > 0) {
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) {
return parsed.map(item => `${item}`.trim()).filter(item => item.length > 0)
}
} catch (error) {
return value.split(/[;,]/).map(item => item.trim()).filter(item => item.length > 0)
}
}
return []
}
const getJSON = (key : string) : any | null => {
const value = getter(key)
if (value == null) {
return null
}
if (typeof value === 'string' && value.length > 0) {
try {
const parsed = JSON.parse(value)
return parsed != null ? parsed : value
} catch (error) {
return value
}
}
return value
}
const statusValue = getString('status') ?? 'active'
return {
id: getString('id') ?? '',
name: getString('name') ?? '未命名网关',
system_code: getString('system_code') ?? '',
serial_number: getString('serial_number'),
status: statusValue as GatewayStatus,
campus_code: getString('campus_code'),
area_name: getString('area_name'),
building_name: getString('building_name'),
floor_label: getString('floor_label'),
room_label: getString('room_label'),
latitude: getNumber('latitude'),
longitude: getNumber('longitude'),
altitude_m: getNumber('altitude_m'),
install_height_m: getNumber('install_height_m'),
orientation_deg: getNumber('orientation_deg'),
coverage_radius_m: getNumber('coverage_radius_m'),
heartbeat_interval_s: getNumber('heartbeat_interval_s'),
ip_address: getString('ip_address'),
lan_mac: getString('lan_mac'),
upstream_mac: getString('upstream_mac'),
firmware_version: getString('firmware_version'),
hardware_version: getString('hardware_version'),
description: getString('description'),
tags: getTags('tags'),
extra: getJSON('extra') as any,
last_online_at: getString('last_online_at'),
last_maintenance_at: getString('last_maintenance_at'),
coverage_notes: getString('coverage_notes')
}
},
syncSelectedGateway() {
if (this.gatewayList.length === 0) {
this.selectedGateway = null
return
}
if (this.selectedGateway != null) {
const updated = this.gatewayList.find(item => item.id === this.selectedGateway!!.id)
if (updated != null) {
this.selectedGateway = updated
return
}
}
this.selectedGateway = this.gatewayList[0]
},
describeError(error : any) : string {
if (error == null) {
return '未知错误'
}
if (typeof error === 'string') {
return error
}
const errObj = error as UTSJSONObject
if (errObj != null && typeof errObj.get === 'function') {
const msg = errObj.get('message') as string | null
if (msg != null) {
return msg
}
}
try {
return JSON.stringify(error)
} catch (err) {
return '网络或服务异常,请稍后重试'
}
},
statusLabel(status : GatewayStatus) : string {
switch (status) {
case 'active':
return '在线'
case 'maintenance':
return '维护中'
case 'inactive':
return '离线'
case 'retired':
return '退役'
default:
return '未定义'
}
},
formatLocation(gateway : LocationGateway) : string {
const parts : Array<string> = []
if (gateway.campus_code != null && gateway.campus_code.length > 0) {
parts.push(gateway.campus_code)
}
if (gateway.area_name != null && gateway.area_name.length > 0) {
parts.push(gateway.area_name)
}
if (gateway.building_name != null && gateway.building_name.length > 0) {
parts.push(gateway.building_name)
}
if (gateway.floor_label != null && gateway.floor_label.length > 0) {
parts.push(gateway.floor_label)
}
if (gateway.room_label != null && gateway.room_label.length > 0) {
parts.push(gateway.room_label)
}
return parts.length > 0 ? parts.join(' / ') : '未标注'
},
formatCoordinates(gateway : LocationGateway) : string {
if (gateway.latitude == null || gateway.longitude == null) {
return '未设置'
}
return `${gateway.latitude.toFixed(6)}, ${gateway.longitude.toFixed(6)}`
},
formatHeartbeat(value : number | null) : string {
if (value == null || value <= 0) {
return '—'
}
return `${value}s`
},
formatCoverage(gateway : LocationGateway) : string {
const parts : Array<string> = []
if (gateway.coverage_radius_m != null) {
parts.push(`半径 ${gateway.coverage_radius_m}m`)
}
if (gateway.install_height_m != null) {
parts.push(`高度 ${gateway.install_height_m}m`)
}
if (gateway.orientation_deg != null) {
parts.push(`朝向 ${gateway.orientation_deg}度`)
}
if (gateway.coverage_notes != null && gateway.coverage_notes.length > 0) {
parts.push(gateway.coverage_notes)
}
return parts.length > 0 ? parts.join(' · ') : '—'
},
formatTimestamp(value : string | null) : string {
if (value == null || value.length === 0) {
return '—'
}
const iso = value.replace('Z', '')
if (iso.length >= 19) {
return iso.substring(0, 19).replace('T', ' ')
}
return value
},
formatExtra(extra : any) : string {
try {
return JSON.stringify(extra, null, 2)
} catch (error) {
return `${extra}`
}
},
selectGateway(gateway : LocationGateway) {
this.selectedGateway = gateway
},
handleRowClick(gateway : LocationGateway) {
this.openEdit(gateway)
},
handleCardClick(gateway : LocationGateway) {
this.scrollToListPanel()
this.openEdit(gateway)
},
scrollToListPanel() {
try {
this.scrollTargetId = ''
this.$nextTick(() => {
this.scrollTargetId = 'gateway-list'
setTimeout(() => {
if (this.scrollTargetId === 'gateway-list') {
this.scrollTargetId = ''
}
}, 300)
})
} catch (error) {
console.warn('scrollToListPanel error', error)
}
},
async goPrevPage() {
if (!this.hasPrevPage) {
return
}
this.pageIndex = Math.max(1, this.pageIndex - 1)
await this.fetchGateways()
},
async goNextPage() {
if (!this.hasNextPage) {
return
}
this.pageIndex += 1
await this.fetchGateways()
},
onStatusChange(event : any) {
const detail = event as UTSJSONObject
const value = detail.get('detail') as UTSJSONObject
const index = parseInt(value.get('value') as string)
if (!isNaN(index)) {
this.selectedStatusIndex = index
}
},
async applyFilter() {
this.pageIndex = 1
await this.fetchGateways()
this.fetchSummaryCounts()
},
async resetFilters() {
this.searchKeyword = ''
this.campusKeyword = ''
this.selectedStatusIndex = 0
await this.applyFilter()
},
onFormStatusChange(event : any) {
const detail = event as UTSJSONObject
const value = detail.get('detail') as UTSJSONObject
const index = parseInt(value.get('value') as string)
const filtered = this.statusOptions.filter(item => item.value !== '')
if (!isNaN(index) && index >= 0 && index < filtered.length) {
this.form.status = filtered[index].value
}
},
openCreate() {
this.formMode = 'create'
this.form = createEmptyForm()
this.formErrors = {}
this.locating = false
this.updateModalScrollHeight()
this.showFormModal = true
},
openEdit(gateway : LocationGateway) {
this.selectedGateway = gateway
this.formMode = 'edit'
this.form = this.buildFormFromGateway(gateway)
this.formErrors = {}
this.updateModalScrollHeight()
this.showFormModal = true
},
closeForm() {
if (this.isSubmitting) {
return
}
this.showFormModal = false
this.formErrors = {}
},
updateModalScrollHeight() {
try {
const info = uni.getSystemInfoSync()
const windowHeight = info.windowHeight
if (windowHeight != null && windowHeight > 0) {
const reserved = 240
const computed = windowHeight - reserved
const minimum = 360
this.modalBodyMaxHeight = computed > minimum ? computed : minimum
} else {
this.modalBodyMaxHeight = 640
}
} catch (error) {
console.warn('updateModalScrollHeight error', error)
this.modalBodyMaxHeight = 640
}
},
async useCurrentLocation() {
if (this.locating) {
return
}
this.locating = true
try {
const that = this
uni.getLocation({
type: 'gcj02',
// type: 'wgs84',
isHighAccuracy: true,
geocode: true,
success: (res) => {
const latitude = res.latitude
if (latitude != null) {
that.form.latitude = latitude.toFixed(6)
}
const longitude = res.longitude
if (longitude != null) {
that.form.longitude = longitude.toFixed(6)
}
const altitude = res.altitude
if (altitude != null) {
that.form.altitude_m = altitude.toFixed(1)
}
const address = res.address as UTSJSONObject | null
const formattedAddress = that.formatGeocodeAddress(address)
if (formattedAddress.length > 0 && that.form.description.trim().length === 0) {
that.form.description = formattedAddress
}
that.updateExtraJsonWithGeolocation(res)
uni.showToast({ title: '定位成功', icon: 'success' })
},
fail: (e) => {
},
complete: (res : any) => {
uni.hideLoading()
const exeRet = JSON.stringify(res)
console.log(exeRet)
}
})
} catch (error) {
console.error('useCurrentLocation error', error)
const message = this.describeLocationError(error)
uni.showToast({ title: message, icon: 'none' })
} finally {
this.locating = false
}
},
formatGeocodeAddress(address : UTSJSONObject | null) : string {
if (address == null) {
return ''
}
const getValue = (key : string) : string => {
if (address == null) {
return ''
}
let value : any = ''
if (address instanceof UTSJSONObject) {
const temp = address.get(key)
value = temp != null ? temp : ''
} else {
const temp = (address as Record<string, any>)[key]
value = temp != null ? temp : ''
}
return typeof value === 'string' ? value : `${value}`
}
const formatted = getValue('formattedAddress')
if (formatted.length > 0) {
return formatted
}
const parts : Array<string> = []
const keys = ['country', 'province', 'city', 'district', 'township', 'streetName', 'streetNumber', 'poiName']
for (let i = 0; i < keys.length; i++) {
const value = getValue(keys[i])
if (value.length > 0) {
parts.push(value)
}
}
return parts.join('')
},
describeLocationError(error : any) : string {
if (error == null) {
return '定位失败'
}
if (typeof error === 'string') {
return error
}
const errorObj = error as UTSJSONObject
const errMsg = errorObj.get('errMsg') as string | null
if (errMsg != null && errMsg.length > 0) {
return errMsg
}
return this.describeError(error)
},
updateExtraJsonWithGeolocation(result : GetLocationSuccess) {
let parsed = {} as UTSJSONObject
try {
const parsedJson = JSON.parse(this.form.extraJson)
if (parsedJson != null && typeof parsedJson === 'object' && !Array.isArray(parsedJson)) {
parsed = parsedJson as UTSJSONObject
}
} catch (error) {
parsed = new UTSJSONObject()
}
const geolocation = result
parsed.set('geolocation', geolocation)
this.form.extraJson = JSON.stringify(parsed, null, 2)
},
toNumberOrNull(value : any) : number | null {
if (value == null) {
return null
}
if (typeof value === 'number') {
return isNaN(value) ? null : value
}
if (typeof value === 'string') {
const parsed = parseFloat(value)
return isNaN(parsed) ? null : parsed
}
return null
},
toStringOrNull(value : any) : string | null {
if (value == null) {
return null
}
if (typeof value === 'string') {
return value
}
return `${value}`
},
buildFormFromGateway(gateway : LocationGateway) : GatewayForm {
const form = createEmptyForm()
form.id = gateway.id
form.name = gateway.name
form.system_code = gateway.system_code
form.serial_number = gateway.serial_number ?? ''
form.status = gateway.status
form.campus_code = gateway.campus_code ?? ''
form.area_name = gateway.area_name ?? ''
form.building_name = gateway.building_name ?? ''
form.floor_label = gateway.floor_label ?? ''
form.room_label = gateway.room_label ?? ''
form.latitude = gateway.latitude != null ? gateway.latitude.toString() : ''
form.longitude = gateway.longitude != null ? gateway.longitude.toString() : ''
form.altitude_m = gateway.altitude_m != null ? gateway.altitude_m.toString() : ''
form.install_height_m = gateway.install_height_m != null ? gateway.install_height_m.toString() : ''
form.orientation_deg = gateway.orientation_deg != null ? gateway.orientation_deg.toString() : ''
form.coverage_radius_m = gateway.coverage_radius_m != null ? gateway.coverage_radius_m.toString() : ''
form.heartbeat_interval_s = gateway.heartbeat_interval_s != null ? gateway.heartbeat_interval_s.toString() : ''
form.ip_address = gateway.ip_address ?? ''
form.lan_mac = gateway.lan_mac ?? ''
form.upstream_mac = gateway.upstream_mac ?? ''
form.firmware_version = gateway.firmware_version ?? ''
form.hardware_version = gateway.hardware_version ?? ''
form.description = gateway.description ?? ''
form.tagsText = gateway.tags.join(',')
form.extraJson = gateway.extra != null ? this.formatExtra(gateway.extra) : '{}'
return form
},
validateForm() : boolean {
const errors = new UTSJSONObject()
if (this.form.name.trim().length === 0) {
errors.set('name', '请填写网关名称')
}
if (this.form.system_code.trim().length === 0) {
errors['system_code'] = '请填写唯一资产编号'
}
const latValue = parseFloat(this.form.latitude)
if (isNaN(latValue)) {
errors['latitude'] = '请输入有效的纬度'
} else if (latValue < -90 || latValue > 90) {
errors['latitude'] = '纬度范围应在 -90 ~ 90'
}
const lngValue = parseFloat(this.form.longitude)
if (isNaN(lngValue)) {
errors['longitude'] = '请输入有效的经度'
} else if (lngValue < -180 || lngValue > 180) {
errors['longitude'] = '经度范围应在 -180 ~ 180'
}
if (this.form.extraJson.trim().length > 0) {
try {
JSON.parse(this.form.extraJson)
} catch (error) {
errors['extraJson'] = '扩展 JSON 不是合法的 JSON 字符串'
}
}
this.formErrors = errors
return errors.size === 0
},
buildPayloadFromForm() : UTSJSONObject {
const payload = new UTSJSONObject()
payload.set('name', this.form.name.trim())
payload.set('system_code', this.form.system_code.trim())
if (this.form.serial_number.trim().length > 0) {
payload.set('serial_number', this.form.serial_number.trim())
} else {
payload.set('serial_number', null)
}
payload.set('status', this.form.status)
payload.set('campus_code', this.form.campus_code.trim().length > 0 ? this.form.campus_code.trim() : null)
payload.set('area_name', this.form.area_name.trim().length > 0 ? this.form.area_name.trim() : null)
payload.set('building_name', this.form.building_name.trim().length > 0 ? this.form.building_name.trim() : null)
payload.set('floor_label', this.form.floor_label.trim().length > 0 ? this.form.floor_label.trim() : null)
payload.set('room_label', this.form.room_label.trim().length > 0 ? this.form.room_label.trim() : null)
payload.set('latitude', parseFloat(this.form.latitude))
payload.set('longitude', parseFloat(this.form.longitude))
payload.set('altitude_m', this.form.altitude_m.trim().length > 0 ? parseFloat(this.form.altitude_m) : null)
payload.set('install_height_m', this.form.install_height_m.trim().length > 0 ? parseFloat(this.form.install_height_m) : null)
payload.set('orientation_deg', this.form.orientation_deg.trim().length > 0 ? parseFloat(this.form.orientation_deg) : null)
payload.set('coverage_radius_m', this.form.coverage_radius_m.trim().length > 0 ? parseFloat(this.form.coverage_radius_m) : null)
payload.set('heartbeat_interval_s', this.form.heartbeat_interval_s.trim().length > 0 ? parseInt(this.form.heartbeat_interval_s) : null)
payload.set('ip_address', this.form.ip_address.trim().length > 0 ? this.form.ip_address.trim() : null)
payload.set('lan_mac', this.form.lan_mac.trim().length > 0 ? this.form.lan_mac.trim() : null)
payload.set('upstream_mac', this.form.upstream_mac.trim().length > 0 ? this.form.upstream_mac.trim() : null)
payload.set('firmware_version', this.form.firmware_version.trim().length > 0 ? this.form.firmware_version.trim() : null)
payload.set('hardware_version', this.form.hardware_version.trim().length > 0 ? this.form.hardware_version.trim() : null)
payload.set('description', this.form.description.trim().length > 0 ? this.form.description.trim() : null)
const tags = this.form.tagsText.split(/[;,\s]+/).map(item => item.trim()).filter(item => item.length > 0)
payload.set('tags', tags.length > 0 ? tags : null)
if (this.form.extraJson.trim().length > 0) {
try {
payload.set('extra', JSON.parse(this.form.extraJson))
} catch (error) {
payload.set('extra', this.form.extraJson)
}
} else {
payload.set('extra', null)
}
return payload
},
async saveGateway() {
if (!this.validateForm()) {
return
}
this.isSubmitting = true
try {
const payload = this.buildPayloadFromForm()
if (this.formMode === 'create') {
await supa.insert('location_gateways', payload)
uni.showToast({ title: '已新增网关', icon: 'success' })
} else if (this.form.id != null) {
await supa.update('location_gateways', `id=eq.${this.form.id}`, payload)
uni.showToast({ title: '已保存修改', icon: 'success' })
}
this.showFormModal = false
await this.fetchGateways()
this.fetchSummaryCounts()
} catch (error) {
console.error('saveGateway error', error)
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
this.isSubmitting = false
}
},
async deleteGateway(gateway : LocationGateway) {
const confirmed = await this.confirmModal(`确定删除网关「${gateway.name}」吗?删除后不可恢复。`)
if (!confirmed) {
return
}
try {
await supa.delete('location_gateways', `id=eq.${gateway.id}`)
uni.showToast({ title: '已删除', icon: 'success' })
await this.fetchGateways()
this.fetchSummaryCounts()
} catch (error) {
console.error('deleteGateway error', error)
uni.showToast({ title: '删除失败', icon: 'none' })
}
},
async openStatusSheet(gateway : LocationGateway) : Promise<void> {
const options = this.statusOptions.filter(item => item.value !== '').map(item => item.label)
return await new Promise<void>((resolve) => {
uni.showActionSheet({
title: '选择新的运行状态',
itemList: options,
success: (res : ShowActionSheetSuccess) => {
const index = res.tapIndex as number
if (index >= 0 && index < options.length) {
const targetStatus = this.statusOptions[index + 1].value
this.updateGatewayStatus(gateway, targetStatus).then(() => {
resolve()
})
} else {
resolve()
}
},
fail: () => resolve()
})
})
},
async updateGatewayStatus(gateway : LocationGateway, status : GatewayStatus) {
if (status === gateway.status) {
return
}
try {
const payload = new UTSJSONObject()
payload.set('status', status)
await supa.update('location_gateways', `id=eq.${gateway.id}`, payload)
uni.showToast({ title: '状态已更新', icon: 'success' })
await this.fetchGateways()
this.fetchSummaryCounts()
} catch (error) {
console.error('updateGatewayStatus error', error)
uni.showToast({ title: '更新失败', icon: 'none' })
}
},
async confirmModal(message : string) : Promise<boolean> {
return await new Promise<boolean>((resolve) => {
uni.showModal({
title: '确认操作',
content: message,
success: (res) => {
resolve(res.confirm)
},
fail: () => resolve(false)
})
})
},
async fetchSummaryCounts() {
this.loadingSummary = true
try {
const baseFilter = this.buildBaseFilter()
const [total, active, maintenance, inactive, retired] = await Promise.all([
this.fetchCountWithStatus(cloneFilter(baseFilter) as UTSJSONObject, ''),
this.fetchCountWithStatus(cloneFilter(baseFilter) as UTSJSONObject, 'active'),
this.fetchCountWithStatus(cloneFilter(baseFilter) as UTSJSONObject, 'maintenance'),
this.fetchCountWithStatus(cloneFilter(baseFilter) as UTSJSONObject, 'inactive'),
this.fetchCountWithStatus(cloneFilter(baseFilter) as UTSJSONObject, 'retired')
])
this.summary.total = total
this.summary.active = active
this.summary.maintenance = maintenance
this.summary.inactive = inactive
this.summary.retired = retired
} catch (error) {
console.error('fetchSummaryCounts error', error)
} finally {
this.loadingSummary = false
}
},
async fetchCountWithStatus(filter : UTSJSONObject, status : GatewayStatus | null) : Promise<number> {
if (filter == null) {
filter = new UTSJSONObject()
}
if (status != null && status.length > 0) {
const statusObj = new UTSJSONObject()
statusObj.set('eq', status)
filter.set('status', statusObj)
} else if (status === '') {
// 空状态不进行过滤
return 0
}
const options : AkSupaSelectOptions = {
count: 'exact',
columns: 'id',
limit: 1,
order: 'created_at.desc'
}
const res = await supa.select_uts('location_gateways', filter as UTSJSONObject, options)
const total = this.extractTotalFromHeaders(res.headers)
return total != null ? total : 0
}
}
}
</script>
<style scoped>
.gateway-page-root {
display: flex;
flex-direction: column;
background: #f5f7fb;
}
.gateway-scroll {
flex: 1;
}
.gateway-page {
display: flex;
flex-direction: column;
height: 100%;
padding: 24rpx 32rpx 40rpx 32rpx;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
flex-wrap: wrap;
gap: 16rpx;
}
.title-group {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.page-title {
font-size: 36rpx;
font-weight: 600;
color: #1f2937;
}
.page-subtitle {
font-size: 26rpx;
color: #6b7280;
}
.header-actions {
display: flex;
gap: 16rpx;
flex-direction: row;
justify-content: space-between;
}
.primary-btn,
.refresh-btn,
.filter-btn,
.secondary-btn,
.danger-btn,
.link-btn {
border-radius: 16rpx;
padding: 18rpx 28rpx;
font-size: 26rpx;
border: none;
}
.primary-btn {
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: #fff;
}
.refresh-btn {
background: #fff;
color: #2563eb;
border: 1px solid #dbeafe;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220rpx, 1fr));
gap: 24rpx;
margin-bottom: 32rpx;
}
.summary-card {
background: #fff;
border-radius: 24rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
box-shadow: 0 12rpx 24rpx rgba(15, 23, 42, 0.05);
}
.summary-label {
font-size: 26rpx;
color: #6b7280;
}
.summary-value-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.summary-value {
font-size: 40rpx;
font-weight: 600;
color: #1f2937;
}
.loading-dot {
width: 16rpx;
height: 16rpx;
background: #93c5fd;
}
.summary-sub {
font-size: 24rpx;
color: #9ca3af;
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200rpx, 1fr));
gap: 16rpx;
margin-bottom: 24rpx;
align-items: end;
}
.filter-field {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.filter-label {
font-size: 24rpx;
color: #6b7280;
}
.filter-input,
.status-picker {
border-radius: 16rpx;
border: 1px solid #d1d5db;
padding: 16rpx;
background: #fff;
font-size: 26rpx;
}
.picker-display {
font-size: 26rpx;
color: #111827;
}
.filter-actions {
display: flex;
gap: 16rpx;
}
.filter-btn {
background: #fff;
color: #2563eb;
border: 1px solid #bfdbfe;
}
.filter-btn.primary {
background: #2563eb;
color: #fff;
}
.list-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
background: #fff;
border-radius: 28rpx;
padding: 16rpx;
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.03);
}
.state-block {
padding: 100rpx 24rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
color: #6b7280;
}
.state-block.error {
color: #dc2626;
}
.state-block.empty .hint {
font-size: 24rpx;
color: #9ca3af;
}
.content-split {
display: flex;
gap: 16rpx;
min-height: 0;
}
.list-panel {
flex: 1.6;
background: #fff;
border-radius: 20rpx;
padding: 12rpx;
box-shadow: 0 10rpx 24rpx rgba(15, 23, 42, 0.04);
}
.gateway-table {
display: none;
flex-direction: column;
width: 100%;
}
.table-header {
display: grid;
grid-template-columns: 220rpx 160rpx 140rpx 260rpx 220rpx 140rpx 220rpx 220rpx;
cursor: pointer;
padding: 16rpx 12rpx;
font-weight: 600;
font-size: 24rpx;
color: #6b7280;
border-bottom: 1px solid #e5e7eb;
}
.table-row {
display: grid;
grid-template-columns: 220rpx 160rpx 140rpx 260rpx 220rpx 140rpx 220rpx 220rpx;
padding: 18rpx 12rpx;
font-size: 26rpx;
align-items: center;
gap: 12rpx;
border-bottom: 1px solid #f1f5f9;
transition: background 0.2s ease;
}
.table-row.selected {
background: #eff6ff;
}
.table-row:hover {
background: #f8fafc;
}
.col.name {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.name-text {
font-weight: 600;
color: #111827;
}
.serial {
font-size: 22rpx;
color: #9ca3af;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8rpx 18rpx;
border-radius: 999px;
font-size: 24rpx;
font-weight: 500;
}
.status-pill.active {
background: rgba(59, 130, 246, 0.12);
color: #2563eb;
}
.status-pill.maintenance {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.status-pill.inactive {
background: rgba(248, 113, 113, 0.14);
color: #b91c1c;
}
.status-pill.retired {
background: rgba(107, 114, 128, 0.14);
color: #4b5563;
}
.col.actions {
display: flex;
gap: 12rpx;
}
.link-btn {
background: transparent;
color: #2563eb;
padding: 8rpx 12rpx;
}
.link-btn.danger {
color: #dc2626;
}
.card-list {
display: grid;
gap: 16rpx;
}
.gateway-card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
box-shadow: 0 10rpx 24rpx rgba(15, 23, 42, 0.04);
display: flex;
flex-direction: column;
gap: 12rpx;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-name {
font-size: 32rpx;
font-weight: 600;
}
.card-row {
display: flex;
justify-content: space-between;
font-size: 26rpx;
color: #4b5563;
}
.card-label {
color: #9ca3af;
}
.card-actions {
display: flex;
flex-direction: row;
gap: 12rpx;
margin-top: 12rpx;
}
.secondary-btn {
background: #e5f2ff;
color: #1d4ed8;
}
.danger-btn {
background: #fee2e2;
color: #b91c1c;
}
.card-hover,
.gateway-card:active {
transform: translateY(-4rpx);
box-shadow: 0 12rpx 32rpx rgba(15, 23, 42, 0.08);
}
.detail-panel {
flex: 1;
background: #ffffff;
border-radius: 24rpx;
padding: 24rpx;
box-shadow: 0 10rpx 24rpx rgba(15, 23, 42, 0.04);
display: flex;
flex-direction: column;
gap: 20rpx;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16rpx;
}
.detail-title {
font-size: 32rpx;
font-weight: 600;
color: #111827;
}
.detail-sub {
font-size: 24rpx;
color: #6b7280;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220rpx, 1fr));
gap: 18rpx;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 6rpx;
background: #f9fafb;
border-radius: 16rpx;
padding: 14rpx 16rpx;
}
.detail-item .label {
font-size: 24rpx;
color: #9ca3af;
}
.detail-item .value {
font-size: 26rpx;
color: #1f2937;
}
.detail-section {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.section-title {
font-size: 28rpx;
font-weight: 600;
color: #1f2937;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.tag {
background: #e0f2fe;
color: #0369a1;
padding: 8rpx 16rpx;
border-radius: 12rpx;
font-size: 24rpx;
}
.extra-box {
max-height: 260rpx;
background: #111827;
border-radius: 18rpx;
padding: 18rpx;
}
.extra-json {
color: #d1d5db;
font-family: "SFMono-Regular", Consolas, Monaco, monospace;
font-size: 24rpx;
white-space: pre-wrap;
}
.detail-actions {
display: flex;
gap: 16rpx;
}
.pagination {
padding: 18rpx 12rpx 6rpx 12rpx;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 16rpx;
}
.page-info {
font-size: 24rpx;
color: #4b5563;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.65);
display: flex;
justify-content: center;
align-items: center;
padding: 40rpx;
z-index: 999;
flex: 1;
}
.modal {
width: 90%;
max-width: 920rpx;
background: #fff;
border-radius: 28rpx;
display: flex;
flex-direction: column;
max-height: 90%;
flex: 1;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx;
border-bottom: 1px solid #e5e7eb;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #111827;
}
.modal-close {
font-size: 36rpx;
color: #9ca3af;
}
.modal-body {
padding: 24rpx;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220rpx, 1fr));
gap: 18rpx;
}
.form-field {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.location-tools {
flex-direction: row;
align-items: center;
gap: 16rpx;
}
.location-tools .secondary-btn {
flex-shrink: 0;
}
.location-hint {
font-size: 22rpx;
color: #6b7280;
}
.form-field.required .label::after {
content: '*';
color: #ef4444;
margin-left: 6rpx;
}
.label {
font-size: 24rpx;
color: #6b7280;
}
.input,
.textarea {
border: 1px solid #d1d5db;
border-radius: 14rpx;
padding: 14rpx 16rpx;
font-size: 26rpx;
background: #fff;
color: #111827;
}
.textarea {
min-height: 140rpx;
}
.textarea.mono {
font-family: "SFMono-Regular", Consolas, Monaco, monospace;
}
.error-text {
font-size: 22rpx;
color: #dc2626;
}
.span-2 {
grid-column: span 2;
}
.modal-footer {
padding: 20rpx 24rpx 28rpx 24rpx;
border-top: 1px solid #e5e7eb;
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 16rpx;
}
@media screen and (min-width: 1024px) {
.gateway-table {
display: flex;
}
.card-list {
display: none;
}
.list-panel {
max-height: none;
}
}
@media screen and (max-width: 1023px) {
.content-split {
flex-direction: column;
}
.detail-panel {
order: -1;
flex: 1;
}
.modal {
width: 96%;
}
}
@media screen and (max-width: 720px) {
.gateway-page {
padding: 24rpx;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filters {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.filter-actions {
justify-content: flex-start;
}
.content-split {
gap: 24rpx;
}
}
</style>