2088 lines
60 KiB
Plaintext
2088 lines
60 KiB
Plaintext
<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">管理 2.4G 网关注册、位置参数与运行状态</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> |