634 lines
18 KiB
Vue
Raw Normal View History

<template>
<view class="home-page">
<!-- Top Bar: Org Switcher + Scan -->
<view class="top-bar">
<view class="org-section" @click="showOrgPicker = true">
<text class="org-name">{{ currentOrgName || '选择组织' }}</text>
<text class="org-arrow">▾</text>
</view>
<view class="scan-btn" @click="handleScan">
<svg class="scan-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7V5a2 2 0 0 1 2-2h2"/>
<path d="M17 3h2a2 2 0 0 1 2 2v2"/>
<path d="M21 17v2a2 2 0 0 1-2 2h-2"/>
<path d="M7 21H5a2 2 0 0 1-2-2v-2"/>
<line x1="7" y1="12" x2="17" y2="12"/>
</svg>
</view>
</view>
<!-- Org Picker Modal -->
<view v-if="showOrgPicker" class="org-overlay" @click="showOrgPicker = false">
<view class="org-picker" @click.stop>
<view class="org-picker-header">
<text class="org-picker-title">切换组织</text>
<text class="org-picker-close" @click="showOrgPicker = false">✕</text>
</view>
<view
class="org-option"
:class="{ active: selectedOrgId === null }"
@click="switchOrgAll"
>
<text>全部</text>
<text v-if="selectedOrgId === null" class="org-check">✓</text>
</view>
<view
v-for="org in orgs"
:key="org.id"
class="org-option"
:class="{ active: selectedOrgId === org.id }"
@click="switchOrg(org)"
>
<text>{{ org.name }}</text>
<text v-if="selectedOrgId === org.id" class="org-check">✓</text>
</view>
</view>
</view>
<!-- Project Filter -->
<scroll-view class="project-filter" scroll-x enable-flex>
<view
class="filter-tag"
:class="{ active: selectedProjectId === null }"
@click="selectProject(null)"
>
全部
</view>
<view
v-for="p in projects"
:key="p.id"
class="filter-tag"
:class="{ active: selectedProjectId === p.id }"
@click="selectProject(p.id)"
>
{{ p.name }}
</view>
</scroll-view>
<!-- 6 Stats -->
<view class="stats-row">
<view class="stat-item">
<text class="stat-value blue">{{ stats.online_cabinets ?? 0 }}</text>
<text class="stat-label">设备</text>
</view>
<view class="stat-item">
<text class="stat-value gray">{{ stats.total_channels ?? 0 }}</text>
<text class="stat-label">通道</text>
</view>
<view class="stat-item">
<text class="stat-value green">{{ stats.idle_channels ?? 0 }}</text>
<text class="stat-label">空闲</text>
</view>
<view class="stat-item">
<text class="stat-value blue">{{ stats.charging_count ?? 0 }}</text>
<text class="stat-label">充电</text>
</view>
<view class="stat-item">
<text class="stat-value purple">{{ stats.full_count ?? 0 }}</text>
<text class="stat-label">充满</text>
</view>
<view class="stat-item">
<text class="stat-value red">{{ stats.fault_count ?? 0 }}</text>
<text class="stat-label">故障</text>
</view>
</view>
<!-- Device Cards -->
<view v-if="cabinets.length === 0 && !loading" class="empty">
<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)"
>
<!-- Header: abstract_id + signal strength -->
<view class="card-header">
<text class="card-id">{{ cab.abstract_id }}</text>
<view class="signal-chip" :style="{ color: signalColor(cab.rssi) }">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path v-if="(cab.rssi ?? -1) >= 30" d="M7 20v-4"/>
<path v-if="(cab.rssi ?? -1) >= 40" d="M12 20v-8"/>
<path v-if="(cab.rssi ?? -1) >= 55" d="M17 20V8"/>
<path v-if="(cab.rssi ?? -1) >= 70" d="M22 4v16"/>
<path d="M2 20h.01"/>
</svg>
<text class="signal-num">{{ cab.rssi ?? '-' }}</text>
</view>
</view>
<!-- Name -->
<text class="card-name">{{ cab.name || '未命名' }}</text>
<!-- Channel stats (like PC CabinetGrid) -->
<view class="card-stats">
<view class="stat-chip total">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/></svg>
{{ cab.channel_count ?? 0 }}
</view>
<view class="stat-chip idle">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 7h1a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-2"/><path d="M6 7H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h1"/><path d="M11 10v2"/><path d="M11 14v.5"/><line x1="22" x2="22" y1="11" y2="13"/></svg>
{{ cab.idle_count ?? 0 }}
</view>
<view class="stat-chip charge">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 7h1a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-2"/><path d="M6 7H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h1"/><path d="M11 7l-3 5h4l-3 5"/><line x1="22" x2="22" y1="11" y2="13"/></svg>
{{ cab.charging_count ?? 0 }}
</view>
<view class="stat-chip full">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 7h1a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-2"/><path d="M6 7H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h1"/><path d="M7 10v4"/><path d="M11 10v4"/><path d="M15 10v4"/><line x1="22" x2="22" y1="11" y2="13"/></svg>
{{ cab.full_count ?? 0 }}
</view>
<view class="stat-chip fault">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
{{ cab.fault_count ?? 0 }}
</view>
</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 jsQR from 'jsqr'
const authStore = useAuthStore()
const user = authStore.user
/** RSSI → 信号颜色 */
function signalColor(rssi?: number): string {
if (!rssi || rssi < 30) return '#9ca3af' // <30 离线
if (rssi >= 70) return '#16a34a' // 强
if (rssi >= 55) return '#2563eb' // 良好
if (rssi >= 40) return '#f59e0b' // 中等
return '#f97316' // 弱
}
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)
// Org switcher state
const orgs = ref<OrgItem[]>([])
const showOrgPicker = ref(false)
const selectedOrgId = ref<number | null>(null)
const currentOrgName = ref('')
// Init org from user info
async function initOrg() {
try {
const res = await h5Api.getOrganizations()
orgs.value = res || []
// Default to 全部 (no org filter)
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 () => {
// 扫码入口检测:从全局store取参数
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.data.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.data
} catch {
// 静默失败,显示零值
}
}
async function fetchCabinets() {
loading.value = true
try {
const params: Record<string, any> = {}
const pid = selectedProjectId.value
if (pid !== null) params.project_id = pid
if (selectedOrgId.value) params.org_id = selectedOrgId.value
const res = await h5Api.getCabinets(Object.keys(params).length > 0 ? params : undefined)
cabinets.value = res.data || []
} 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() {
if (!navigator.mediaDevices?.getUserMedia) return showScanInput()
const video = document.createElement('video')
video.setAttribute('autoplay', '')
video.setAttribute('playsinline', '')
video.style.width = '100%'
video.style.maxWidth = '400px'
video.style.borderRadius = '8px'
const overlay = document.createElement('div')
overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:24px'
const closeBtn = document.createElement('button')
closeBtn.textContent = '取消'
closeBtn.style.cssText = 'padding:10px 32px;border:none;border-radius:8px;background:#fff;font-size:16px;cursor:pointer'
const hint = document.createElement('p')
hint.textContent = '将二维码对准摄像头'
hint.style.cssText = 'color:#fff;font-size:14px;margin:0'
overlay.appendChild(hint)
overlay.appendChild(video)
overlay.appendChild(closeBtn)
document.body.appendChild(overlay)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
let stream: MediaStream | null = null
let scanning = true
const stop = () => {
scanning = false
stream?.getTracks().forEach(t => t.stop())
overlay.remove()
}
closeBtn.onclick = stop
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }).then(async (s) => {
stream = s
video.srcObject = s
await video.play()
const scan = () => {
if (!scanning) return
try {
if (video.videoWidth > 0 && video.videoHeight > 0) {
canvas.width = video.videoWidth
canvas.height = video.videoHeight
ctx.drawImage(video, 0, 0)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const result = jsQR(imageData.data, imageData.width, imageData.height)
if (result) {
const code = result.data.trim()
stop()
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' })
}
return
}
}
} catch {}
setTimeout(scan, 300)
}
scan()
}).catch(() => {
stop()
showScanInput()
})
}
async function goToDeviceByScan(code: string) {
try {
const result = await h5Api.scanCabinet(code)
uni.navigateTo({ url: `/pages/device-detail/index?id=${result.data.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
const code = res.content.trim()
goToDeviceByScan(code)
}
})
}
</script>
<style scoped>
.home-page {
padding: 12px 12px 0;
}
/* Top Bar */
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.org-section {
display: flex;
align-items: center;
padding: 6px 10px;
background: #f3f4f6;
border-radius: 6px;
}
.org-name {
font-size: 14px;
font-weight: 600;
color: #111;
}
.org-arrow {
font-size: 12px;
color: #6b7280;
margin-left: 4px;
}
.scan-btn {
display: flex;
align-items: center;
justify-content: center;
height: 36px;
color: #6b7280;
}
.scan-icon {
width: 22px;
height: 22px;
display: block;
}
/* Org Picker Modal */
.org-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.4);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
}
.org-picker {
background: #fff;
border-radius: 12px;
width: 85%;
max-height: 60vh;
overflow-y: auto;
}
.org-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #f3f4f6;
}
.org-picker-title {
font-size: 16px;
font-weight: 600;
color: #111;
}
.org-picker-close {
font-size: 18px;
color: #6b7280;
padding: 4px;
}
.org-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
font-size: 15px;
color: #374151;
border-bottom: 1px solid #f9fafb;
}
.org-option.active {
color: #2563eb;
font-weight: 600;
background: #eff6ff;
}
.org-option:last-child {
border-bottom: none;
}
.org-check {
color: #2563eb;
font-size: 14px;
}
/* Project filter */
.project-filter {
display: flex;
white-space: nowrap;
margin-bottom: 12px;
padding-bottom: 4px;
}
.filter-tag {
display: inline-flex;
padding: 5px 14px;
margin-right: 8px;
border-radius: 16px;
font-size: 13px;
color: #6b7280;
background-color: #f3f4f6;
}
.filter-tag.active {
color: #fff;
background-color: #2563eb;
font-weight: 600;
}
/* 5 Stats */
.stats-row {
display: flex;
gap: 6px;
margin-bottom: 12px;
}
.stat-item {
flex: 1;
background: #fff;
border-radius: 8px;
padding: 8px 2px;
text-align: center;
}
.stat-value {
font-size: 18px;
font-weight: 700;
display: block;
}
.stat-value.blue { color: #2563eb; }
.stat-value.gray { color: #6b7280; }
.stat-value.green { color: #16a34a; }
.stat-value.purple { color: #7c3aed; }
.stat-value.red { color: #dc2626; }
.stat-label {
font-size: 11px;
color: #6b7280;
margin-top: 4px;
}
/* Empty */
.empty {
padding: 40px 0;
text-align: center;
}
.empty-text {
font-size: 15px;
color: #9ca3af;
}
/* Device Cards */
.device-list {
display: flex;
flex-direction: column;
gap: 10px;
padding-bottom: 12px;
}
.device-card {
background: #fff;
border-radius: 10px;
padding: 12px 14px;
}
.device-card:active {
background-color: #f9fafb;
}
/* Card header */
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.card-id {
font-size: 14px;
font-weight: 600;
color: #111;
}
.badge {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
}
.signal-chip {
display: flex;
align-items: center;
gap: 2px;
}
.signal-chip svg {
display: block;
width: 18px;
height: 18px;
}
.signal-num {
font-size: 13px;
font-weight: 600;
}
/* Card name */
.card-name {
font-size: 15px;
color: #374151;
margin-bottom: 6px;
display: block;
}
/* Channel stats */
.card-stats {
display: flex;
gap: 8px;
padding-top: 8px;
border-top: 1px solid #f3f4f6;
}
.stat-chip {
font-size: 12px;
color: #6b7280;
display: flex;
align-items: center;
gap: 4px;
}
.stat-chip svg {
display: block;
flex-shrink: 0;
width: 14px;
height: 14px;
}
.stat-chip.total { color: #6b7280; }
.stat-chip.idle { color: #16a34a; }
.stat-chip.charge { color: #2563eb; }
.stat-chip.full { color: #7c3aed; }
.stat-chip.fault { color: #dc2626; }
</style>