feat: H5 用户端 uni-app 重写(替换 Taro)

- 使用 uni-app + Vue 3 + Composition API 重写 H5 用户端
- 6 个页面:登录、首页、设备列表、设备详情、仓体详情、个人中心
- Pinia 状态管理 + API 层封装
- TabBar 导航(首页/设备/我的)
- 已构建并部署到服务器 /data/pms/h5/
- 登录页渲染正常,布局完整
This commit is contained in:
12451 2026-07-03 04:10:46 +08:00
parent 6b97cd78ad
commit e90da20e82
30 changed files with 1497 additions and 7 deletions

View File

@ -38,6 +38,7 @@ const config = {
staticDirectory: 'static', staticDirectory: 'static',
router: { router: {
mode: 'browser', mode: 'browser',
basename: '/h5/',
}, },
postcss: { postcss: {
autoprefixer: { enable: true, config: {} }, autoprefixer: { enable: true, config: {} },

View File

@ -1,11 +1,11 @@
export default { export default {
pages: [ pages: [
'pages/login/index',
'pages/home/index', 'pages/home/index',
'pages/devices/index', 'pages/devices/index',
'pages/me/index',
'pages/login/index',
'pages/device-detail/index', 'pages/device-detail/index',
'pages/compartment-detail/index', 'pages/compartment-detail/index',
'pages/me/index',
], ],
window: { window: {
backgroundTextStyle: 'light', backgroundTextStyle: 'light',

View File

@ -5,17 +5,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>安知充</title> <title>安知充</title>
<script> <script>
// 默认路由:根路径跳转到登录页 // 默认路由:根路径跳转到 home 页tab 页必须在 pages 数组第一位)
;(function () { ;(function () {
var path = window.location.pathname; var path = window.location.pathname;
// 扫码入口: 提取 URL 中的 abstract_id 并重写路由 // 扫码入口: 提取 URL 中的 abstract_id 并重写路由
var match = path.match(/\/h5\/(\d{2}-\d{8})/); var match = path.match(/\/h5\/(\d{2}-\d{8})/);
if (match) { if (match) {
window.__SCAN_ABSTRACT_ID__ = match[1]; window.__SCAN_ABSTRACT_ID__ = match[1];
window.history.replaceState({}, '', '/h5/'); window.history.replaceState({}, '', '/h5/pages/home/index');
} else if (path === '/h5/' || path === '/h5') { } else if (path === '/h5/' || path === '/h5') {
// 纯根路径访问,重定向到登录页让 Taro 能匹配路由 // 纯根路径访问,重定向到 home 页让 Taro 能匹配路由
window.history.replaceState({}, '', '/h5/pages/login/index'); window.history.replaceState({}, '', '/h5/pages/home/index');
} }
})(); })();
// Taro H5 路由 base // Taro H5 路由 base

View File

@ -16,6 +16,11 @@ export default function CompartmentDetailPage() {
const [actionLoading, setActionLoading] = useState('') const [actionLoading, setActionLoading] = useState('')
const [confirmAction, setConfirmAction] = useState<'stop' | 'door' | null>(null) const [confirmAction, setConfirmAction] = useState<'stop' | 'door' | null>(null)
// 隐藏 tabbar非 tab 页)
useEffect(() => {
Taro.hideTabBar()
}, [])
const fetchDetail = async () => { const fetchDetail = async () => {
if (!id) return if (!id) return
setLoading(true) setLoading(true)

View File

@ -14,6 +14,11 @@ export default function DeviceDetailPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
// 隐藏 tabbar非 tab 页)
useEffect(() => {
Taro.hideTabBar()
}, [])
const fetchDetail = async () => { const fetchDetail = async () => {
if (!id) return if (!id) return
setLoading(true) setLoading(true)

View File

@ -7,9 +7,17 @@ import Loading from '../../components/Loading'
export default function HomePage() { export default function HomePage() {
const user = useH5AuthStore((s) => s.user) const user = useH5AuthStore((s) => s.user)
const isAuthenticated = useH5AuthStore((s) => s.isAuthenticated)
const [data, setData] = useState<DashboardData | null>(null) const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// 未登录跳转到登录页
useEffect(() => {
if (!isAuthenticated) {
Taro.navigateTo({ url: '/pages/login/index' })
}
}, [isAuthenticated])
const fetchDashboard = async () => { const fetchDashboard = async () => {
setLoading(true) setLoading(true)
try { try {

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react' import React, { useEffect, useState } from 'react'
import { View, Text, Input, Button } from '@tarojs/components' import { View, Text, Input, Button } from '@tarojs/components'
import Taro, { useRouter } from '@tarojs/taro' import Taro, { useRouter } from '@tarojs/taro'
import { useH5AuthStore } from '../../store/auth' import { useH5AuthStore } from '../../store/auth'
@ -12,6 +12,11 @@ export default function LoginPage() {
const [error, setError] = useState('') const [error, setError] = useState('')
const { login, loading } = useH5AuthStore() const { login, loading } = useH5AuthStore()
// 隐藏 tabbarlogin 页不是 tab 页)
useEffect(() => {
Taro.hideTabBar()
}, [])
const handleLogin = async () => { const handleLogin = async () => {
if (!phone || !password) { if (!phone || !password) {
setError('请输入手机号和密码') setError('请输入手机号和密码')

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>安知充</title>
<script>
// 扫码入口:提取 abstract_id 并重写路由
;(function () {
var path = window.location.pathname;
var match = path.match(/\/h5\/(\d{2}-\d{8})/);
if (match) {
window.__SCAN_ABSTRACT_ID__ = match[1];
window.history.replaceState({}, '', '/h5/');
}
})();
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,27 @@
{
"name": "h5-uniapp",
"version": "1.0.0",
"private": true,
"scripts": {
"dev:h5": "uni",
"build:h5": "uni build"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-4060620250520001",
"@dcloudio/uni-app-plus": "3.0.0-4060620250520001",
"@dcloudio/uni-components": "3.0.0-4060620250520001",
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
"pinia": "^2.1.7",
"vue": "^3.4.21"
},
"devDependencies": {
"@dcloudio/types": "^3.4.8",
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
"typescript": "^5.4.5",
"vite": "^5.2.8"
}
}

View File

@ -0,0 +1,16 @@
<script setup lang="ts">
import { onLaunch } from '@dcloudio/uni-app'
import { useAuthStore } from './store/auth'
onLaunch(() => {
const authStore = useAuthStore()
authStore.restore()
})
</script>
<style>
page {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f5f5;
}
</style>

View File

@ -0,0 +1,164 @@
// API 层 — 复用 Taro 项目的逻辑,改用 uni.request
const BASE_URL = '/api/h5'
interface RequestOptions {
method?: string
body?: unknown
params?: Record<string, string>
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const token = uni.getStorageSync('h5_token')
const header: Record<string, string> = {
'Content-Type': 'application/json',
}
if (token) {
header['Authorization'] = `Bearer ${token}`
}
let url = `${BASE_URL}${path}`
if (options.params) {
const qs = Object.entries(options.params)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&')
url += `?${qs}`
}
return new Promise((resolve, reject) => {
uni.request({
url,
method: (options.method as any) || 'GET',
header,
data: options.body,
success: (res) => {
if (res.statusCode >= 400) {
const msg = (res.data as any)?.message || (res.data as any)?.error || '请求失败'
reject(new Error(msg))
return
}
resolve(res.data as T)
},
fail: (err) => {
reject(new Error(err.errMsg || '网络错误'))
}
})
})
}
// ---- Types ----
export interface LoginParams {
phone: string
password: string
}
export interface LoginResult {
token: string
user: H5User
}
export interface H5User {
id: number
phone: string
name?: string
}
export interface DashboardData {
online_cabinets: number
charging_count: number
idle_channels: number
fault_channels: number
today_charge_count: number
today_energy_kwh: number
today_charge_hours: number
alerts: { project: string; cabinet: string; channel: number }[]
projects: { id: number; name: string; cabinet_count: number; charging: number; idle: number; fault: number }[]
}
export interface CabinetItem {
id: number
abstract_id: string
name?: string
status: number
project_id?: number
}
export interface CompartmentInfo {
id: number
channel_id: number
status: number
voltage?: number
current?: number
}
export interface CabinBoard {
id: number
board_id: number
status: number
compartments: CompartmentInfo[]
}
export interface CabinetDetail {
id: number
abstract_id: string
name?: string
imei?: string
status: number
cabin_boards: CabinBoard[]
}
export interface CompartmentDetail {
id: number
channel_id: number
status: number
voltage?: number
current?: number
power?: number
energy?: number
soc?: number
soh?: number
cell_voltages?: number[]
cell_temps?: number[]
bms_alerts?: string[]
charge_records?: ChargeRecord[]
}
export interface ChargeRecord {
id: number
start_time: string
end_time?: string
energy_kwh: number
cost: number
}
// ---- API ----
export const h5Api = {
login: (params: LoginParams) =>
request<{ data: LoginResult }>('/h5/auth/login', { method: 'POST', body: params }),
getDashboard: () =>
request<{ data: DashboardData }>('/h5/dashboard'),
getCabinets: (params?: { project_id?: number }) =>
request<{ data: CabinetItem[] }>('/h5/cabinets', { params: params as Record<string, string> }),
getCabinetByAbstract: (abstractId: string) =>
request<{ data: CabinetDetail }>(`/h5/cabinets/abstract/${abstractId}`),
getCabinetDetail: (id: number) =>
request<{ data: CabinetDetail }>(`/h5/cabinets/${id}`),
getCompartmentDetail: (id: number) =>
request<{ data: CompartmentDetail }>(`/h5/compartments/${id}`),
startCharge: (compartmentId: number) =>
request<{ code: number }>('/h5/charge/start', { method: 'POST', body: { compartment_id: compartmentId } }),
stopCharge: (compartmentId: number) =>
request<{ code: number }>('/h5/charge/stop', { method: 'POST', body: { compartment_id: compartmentId } }),
openDoor: (compartmentId: number) =>
request<{ code: number }>('/h5/door/open', { method: 'POST', body: { compartment_id: compartmentId } }),
}

7
software/h5-uniapp/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="@dcloudio/types" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@ -0,0 +1,10 @@
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
return { app }
}

View File

@ -0,0 +1,24 @@
{
"name": "安知充",
"appid": "",
"description": "智能充电柜管理系统",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"h5": {
"title": "安知充",
"router": {
"mode": "history",
"base": "/h5/"
},
"devServer": {
"port": 5175,
"proxy": {
"/api": {
"target": "http://10.8.0.252:3000",
"changeOrigin": true
}
}
}
}
}

View File

@ -0,0 +1,27 @@
{
"pages": [
{ "path": "pages/home/index", "style": { "navigationBarTitleText": "首页" } },
{ "path": "pages/devices/index", "style": { "navigationBarTitleText": "设备" } },
{ "path": "pages/me/index", "style": { "navigationBarTitleText": "我的" } },
{ "path": "pages/login/index", "style": { "navigationBarTitleText": "登录", "navigationStyle": "custom" } },
{ "path": "pages/device-detail/index", "style": { "navigationBarTitleText": "设备详情" } },
{ "path": "pages/compartment-detail/index", "style": { "navigationBarTitleText": "仓体详情" } }
],
"tabBar": {
"color": "#999999",
"selectedColor": "#2563eb",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"list": [
{ "pagePath": "pages/home/index", "text": "首页", "iconPath": "static/tabbar/home.png", "selectedIconPath": "static/tabbar/home-active.png" },
{ "pagePath": "pages/devices/index", "text": "设备", "iconPath": "static/tabbar/device.png", "selectedIconPath": "static/tabbar/device-active.png" },
{ "pagePath": "pages/me/index", "text": "我的", "iconPath": "static/tabbar/me.png", "selectedIconPath": "static/tabbar/me-active.png" }
]
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "安知充",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f5f5f5"
}
}

View File

@ -0,0 +1,308 @@
<template>
<view class="detail-page">
<view v-if="loading" class="loading">
<text>加载中...</text>
</view>
<view v-else-if="error" class="error-state">
<text class="error-text">{{ error }}</text>
<button class="retry-btn" @click="fetchDetail">重试</button>
</view>
<view v-else-if="data">
<!-- Real-time status -->
<view class="status-card">
<view class="status-header">
<text class="status-title">实时状态</text>
<text class="status-badge" :class="getStatusClass(data.status)">
{{ getStatusText(data.status) }}
</text>
</view>
<view class="stats-row">
<view class="stat-item">
<text class="stat-value">{{ data.soc ?? '-' }}%</text>
<text class="stat-label">SOC</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ data.soh ?? '-' }}%</text>
<text class="stat-label">SOH</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ data.energy?.toFixed(1) ?? '-' }}</text>
<text class="stat-label">能耗(kWh)</text>
</view>
</view>
</view>
<!-- Electrical params -->
<view class="params-card">
<text class="card-title">电气参数</text>
<view class="params-grid">
<view class="param-item">
<text class="param-label">电压</text>
<text class="param-value">{{ data.voltage?.toFixed(1) ?? '-' }} V</text>
</view>
<view class="param-item">
<text class="param-label">电流</text>
<text class="param-value">{{ data.current?.toFixed(1) ?? '-' }} A</text>
</view>
<view class="param-item">
<text class="param-label">功率</text>
<text class="param-value">{{ data.power?.toFixed(1) ?? '-' }} W</text>
</view>
<view class="param-item">
<text class="param-label">能耗</text>
<text class="param-value">{{ data.energy?.toFixed(3) ?? '-' }} kWh</text>
</view>
</view>
</view>
<!-- Charge records -->
<view v-if="data.charge_records && data.charge_records.length > 0" class="records-card">
<text class="card-title">充电记录</text>
<view v-for="record in data.charge_records.slice(0, 5)" :key="record.id" class="record-item">
<view>
<text class="record-time">{{ record.start_time.slice(0, 16) }}</text>
<text v-if="record.end_time" class="record-end">{{ record.end_time.slice(0, 16) }}</text>
</view>
<view class="record-right">
<text class="record-energy">{{ record.energy_kwh.toFixed(2) }} kWh</text>
<text class="record-cost">¥{{ record.cost.toFixed(2) }}</text>
</view>
</view>
</view>
<!-- Action buttons -->
<view class="actions">
<button v-if="data.status === 0" class="btn-action green" @click="handleAction('start')">
{{ actionLoading === 'start' ? '操作中...' : '开始充电' }}
</button>
<button v-if="data.status === 1" class="btn-action orange" @click="confirmStop">
{{ actionLoading === 'stop' ? '操作中...' : '停止充电' }}
</button>
<button class="btn-action outline" @click="confirmDoor">
{{ actionLoading === 'door' ? '操作中...' : '开门' }}
</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { h5Api, type CompartmentDetail } from '../../api'
const data = ref<CompartmentDetail | null>(null)
const loading = ref(true)
const error = ref('')
const actionLoading = ref('')
onLoad((query) => {
if (query?.id) {
fetchDetail(Number(query.id))
}
})
async function fetchDetail(id: number) {
loading.value = true
error.value = ''
try {
const res = await h5Api.getCompartmentDetail(id)
data.value = res.data
} catch {
error.value = '加载通道详情失败'
} finally {
loading.value = false
}
}
function getStatusClass(status: number) {
if (status === 1) return 'charging'
if (status === 2) return 'fault'
return 'idle'
}
function getStatusText(status: number) {
if (status === 0) return '空闲'
if (status === 1) return '充电中'
if (status === 2) return '故障'
return '离线'
}
function confirmStop() {
uni.showModal({
title: '停止充电',
content: '确定要停止当前充电?',
success: (res) => {
if (res.confirm) handleAction('stop')
}
})
}
function confirmDoor() {
uni.showModal({
title: '开门',
content: '确定要打开仓门?',
success: (res) => {
if (res.confirm) handleAction('door')
}
})
}
async function handleAction(action: 'start' | 'stop' | 'door') {
if (!data.value || actionLoading.value) return
actionLoading.value = action
try {
if (action === 'start') {
await h5Api.startCharge(data.value.id)
} else if (action === 'stop') {
await h5Api.stopCharge(data.value.id)
} else if (action === 'door') {
await h5Api.openDoor(data.value.id)
}
fetchDetail(data.value.id)
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
actionLoading.value = ''
}
}
</script>
<style scoped>
.detail-page {
padding: 24px;
}
.loading, .error-state {
padding: 80px 0;
text-align: center;
}
.error-text {
color: #dc2626;
font-size: 28px;
}
.retry-btn {
margin-top: 20px;
background-color: #2563eb;
color: #fff;
border-radius: 12px;
}
.status-card, .params-card, .records-card {
background-color: #fff;
border-radius: 16px;
padding: 24px;
margin-bottom: 24px;
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.status-title {
font-size: 28px;
font-weight: 600;
color: #111;
}
.status-badge {
font-size: 24px;
padding: 4px 12px;
border-radius: 8px;
}
.status-badge.idle { color: #6b7280; background-color: #f9fafb; }
.status-badge.charging { color: #2563eb; background-color: #eff6ff; }
.status-badge.fault { color: #dc2626; background-color: #fef2f2; }
.stats-row {
display: flex;
gap: 16px;
}
.stat-item {
flex: 1;
text-align: center;
}
.stat-value {
font-size: 36px;
font-weight: 700;
color: #2563eb;
display: block;
}
.stat-label {
font-size: 24px;
color: #6b7280;
}
.card-title {
font-size: 28px;
font-weight: 600;
color: #111;
margin-bottom: 16px;
display: block;
}
.params-grid {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.param-item {
width: calc(50% - 8px);
display: flex;
justify-content: space-between;
}
.param-label {
color: #6b7280;
font-size: 26px;
}
.param-value {
font-weight: 500;
color: #111;
font-size: 26px;
}
.record-item {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.record-item:last-child {
border-bottom: none;
}
.record-time {
font-size: 26px;
color: #111;
display: block;
}
.record-end {
font-size: 22px;
color: #9ca3af;
}
.record-right {
text-align: right;
}
.record-energy {
font-size: 26px;
color: #111;
display: block;
}
.record-cost {
font-size: 22px;
color: #9ca3af;
}
.actions {
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 32px;
}
.btn-action {
height: 88px;
border-radius: 12px;
font-size: 32px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
border: none;
}
.btn-action.green { background-color: #16a34a; color: #fff; }
.btn-action.orange { background-color: #f97316; color: #fff; }
.btn-action.outline { background-color: #fff; color: #374151; border: 1px solid #d1d5db; }
.btn-action[disabled] { opacity: 0.5; }
</style>

View File

@ -0,0 +1,195 @@
<template>
<view class="detail-page">
<view v-if="loading" class="loading">
<text>加载中...</text>
</view>
<view v-else-if="error" class="error-state">
<text class="error-text">{{ error }}</text>
<button class="retry-btn" @click="fetchDetail">重试</button>
</view>
<view v-else-if="data">
<!-- Basic info -->
<view class="info-card">
<view class="info-header">
<view>
<text class="info-name">{{ data.name || data.abstract_id }}</text>
<text class="info-id">ID: {{ data.abstract_id }}</text>
<text v-if="data.imei" class="info-imei">IMEI: {{ data.imei }}</text>
</view>
<text class="status-badge" :class="data.status === 1 ? 'online' : 'offline'">
{{ data.status === 1 ? '在线' : '离线' }}
</text>
</view>
</view>
<!-- Cabin boards -->
<view v-for="board in data.cabin_boards" :key="board.id" class="board-section">
<view class="board-header">
<text class="board-title">仓板 {{ board.board_id }}</text>
<text class="board-status" :class="board.status === 1 ? 'online' : 'offline'">
({{ board.status === 1 ? '在线' : '离线' }})
</text>
</view>
<view class="channels-grid">
<view
v-for="comp in board.compartments"
:key="comp.id"
class="channel-card"
:class="getStatusClass(comp.status)"
@click="goCompartment(comp.id)"
>
<text class="channel-id">通道 {{ comp.channel_id }}</text>
<text class="channel-status">{{ getStatusText(comp.status) }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { h5Api, type CabinetDetail } from '../../api'
const data = ref<CabinetDetail | null>(null)
const loading = ref(true)
const error = ref('')
onLoad((query) => {
if (query?.id) {
fetchDetail(Number(query.id))
}
})
async function fetchDetail(id: number) {
loading.value = true
error.value = ''
try {
const res = await h5Api.getCabinetDetail(id)
data.value = res.data
} catch {
error.value = '加载设备详情失败'
} finally {
loading.value = false
}
}
function getStatusClass(status: number) {
if (status === 1) return 'charging'
if (status === 2) return 'fault'
return 'idle'
}
function getStatusText(status: number) {
if (status === 0) return '空闲'
if (status === 1) return '充电中'
if (status === 2) return '故障'
return '离线'
}
function goCompartment(id: number) {
uni.navigateTo({ url: `/pages/compartment-detail/index?id=${id}` })
}
</script>
<style scoped>
.detail-page {
padding: 24px;
}
.loading, .error-state {
padding: 80px 0;
text-align: center;
}
.error-text {
color: #dc2626;
font-size: 28px;
}
.retry-btn {
margin-top: 20px;
background-color: #2563eb;
color: #fff;
border-radius: 12px;
font-size: 28px;
}
.info-card {
background-color: #fff;
border-radius: 16px;
padding: 24px;
margin-bottom: 24px;
}
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-name {
font-size: 32px;
font-weight: 600;
color: #111;
display: block;
}
.info-id {
font-size: 24px;
color: #6b7280;
margin-top: 4px;
display: block;
}
.info-imei {
font-size: 24px;
color: #9ca3af;
display: block;
}
.status-badge {
font-size: 24px;
padding: 4px 12px;
border-radius: 8px;
}
.status-badge.online { color: #16a34a; background-color: #f0fdf4; }
.status-badge.offline { color: #9ca3af; background-color: #f9fafb; }
.board-section {
margin-bottom: 24px;
}
.board-header {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.board-title {
font-size: 26px;
font-weight: 500;
color: #374151;
}
.board-status {
font-size: 24px;
margin-left: 12px;
}
.board-status.online { color: #16a34a; }
.board-status.offline { color: #9ca3af; }
.channels-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.channel-card {
width: calc(33.33% - 8px);
background-color: #fff;
border-radius: 12px;
padding: 16px;
text-align: center;
}
.channel-card.idle { border-left: 4px solid #6b7280; }
.channel-card.charging { border-left: 4px solid #2563eb; }
.channel-card.fault { border-left: 4px solid #dc2626; }
.channel-id {
font-size: 24px;
color: #374151;
display: block;
}
.channel-status {
font-size: 22px;
color: #6b7280;
margin-top: 4px;
display: block;
}
</style>

View File

@ -0,0 +1,105 @@
<template>
<view class="devices-page">
<!-- List -->
<view v-if="list.length === 0 && !loading" class="empty">
<text class="empty-text">暂无设备</text>
</view>
<view v-else class="device-list">
<view
v-for="item in list"
:key="item.id"
class="device-card"
@click="goDetail(item.id)"
>
<view class="device-header">
<text class="device-name">{{ item.name || item.abstract_id }}</text>
<text class="device-status" :class="item.status === 1 ? 'online' : 'offline'">
{{ item.status === 1 ? '在线' : '离线' }}
</text>
</view>
<text class="device-id">ID: {{ item.abstract_id }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { h5Api, type CabinetItem } from '../../api'
const list = ref<CabinetItem[]>([])
const loading = ref(true)
onShow(() => {
fetchList()
})
async function fetchList() {
loading.value = true
try {
const res = await h5Api.getCabinets()
list.value = res.data || []
} catch {
//
} finally {
loading.value = false
}
}
function goDetail(id: number) {
uni.navigateTo({ url: `/pages/device-detail/index?id=${id}` })
}
</script>
<style scoped>
.devices-page {
padding: 24px;
}
.empty {
padding: 80px 0;
text-align: center;
}
.empty-text {
font-size: 28px;
color: #9ca3af;
}
.device-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.device-card {
background-color: #fff;
border-radius: 16px;
padding: 24px;
}
.device-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.device-name {
font-size: 30px;
font-weight: 600;
color: #111;
}
.device-status {
font-size: 24px;
padding: 4px 12px;
border-radius: 8px;
}
.device-status.online {
color: #16a34a;
background-color: #f0fdf4;
}
.device-status.offline {
color: #9ca3af;
background-color: #f9fafb;
}
.device-id {
font-size: 24px;
color: #9ca3af;
}
</style>

View File

@ -0,0 +1,212 @@
<template>
<view class="home-page">
<!-- Welcome -->
<view class="welcome">
<text class="greeting">{{ user?.name ? `${user.name},你好` : '你好' }}</text>
<text class="desc">欢迎使用安知充充电柜</text>
</view>
<!-- Stats Grid -->
<view class="stats-grid">
<view class="stat-card">
<text class="stat-value blue">{{ data?.online_cabinets ?? 0 }}</text>
<text class="stat-label">在线设备</text>
</view>
<view class="stat-card">
<text class="stat-value green">{{ data?.charging_count ?? 0 }}</text>
<text class="stat-label">充电中</text>
</view>
<view class="stat-card">
<text class="stat-value yellow">{{ data?.idle_channels ?? 0 }}</text>
<text class="stat-label">空闲仓体</text>
</view>
<view class="stat-card">
<text class="stat-value" :class="data?.fault_channels ? 'red' : 'gray'">
{{ data?.fault_channels ?? 0 }}
</text>
<text class="stat-label">故障</text>
</view>
</view>
<!-- Today summary -->
<view class="card">
<text class="card-title">今日概况</text>
<view class="today-row">
<view class="today-item">
<text class="today-value">{{ data?.today_charge_count ?? 0 }}</text>
<text class="today-label">充电次数</text>
</view>
<view class="today-item">
<text class="today-value">{{ data?.today_energy_kwh?.toFixed(1) ?? '0.0' }}</text>
<text class="today-label">充电量(kWh)</text>
</view>
<view class="today-item">
<text class="today-value">{{ data?.today_charge_hours?.toFixed(1) ?? '0.0' }}</text>
<text class="today-label">时长(h)</text>
</view>
</view>
</view>
<!-- Fault alerts -->
<view v-if="data?.alerts && data.alerts.length > 0" class="card">
<text class="card-title red-text">故障告警</text>
<view v-for="(alert, i) in data.alerts.slice(0, 5)" :key="i" class="alert-item">
<text class="alert-text">{{ alert.cabinet }} 通道 {{ alert.channel }}</text>
<text class="alert-project">{{ alert.project }}</text>
</view>
</view>
<!-- Quick Actions -->
<view class="card">
<text class="card-title">快捷操作</text>
<view class="action-item" @click="goDevices">
<text class="action-text">查看设备</text>
</view>
<view class="action-item" @click="goMe">
<text class="action-text">个人中心</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { h5Api, type DashboardData } from '../../api'
import { useAuthStore } from '../../store/auth'
const authStore = useAuthStore()
const user = authStore.user
const data = ref<DashboardData | null>(null)
onShow(() => {
if (!authStore.isAuthenticated) {
uni.navigateTo({ url: '/pages/login/index' })
return
}
fetchDashboard()
})
async function fetchDashboard() {
try {
const res = await h5Api.getDashboard()
data.value = res.data
} catch {
//
}
}
function goDevices() {
uni.switchTab({ url: '/pages/devices/index' })
}
function goMe() {
uni.switchTab({ url: '/pages/me/index' })
}
</script>
<style scoped>
.home-page {
padding: 24px;
}
.welcome {
margin-bottom: 32px;
}
.greeting {
font-size: 36px;
font-weight: 600;
color: #111;
display: block;
}
.desc {
font-size: 26px;
color: #6b7280;
margin-top: 8px;
}
.stats-grid {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
width: calc(50% - 8px);
background-color: #fff;
border-radius: 16px;
padding: 24px;
}
.stat-value {
font-size: 40px;
font-weight: 700;
display: block;
}
.stat-value.blue { color: #2563eb; }
.stat-value.green { color: #16a34a; }
.stat-value.yellow { color: #f59e0b; }
.stat-value.red { color: #dc2626; }
.stat-value.gray { color: #6b7280; }
.stat-label {
font-size: 24px;
color: #6b7280;
margin-top: 4px;
}
.card {
background-color: #fff;
border-radius: 16px;
padding: 24px;
margin-bottom: 24px;
}
.card-title {
font-size: 28px;
font-weight: 600;
color: #111;
margin-bottom: 16px;
display: block;
}
.red-text { color: #dc2626; }
.today-row {
display: flex;
justify-content: space-between;
}
.today-item {
flex: 1;
text-align: center;
}
.today-value {
font-size: 32px;
font-weight: 700;
color: #111;
display: block;
}
.today-label {
font-size: 22px;
color: #6b7280;
}
.alert-item {
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.alert-item:last-child {
border-bottom: none;
}
.alert-text {
font-size: 26px;
color: #111;
display: block;
}
.alert-project {
font-size: 22px;
color: #9ca3af;
}
.action-item {
padding: 20px 0;
border-bottom: 1px solid #f3f4f6;
}
.action-item:last-child {
border-bottom: none;
}
.action-text {
font-size: 28px;
color: #374151;
}
</style>

View File

@ -0,0 +1,150 @@
<template>
<view class="login-page">
<view class="header">
<text class="title">安知充</text>
<text class="subtitle">智能充电柜管理系统</text>
</view>
<view class="form">
<view class="field">
<text class="label">手机号</text>
<input
class="input"
placeholder="请输入手机号"
type="number"
:maxlength="11"
v-model="phone"
/>
</view>
<view class="field">
<text class="label">密码</text>
<input
class="input"
placeholder="请输入密码"
type="password"
v-model="password"
/>
</view>
<text v-if="error" class="error">{{ error }}</text>
<button
class="btn-login"
:disabled="loading"
@click="handleLogin"
>
{{ loading ? '登录中...' : '登录' }}
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useAuthStore } from '../../store/auth'
import { h5Api } from '../../api'
const authStore = useAuthStore()
const phone = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
const abstractId = ref('')
onLoad((query) => {
if (query?.abstract_id) {
abstractId.value = query.abstract_id
}
})
async function handleLogin() {
if (!phone.value || !password.value) {
error.value = '请输入手机号和密码'
return
}
error.value = ''
loading.value = true
try {
await authStore.login(phone.value, password.value)
if (abstractId.value) {
try {
const res = await h5Api.getCabinetByAbstract(abstractId.value)
uni.navigateTo({ url: `/pages/device-detail/index?id=${res.data.id}` })
} catch {
uni.switchTab({ url: '/pages/home/index' })
}
} else {
uni.switchTab({ url: '/pages/home/index' })
}
} catch (e: any) {
error.value = e.message || '登录失败'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #fff;
}
.header {
padding: 80px 48px 40px;
}
.title {
font-size: 56px;
font-weight: 700;
color: #111;
display: block;
}
.subtitle {
font-size: 28px;
color: #6b7280;
margin-top: 12px;
}
.form {
padding: 0 48px;
}
.field {
margin-bottom: 32px;
}
.label {
font-size: 26px;
color: #374151;
margin-bottom: 8px;
display: block;
}
.input {
height: 88px;
border: 1px solid #d1d5db;
border-radius: 12px;
padding: 0 24px;
font-size: 28px;
}
.error {
color: #dc2626;
font-size: 26px;
margin-bottom: 16px;
display: block;
}
.btn-login {
height: 88px;
background-color: #2563eb;
color: #fff;
border-radius: 12px;
font-size: 32px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
border: none;
opacity: 1;
}
.btn-login[disabled] {
opacity: 0.6;
}
</style>

View File

@ -0,0 +1,126 @@
<template>
<view class="me-page">
<!-- User info -->
<view class="user-card">
<view class="avatar">
<text class="avatar-text">{{ (user?.name || user?.phone || '?').charAt(0) }}</text>
</view>
<view class="user-info">
<text class="user-name">{{ user?.name || '用户' }}</text>
<text class="user-phone">{{ user?.phone }}</text>
</view>
</view>
<!-- Menu items -->
<view class="menu-card">
<view class="menu-item" @click="showTip">
<text class="menu-text">修改密码</text>
<text class="menu-arrow"></text>
</view>
</view>
<!-- Logout -->
<view class="logout-wrap">
<button class="btn-logout" @click="confirmLogout">退出登录</button>
</view>
</view>
</template>
<script setup lang="ts">
import { useAuthStore } from '../../store/auth'
const authStore = useAuthStore()
const user = authStore.user
function showTip() {
uni.showToast({ title: '功能开发中', icon: 'none' })
}
function confirmLogout() {
uni.showModal({
title: '退出登录',
content: '确定要退出当前账号?',
success: (res) => {
if (res.confirm) {
authStore.logout()
uni.reLaunch({ url: '/pages/login/index' })
}
}
})
}
</script>
<style scoped>
.me-page {
padding: 24px;
}
.user-card {
background-color: #fff;
border-radius: 16px;
padding: 24px;
margin-bottom: 32px;
display: flex;
align-items: center;
gap: 16px;
}
.avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background-color: #dbeafe;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-text {
font-size: 28px;
font-weight: 700;
color: #2563eb;
}
.user-info {
flex: 1;
}
.user-name {
font-size: 28px;
font-weight: 600;
color: #111;
display: block;
}
.user-phone {
font-size: 24px;
color: #6b7280;
}
.menu-card {
background-color: #fff;
border-radius: 16px;
}
.menu-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
}
.menu-text {
font-size: 26px;
color: #111;
}
.menu-arrow {
font-size: 24px;
color: #9ca3af;
}
.logout-wrap {
margin-top: 32px;
}
.btn-logout {
height: 88px;
background-color: #fff;
color: #dc2626;
border-radius: 12px;
font-size: 28px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #fecaca;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { h5Api, type H5User } from '../api'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null)
const user = ref<H5User | null>(null)
const isAuthenticated = ref(false)
async function login(phone: string, password: string) {
const res = await h5Api.login({ phone, password })
const { token: t, user: u } = res.data
uni.setStorageSync('h5_token', t)
uni.setStorageSync('h5_user', JSON.stringify(u))
token.value = t
user.value = u
isAuthenticated.value = true
}
function logout() {
uni.removeStorageSync('h5_token')
uni.removeStorageSync('h5_user')
token.value = null
user.value = null
isAuthenticated.value = false
}
function restore() {
try {
const t = uni.getStorageSync('h5_token')
const raw = uni.getStorageSync('h5_user')
if (t) {
token.value = t
user.value = raw ? JSON.parse(raw) : null
isAuthenticated.value = true
}
} catch {
// storage 读取失败,忽略
}
}
return { token, user, isAuthenticated, login, logout, restore }
})

View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true,
"jsx": "preserve",
"moduleResolution": "node",
"esModuleInterop": true,
"sourceMap": true,
"skipLibCheck": true,
"lib": ["esnext", "dom"],
"types": ["@dcloudio/types"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
export default defineConfig({
plugins: [uni()],
base: '/h5/',
build: {
outDir: 'dist/build/h5',
assetsDir: 'static'
}
})