协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
240 lines
16 KiB
Vue
240 lines
16 KiB
Vue
<template>
|
|
<view class="home-page">
|
|
<view class="top-bar" :style="{ paddingTop: statusBarHeight + 'px', height: (statusBarHeight + navBarHeight) + 'px', paddingRight: '110px' }">
|
|
<view class="org-switcher" @click="showOrgPicker = true">
|
|
<text class="org-label">{{ currentOrgName || '选择组织' }}</text>
|
|
<view class="org-arrow"><AppIcon name="arrow_down" :size="12" color="#111827" /></view>
|
|
</view>
|
|
<view class="scan-btn" @click="handleScan">
|
|
<image src="/static/scan-icon.png" style="width: 24px; height: 24px;" />
|
|
</view>
|
|
</view>
|
|
|
|
<view v-if="selectedOrgId !== null" class="project-filter">
|
|
<view class="filter-inner">
|
|
<view class="filter-tag" :class="{ active: selectedProjectId === null }" @click="selectProject(null)"><text>全部</text></view>
|
|
<view v-for="p in projects" :key="p.id" class="filter-tag" :class="{ active: selectedProjectId === p.id }" @click="selectProject(p.id)"><text>{{ p.name }}</text></view>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="stats-row">
|
|
<view class="stat-item"><text class="stat-value primary">{{ Number(stats.online_cabinets) || 0 }}</text><text class="stat-label">在线设备</text></view>
|
|
<view class="stat-item"><text class="stat-value muted">{{ Number(stats.total_channels) || 0 }}</text><text class="stat-label">总通道</text></view>
|
|
<view class="stat-item"><text class="stat-value success">{{ Number(stats.idle_channels) || 0 }}</text><text class="stat-label">空闲</text></view>
|
|
<view class="stat-item"><text class="stat-value primary">{{ Number(stats.charging_count) || 0 }}</text><text class="stat-label">充电中</text></view>
|
|
<view class="stat-item"><text class="stat-value warning">{{ Number(stats.full_count) || 0 }}</text><text class="stat-label">充满</text></view>
|
|
<view class="stat-item"><text class="stat-value danger">{{ Number(stats.fault_count) || 0 }}</text><text class="stat-label">故障</text></view>
|
|
</view>
|
|
|
|
<view class="section-header"><text class="section-title">设备列表</text><text class="section-count">{{ cabinets.length }} 台</text></view>
|
|
|
|
<view v-if="cabinets.length === 0 && !loading" class="empty-state">
|
|
<text class="empty-text">暂无设备</text>
|
|
</view>
|
|
<view v-else class="device-list">
|
|
<view v-for="cab in cabinets" :key="cab.id" class="device-card" @click="goDetail(cab.id)">
|
|
<view class="device-header">
|
|
<view class="device-id-row">
|
|
<text class="device-id">{{ cab.abstract_id }}</text>
|
|
<view class="status-badge" :class="cab.status === 1 ? 'online' : 'offline'"><text>{{ cab.status === 1 ? '在线' : '离线' }}</text></view>
|
|
</view>
|
|
<view class="header-right">
|
|
<text v-if="cab.temp != null" class="temp-badge">{{ cab.temp }}°C</text>
|
|
<view class="signal-inline">
|
|
<view class="signal" :class="signalClass(cab.rssi)">
|
|
<view class="signal-bar l1"></view><view class="signal-bar l2"></view><view class="signal-bar l3"></view><view class="signal-bar l4"></view><view class="signal-bar l5"></view>
|
|
</view>
|
|
<text class="rssi-value">{{ cab.rssi ?? '—' }}</text>
|
|
</view>
|
|
<view class="pow-tags">
|
|
<view class="pow-tag" :class="isPowerFail(cab.pow_fail_dc) ? 'off' : 'on'"><text>{{ isPowerFail(cab.pow_fail_dc) ? '直流停电' : 'DC' }}</text></view>
|
|
<view class="pow-tag" :class="isPowerFail(cab.pow_fail_ac) ? 'off' : 'on'"><text>{{ isPowerFail(cab.pow_fail_ac) ? '交流停电' : 'AC' }}</text></view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
<text class="device-name">{{ cab.name || '未命名' }}</text>
|
|
<text v-if="cab.imei" class="device-imei">IMEI: {{ cab.imei }}</text>
|
|
<text v-if="cab.address" class="device-addr">{{ cab.address }}</text>
|
|
<view class="channel-stats">
|
|
<view class="channel-chip total"><AppIcon name="grid" :size="14" color="#6b7280" /><text class="chip-num">{{ Number(cab.channel_count) || 0 }}</text></view>
|
|
<view class="channel-chip idle"><AppIcon name="battery" :size="14" color="#16a34a" /><text class="chip-num">{{ Number(cab.idle_count) || 0 }}</text></view>
|
|
<view class="channel-chip charging"><AppIcon name="battery_charging" :size="14" color="#0f766e" /><text class="chip-num">{{ Number(cab.charging_count) || 0 }}</text></view>
|
|
<view class="channel-chip full"><AppIcon name="battery_full" :size="14" color="#d97706" /><text class="chip-num">{{ Number(cab.full_count) || 0 }}</text></view>
|
|
<view class="channel-chip fault"><AppIcon name="alert" :size="14" color="#dc2626" /><text class="chip-num">{{ Number(cab.fault_count) || 0 }}</text></view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<view v-if="showOrgPicker" class="org-overlay" @click="showOrgPicker = false">
|
|
<view class="org-sheet" @click.stop>
|
|
<view class="org-header"><text class="org-title">切换组织</text><view class="org-close" @click="showOrgPicker = false"><text>✕</text></view></view>
|
|
<scroll-view class="org-list" scroll-y>
|
|
<view class="org-option" :class="{ selected: selectedOrgId === null }" @click="switchOrgAll"><text>全部</text></view>
|
|
<view v-for="org in orgs" :key="org.id" class="org-option" :class="{ selected: selectedOrgId === org.id }" @click="switchOrg(org)"><text>{{ org.name }}</text></view>
|
|
</scroll-view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
|
import { h5Api, type DashboardData, type CabinetItem, type OrgItem } from '../../api'
|
|
import { useAuthStore } from '../../store/auth'
|
|
import AppIcon from '../../components/AppIcon.vue'
|
|
import { isPowerFail } from '../../utils/deviceStatus'
|
|
|
|
const statusBarHeight = ref(47)
|
|
const navBarHeight = ref(44)
|
|
function calcNav() {
|
|
try {
|
|
const sys = wx.getSystemInfoSync()
|
|
statusBarHeight.value = sys.statusBarHeight || 47
|
|
const btn = wx.getMenuButtonBoundingClientRect()
|
|
navBarHeight.value = (btn && btn.height > 0) ? (btn.top - statusBarHeight.value) * 2 + btn.height : 44
|
|
} catch { statusBarHeight.value = 47; navBarHeight.value = 44 }
|
|
}
|
|
calcNav()
|
|
setTimeout(calcNav, 300)
|
|
setTimeout(calcNav, 600)
|
|
|
|
const authStore = useAuthStore()
|
|
|
|
function signalClass(rssi?: number): string {
|
|
if (!rssi || rssi < 30) return 'off'
|
|
if (rssi >= 55) return 'good'
|
|
if (rssi >= 40) return 'mid'
|
|
return 'low'
|
|
}
|
|
|
|
const stats = ref<DashboardData>({ online_cabinets: 0, total_channels: 0, charging_count: 0, idle_channels: 0, full_count: 0, fault_channels: 0, fault_count: 0, today_charge_count: 0, today_energy_kwh: 0, today_charge_hours: 0, alerts: [], projects: [] })
|
|
const projects = computed(() => stats.value.projects || [])
|
|
const cabinets = ref<CabinetItem[]>([])
|
|
const loading = ref(true)
|
|
const selectedProjectId = ref<number | null>(null)
|
|
const orgs = ref<OrgItem[]>([])
|
|
const showOrgPicker = ref(false)
|
|
const selectedOrgId = ref<number | null>(null)
|
|
const currentOrgName = ref('')
|
|
|
|
async function initOrg() {
|
|
try { const res = await h5Api.getOrganizations(); orgs.value = res || []; switchOrgAll() } catch { currentOrgName.value = '' }
|
|
}function switchOrgAll() { showOrgPicker.value = false; selectedOrgId.value = null; currentOrgName.value = '全部'; selectedProjectId.value = null; fetchDashboard(); fetchCabinets() }
|
|
function switchOrg(org: OrgItem) { showOrgPicker.value = false; selectedOrgId.value = org.id; currentOrgName.value = org.name; selectedProjectId.value = null; fetchDashboard(); fetchCabinets() }
|
|
|
|
onShow(async () => {
|
|
const scanId = authStore.scanAbstractId
|
|
if (scanId) {
|
|
authStore.clearScanId()
|
|
if (!authStore.isAuthenticated) { uni.navigateTo({ url: '/pages/login/index' }); return }
|
|
try { const res = await h5Api.getCabinetByAbstract(scanId); uni.navigateTo({ url: `/pages/device-detail/index?id=${res.id}` }); return } catch { uni.showToast({ title: '设备不存在', icon: 'none' }) }
|
|
}
|
|
if (!authStore.isAuthenticated) { uni.navigateTo({ url: '/pages/login/index' }); return }
|
|
initOrg(); fetchDashboard(); fetchCabinets()
|
|
})
|
|
onPullDownRefresh(async () => { await Promise.all([fetchDashboard(), fetchCabinets()]); uni.stopPullDownRefresh() })
|
|
|
|
async function fetchDashboard() {
|
|
try { const params: Record<string, string> = {}; if (selectedOrgId.value) params.org_id = String(selectedOrgId.value); const res = await h5Api.getDashboard(params); stats.value = res } catch {}
|
|
}
|
|
async function fetchCabinets() {
|
|
loading.value = true
|
|
try { const params: Record<string, any> = {}; if (selectedProjectId.value !== null) params.project_id = selectedProjectId.value; if (selectedOrgId.value) params.org_id = selectedOrgId.value; const res = await h5Api.getCabinets(Object.keys(params).length > 0 ? params : undefined); cabinets.value = res || [] } catch { cabinets.value = [] } finally { loading.value = false }
|
|
}
|
|
function selectProject(id: number | null) { selectedProjectId.value = id; fetchCabinets() }
|
|
function goDetail(id: number) { uni.navigateTo({ url: `/pages/device-detail/index?id=${id}` }) }
|
|
|
|
function handleScan() {
|
|
uni.scanCode({
|
|
scanType: ['qrCode'],
|
|
success: (res) => {
|
|
const code = res.result.trim()
|
|
const match = code.match(/\/(\d{2}-\d{8})$/) || code.match(/[?&]id=(\d{2}-\d{8})/)
|
|
const abstractId = match ? match[1] : code
|
|
if (/^\d{2}-\d{8}$/.test(abstractId)) { goToDeviceByScan(abstractId) }
|
|
else { uni.showToast({ title: '无效的二维码', icon: 'none' }) }
|
|
},
|
|
fail: () => { showScanInput() }
|
|
})
|
|
}
|
|
async function goToDeviceByScan(code: string) {
|
|
try { const result = await h5Api.scanCabinet(code); uni.navigateTo({ url: `/pages/device-detail/index?id=${result.id}` }) } catch (err: any) {
|
|
const msg = err?.message || ''
|
|
if (msg.includes('没有权限') || msg.includes('403') || msg.includes('Forbidden')) { uni.showToast({ title: '没有权限访问该设备', icon: 'none' }) } else if (msg.includes('不存在')) { uni.showToast({ title: '设备不存在', icon: 'none' }) } else { uni.showToast({ title: '扫码失败: ' + msg, icon: 'none' }) }
|
|
}
|
|
}
|
|
function showScanInput() {
|
|
uni.showModal({ title: '扫码', content: '请输入或粘贴二维码中的设备编号', editable: true, placeholderText: '如: 10-00000000', success: async (res) => { if (!res.confirm || !res.content?.trim()) return; goToDeviceByScan(res.content.trim()) } })
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.home-page { background: #f5f7f8; min-height: 100vh; }
|
|
.top-bar { display: flex; align-items: center; justify-content: space-between; padding: 0 20px; background: #fff; border-bottom: 1px solid #f1f5f9; position: sticky; top: 0; z-index: 100; box-sizing: border-box; }
|
|
.org-switcher { display: flex; align-items: center; gap: 6px; padding: 6px 12px; background: #f1f5f9; border-radius: 999px; }
|
|
.org-label { font-size: 14px; font-weight: 500; color: #111827; max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.org-arrow { display: flex; align-items: center; }
|
|
.scan-btn { display: flex; align-items: center; justify-content: center; background: none; color: #111827; border: none; padding: 0; }
|
|
.project-filter { padding: 12px 20px; background: #fff; border-bottom: 1px solid #f1f5f9; overflow-x: auto; }
|
|
.project-filter::-webkit-scrollbar { display: none; }
|
|
.filter-inner { display: flex; gap: 8px; white-space: nowrap; }
|
|
.filter-tag { flex-shrink: 0; padding: 6px 14px; font-size: 13px; border-radius: 999px; border: 1px solid #e2e8f0; color: #475569; background: #fff; }
|
|
.filter-tag.active { background: #0d9488; color: #fff; border-color: #0d9488; }
|
|
.stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 16px 20px; }
|
|
.stat-item { background: #fff; border-radius: 12px; padding: 16px 12px; text-align: center; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
|
|
.stat-value { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.1; display: block; }
|
|
.stat-value.primary { color: #0d9488; }
|
|
.stat-value.success { color: #22c55e; }
|
|
.stat-value.warning { color: #f59e0b; }
|
|
.stat-value.danger { color: #ef4444; }
|
|
.stat-value.muted { color: #94a3b8; }
|
|
.stat-label { font-size: 12px; color: #94a3b8; margin-top: 4px; }
|
|
.section-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; }
|
|
.section-title { font-size: 16px; font-weight: 600; color: #111827; }
|
|
.section-count { font-size: 12px; color: #94a3b8; }
|
|
.device-list { padding: 0 20px; display: flex; flex-direction: column; gap: 12px; }
|
|
.device-card { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
|
|
.device-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
|
.device-id-row { display: flex; align-items: center; gap: 8px; }
|
|
.device-id { font-size: 14px; font-weight: 600; color: #111827; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }
|
|
.status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; font-size: 11px; font-weight: 500; border-radius: 999px; }
|
|
.status-badge.online { background: #f0fdf4; color: #22c55e; }
|
|
.status-badge.offline { background: #f1f5f9; color: #94a3b8; }
|
|
.status-badge.online::before, .status-badge.offline::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
|
.temp-badge { font-size: 12px; color: #475569; font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
|
.header-right { display: flex; align-items: center; gap: 8px; }
|
|
.signal-inline { display: flex; align-items: center; gap: 3px; }
|
|
.signal { display: flex; align-items: flex-end; gap: 1.5px; }
|
|
.signal-bar { width: 3px; border-radius: 1px; }
|
|
.signal-bar.l1 { height: 4px; } .signal-bar.l2 { height: 7px; } .signal-bar.l3 { height: 10px; } .signal-bar.l4 { height: 13px; } .signal-bar.l5 { height: 16px; }
|
|
.signal.good .signal-bar { background: #16a34a; } .signal.mid .signal-bar { background: #f59e0b; } .signal.low .signal-bar { background: #ef4444; } .signal.off .signal-bar { background: #94a3b8; }
|
|
.rssi-value { font-size: 11px; color: #94a3b8; font-variant-numeric: tabular-nums; }
|
|
.pow-tags { display: flex; gap: 4px; }
|
|
.pow-tag { font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: 3px; letter-spacing: 0.04em; white-space: nowrap; }
|
|
.pow-tag.on { background: #dcfce7; color: #16a34a; }
|
|
.pow-tag.off { background: #fee2e2; color: #dc2626; }
|
|
.device-name { font-size: 14px; font-weight: 500; color: #111827; display: block; margin-bottom: 4px; }
|
|
.device-imei { font-size: 11px; color: #94a3b8; font-variant-numeric: tabular-nums; display: block; margin-bottom: 4px; opacity: 0.7; }
|
|
.device-addr { font-size: 12px; color: #475569; display: block; margin-bottom: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.channel-stats { display: flex; align-items: center; gap: 12px; padding-top: 12px; border-top: 1px solid #f1f5f9; }
|
|
.channel-chip { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: #475569; }
|
|
.channel-chip.total { color: #6b7280; }
|
|
.channel-chip.idle { color: #16a34a; }
|
|
.channel-chip.charging { color: #0f766e; }
|
|
.channel-chip.full { color: #d97706; }
|
|
.channel-chip.fault { color: #dc2626; }
|
|
.chip-num { font-variant-numeric: tabular-nums; font-weight: 600; color: #111827; }
|
|
.empty-state { text-align: center; padding: 64px 20px; color: #94a3b8; }
|
|
.empty-text { font-size: 14px; }
|
|
.org-overlay { position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; }
|
|
.org-sheet { background: #fff; width: 100%; max-width: 430px; max-height: 70vh; border-radius: 16px 16px 0 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
.org-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid #f1f5f9; }
|
|
.org-title { font-size: 16px; font-weight: 600; }
|
|
.org-close { width: 28px; height: 28px; border-radius: 50%; background: #f1f5f9; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #475569; }
|
|
.org-list { flex: 1; }
|
|
.org-option { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; font-size: 15px; color: #111827; border-bottom: 1px solid #f1f5f9; }
|
|
.org-option.selected { color: #0d9488; font-weight: 500; }
|
|
.org-option.selected::after { content: '✓'; font-size: 16px; font-weight: 600; }
|
|
</style>
|