@roki-h5/create-roki-app 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/h5/package.json +2 -1
- package/templates/h5/public/favicon.ico +0 -0
- package/templates/h5/public/favicon.png +0 -0
- package/templates/h5/src/App.vue +5 -0
- package/templates/h5/src/api/deviceInfo.ts +46 -0
- package/templates/h5/src/assets/images/home/icon-more.png +0 -0
- package/templates/h5/src/assets/images/home/icon-power.png +0 -0
- package/templates/h5/src/assets/images/logo.png +0 -0
- package/templates/h5/src/constants/device.ts +83 -0
- package/templates/h5/src/stores/app.ts +30 -0
- package/templates/h5/src/stores/index.ts +1 -0
- package/templates/h5/src/stores/machineState.ts +39 -0
- package/templates/h5/src/utils/buildInfo.ts +2 -2
- package/templates/h5/src/utils/deviceParams.ts +40 -0
- package/templates/h5/src/utils/deviceShadow.ts +69 -0
- package/templates/h5/src/utils/random.ts +8 -0
- package/templates/h5/src/utils/sse/index.ts +292 -0
- package/templates/h5/src/utils/useDeviceShadow.ts +29 -0
- package/templates/h5/src/utils/useGlobalSse.ts +198 -0
- package/templates/h5/src/views/home/index.vue +144 -20
- package/templates/h5/src/vite-env.d.ts +6 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roki-h5/scaffold-template",
|
|
3
3
|
"private": true,
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "vite --mode development",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@roki-h5/ui": "workspace:*",
|
|
16
16
|
"axios": "^1.7.9",
|
|
17
|
+
"event-source-polyfill": "^1.0.31",
|
|
17
18
|
"pinia": "^2.3.0",
|
|
18
19
|
"vue": "^3.5.13",
|
|
19
20
|
"vue-router": "^4.5.0"
|
|
Binary file
|
|
Binary file
|
package/templates/h5/src/App.vue
CHANGED
|
@@ -3,10 +3,15 @@ import { onBeforeMount, onMounted } from 'vue'
|
|
|
3
3
|
import { useAppStore } from '@/stores'
|
|
4
4
|
import { getAppData } from '@/utils/appInfo'
|
|
5
5
|
import { BUILD_TIME, VERSION } from '@/utils/buildInfo'
|
|
6
|
+
import { useDeviceShadow } from '@/utils/useDeviceShadow'
|
|
7
|
+
import { useGlobalSse } from '@/utils/useGlobalSse'
|
|
6
8
|
|
|
7
9
|
const appStore = useAppStore()
|
|
8
10
|
const appTitle = import.meta.env.VITE_APP_TITLE || 'Roki H5'
|
|
9
11
|
|
|
12
|
+
useDeviceShadow()
|
|
13
|
+
useGlobalSse()
|
|
14
|
+
|
|
10
15
|
onBeforeMount(async () => {
|
|
11
16
|
await getAppData()
|
|
12
17
|
void appStore.loadWelcomeMessage()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { devReq } from '@/api/request'
|
|
2
|
+
|
|
3
|
+
export interface DeviceShadowItem {
|
|
4
|
+
properties?: Record<string, unknown>
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface DeviceShadowResponse {
|
|
8
|
+
data?: DeviceShadowItem[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DeviceStatusItem {
|
|
12
|
+
status?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DeviceStatusResponse {
|
|
16
|
+
data?: DeviceStatusItem[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getDeviceStatus(deviceIds: string[]) {
|
|
20
|
+
return devReq<DeviceStatusResponse>({
|
|
21
|
+
url: '/rest/iot/api/device/info/state',
|
|
22
|
+
method: 'post',
|
|
23
|
+
data: deviceIds,
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getDeviceAllProperty(params: { deviceIds: string }) {
|
|
28
|
+
return devReq<DeviceShadowResponse>({
|
|
29
|
+
url: '/rest/iot/api/device/property/shadow',
|
|
30
|
+
method: 'get',
|
|
31
|
+
params,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function setDeviceFunctionProperty(data: Record<string, unknown>) {
|
|
36
|
+
return devReq({
|
|
37
|
+
url: '/rest/iot/api/device/function/invoke',
|
|
38
|
+
method: 'post',
|
|
39
|
+
data,
|
|
40
|
+
}).catch((err) => {
|
|
41
|
+
if (import.meta.env.DEV) {
|
|
42
|
+
console.warn('[setDeviceFunctionProperty] devReq rejected', err)
|
|
43
|
+
}
|
|
44
|
+
return null
|
|
45
|
+
})
|
|
46
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** shadow / SSE 中开关机字段名 */
|
|
2
|
+
export const DEVICE_PROPERTY_KEY = {
|
|
3
|
+
POW_STATE: 'powState',
|
|
4
|
+
} as const
|
|
5
|
+
|
|
6
|
+
/** 设备在线状态(info/state 接口 data[0].status) */
|
|
7
|
+
export const DEVICE_ONLINE_STATUS = {
|
|
8
|
+
OFFLINE: 0,
|
|
9
|
+
ONLINE: 1,
|
|
10
|
+
} as const
|
|
11
|
+
|
|
12
|
+
/** 设备开关机状态 powState(调料机等) */
|
|
13
|
+
export const DEVICE_POW_STATE = {
|
|
14
|
+
OFF: '0',
|
|
15
|
+
ON: '1',
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 热水器 shadow powerState(与 SSE 一致)
|
|
20
|
+
* 0 关机 / 1 就绪 / 2 工作中
|
|
21
|
+
*/
|
|
22
|
+
export const DEVICE_POWER_STATE = {
|
|
23
|
+
OFF: 0,
|
|
24
|
+
READY: 1,
|
|
25
|
+
WORKING: 2,
|
|
26
|
+
} as const
|
|
27
|
+
|
|
28
|
+
export const DEVICE_POWER_FUNCTION = {
|
|
29
|
+
POWER_CONTROL: 'powerControl',
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
export const DEVICE_TOAST = {
|
|
33
|
+
OFFLINE: '设备已离线',
|
|
34
|
+
POWER_OFF: '热水器已关机',
|
|
35
|
+
POWER_ON_SUCCESS: '热水器已准备就绪',
|
|
36
|
+
POWER_OFF_SUCCESS: '热水器已关机',
|
|
37
|
+
} as const
|
|
38
|
+
|
|
39
|
+
export function parseDevicePowerState(raw: unknown) {
|
|
40
|
+
if (raw === undefined || raw === null || raw === '') return null
|
|
41
|
+
const n = Number(raw)
|
|
42
|
+
return Number.isNaN(n) ? null : n
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isDevicePowerOff(powerState: number | null) {
|
|
46
|
+
return powerState === DEVICE_POWER_STATE.OFF
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolvePowState(source: Record<string, unknown> | null | undefined) {
|
|
50
|
+
if (source == null || typeof source !== 'object') {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const value = source[DEVICE_PROPERTY_KEY.POW_STATE] ?? source.powerState
|
|
55
|
+
|
|
56
|
+
if (value == null || value === '') {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return String(value)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function normalizePowState(value: unknown) {
|
|
64
|
+
if (value == null || value === '') {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return String(value)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isDeviceOnline(status: unknown) {
|
|
72
|
+
return Number(status) === DEVICE_ONLINE_STATUS.ONLINE
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isDevicePowerOn(powState: unknown) {
|
|
76
|
+
const normalized = normalizePowState(powState)
|
|
77
|
+
|
|
78
|
+
if (normalized != null) {
|
|
79
|
+
return normalized === DEVICE_POW_STATE.ON
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return Number(powState) === 1
|
|
83
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ref } from 'vue'
|
|
2
2
|
import { defineStore } from 'pinia'
|
|
3
3
|
import { getWelcomeMessage, type WelcomeMessageData } from '@/api/welcome'
|
|
4
|
+
import { SseService } from '@/utils/sse'
|
|
4
5
|
|
|
5
6
|
function formatRequestDate(date = new Date()) {
|
|
6
7
|
const pad = (n: number) => String(n).padStart(2, '0')
|
|
@@ -18,6 +19,9 @@ export interface AppStateUpdates {
|
|
|
18
19
|
deviceId?: string
|
|
19
20
|
deviceGuid?: string
|
|
20
21
|
userId?: string
|
|
22
|
+
machineOpenStatus?: boolean | null
|
|
23
|
+
machinePowerStateReady?: boolean
|
|
24
|
+
machineOnlineStatus?: boolean
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export const useAppStore = defineStore('app', () => {
|
|
@@ -40,6 +44,15 @@ export const useAppStore = defineStore('app', () => {
|
|
|
40
44
|
/** `/rest/ops/ai/welcome` 的 data:prefix / speech */
|
|
41
45
|
const welcomeCopy = ref<WelcomeMessageData>({})
|
|
42
46
|
|
|
47
|
+
/** 设备是否开机(null 表示尚未从 shadow/SSE 同步) */
|
|
48
|
+
const machineOpenStatus = ref<boolean | null>(null)
|
|
49
|
+
/** 开关机状态是否已从 shadow/SSE 同步过 */
|
|
50
|
+
const machinePowerStateReady = ref(false)
|
|
51
|
+
/** 设备是否在线 */
|
|
52
|
+
const machineOnlineStatus = ref(true)
|
|
53
|
+
/** SSE 客户端 */
|
|
54
|
+
const sseClient = ref<SseService | null>(null)
|
|
55
|
+
|
|
43
56
|
function setAppName(name: string) {
|
|
44
57
|
appName.value = name
|
|
45
58
|
}
|
|
@@ -48,6 +61,15 @@ export const useAppStore = defineStore('app', () => {
|
|
|
48
61
|
accessToken.value = token
|
|
49
62
|
}
|
|
50
63
|
|
|
64
|
+
function initSSEClient(url: string) {
|
|
65
|
+
if (sseClient.value) {
|
|
66
|
+
console.log('[SSE] 已存在连接,不重复创建')
|
|
67
|
+
return sseClient.value
|
|
68
|
+
}
|
|
69
|
+
sseClient.value = new SseService(url)
|
|
70
|
+
return sseClient.value
|
|
71
|
+
}
|
|
72
|
+
|
|
51
73
|
function resetWelcomeCopy() {
|
|
52
74
|
welcomeCopy.value = {}
|
|
53
75
|
}
|
|
@@ -78,6 +100,9 @@ export const useAppStore = defineStore('app', () => {
|
|
|
78
100
|
deviceId,
|
|
79
101
|
deviceGuid,
|
|
80
102
|
userId,
|
|
103
|
+
machineOpenStatus,
|
|
104
|
+
machinePowerStateReady,
|
|
105
|
+
machineOnlineStatus,
|
|
81
106
|
} as const
|
|
82
107
|
|
|
83
108
|
Object.entries(updates).forEach(([key, value]) => {
|
|
@@ -101,8 +126,13 @@ export const useAppStore = defineStore('app', () => {
|
|
|
101
126
|
userId,
|
|
102
127
|
accessToken,
|
|
103
128
|
welcomeCopy,
|
|
129
|
+
machineOpenStatus,
|
|
130
|
+
machinePowerStateReady,
|
|
131
|
+
machineOnlineStatus,
|
|
132
|
+
sseClient,
|
|
104
133
|
setAppName,
|
|
105
134
|
setAccessToken,
|
|
135
|
+
initSSEClient,
|
|
106
136
|
resetWelcomeCopy,
|
|
107
137
|
loadWelcomeMessage,
|
|
108
138
|
updateState,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import { defineStore } from 'pinia'
|
|
3
|
+
|
|
4
|
+
export interface MachineDeviceData {
|
|
5
|
+
data?: Record<string, unknown>
|
|
6
|
+
messageType?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const useMachineStateStore = defineStore('machineState', () => {
|
|
10
|
+
const deviceData = ref<MachineDeviceData>({})
|
|
11
|
+
const deviceShadowReady = ref(false)
|
|
12
|
+
|
|
13
|
+
async function updateMachineState(newState: MachineDeviceData) {
|
|
14
|
+
deviceData.value = {
|
|
15
|
+
...deviceData.value,
|
|
16
|
+
data: {
|
|
17
|
+
...deviceData.value.data,
|
|
18
|
+
...newState.data,
|
|
19
|
+
},
|
|
20
|
+
messageType: newState.messageType,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function markDeviceShadowReady() {
|
|
25
|
+
deviceShadowReady.value = true
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resetDeviceShadowReady() {
|
|
29
|
+
deviceShadowReady.value = false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
deviceData,
|
|
34
|
+
deviceShadowReady,
|
|
35
|
+
updateMachineState,
|
|
36
|
+
markDeviceShadowReady,
|
|
37
|
+
resetDeviceShadowReady,
|
|
38
|
+
}
|
|
39
|
+
})
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// 此文件由构建脚本自动生成,请勿手动修改
|
|
2
|
-
export const BUILD_TIME = '
|
|
3
|
-
export const VERSION = '0.
|
|
2
|
+
export const BUILD_TIME = '2026/6/26 17:40:17'
|
|
3
|
+
export const VERSION = '0.1.1'
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useAppStore } from '@/stores/app'
|
|
2
|
+
|
|
3
|
+
interface DeviceParamsOverrides {
|
|
4
|
+
uid?: string
|
|
5
|
+
deviceId?: string
|
|
6
|
+
function?: string
|
|
7
|
+
params?: Record<string, unknown>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** 设备功能下发 invoke 请求体 */
|
|
11
|
+
export class DeviceParams {
|
|
12
|
+
messageId: number
|
|
13
|
+
uid: string
|
|
14
|
+
deviceId: string
|
|
15
|
+
params: Record<string, unknown>
|
|
16
|
+
function: string
|
|
17
|
+
|
|
18
|
+
constructor(overrides: DeviceParamsOverrides = {}) {
|
|
19
|
+
const appStore = useAppStore()
|
|
20
|
+
this.messageId = Date.now()
|
|
21
|
+
this.uid = overrides.uid ?? appStore.userId
|
|
22
|
+
this.deviceId = overrides.deviceId ?? appStore.deviceId
|
|
23
|
+
this.params = { powerCtrl: 1, ...(overrides.params ?? {}) }
|
|
24
|
+
this.function = overrides.function ?? ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static create(overrides: DeviceParamsOverrides = {}) {
|
|
28
|
+
return new DeviceParams(overrides)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
toPayload() {
|
|
32
|
+
return {
|
|
33
|
+
messageId: this.messageId,
|
|
34
|
+
uid: this.uid,
|
|
35
|
+
deviceId: this.deviceId,
|
|
36
|
+
function: this.function,
|
|
37
|
+
params: this.params,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { getDeviceAllProperty } from '@/api/deviceInfo'
|
|
2
|
+
import { isDevicePowerOn, resolvePowState } from '@/constants/device'
|
|
3
|
+
import { useAppStore } from '@/stores/app'
|
|
4
|
+
import { useMachineStateStore } from '@/stores/machineState'
|
|
5
|
+
|
|
6
|
+
let cachedDeviceId = ''
|
|
7
|
+
let inflightPromise: Promise<boolean> | null = null
|
|
8
|
+
|
|
9
|
+
export function applyDeviceShadowProperties(properties?: Record<string, unknown>) {
|
|
10
|
+
if (!properties || typeof properties !== 'object') {
|
|
11
|
+
return false
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const appStore = useAppStore()
|
|
15
|
+
const machineStateStore = useMachineStateStore()
|
|
16
|
+
const powState = resolvePowState(properties)
|
|
17
|
+
|
|
18
|
+
appStore.updateState({
|
|
19
|
+
machinePowerStateReady: true,
|
|
20
|
+
...(powState != null ? { machineOpenStatus: isDevicePowerOn(powState) } : {}),
|
|
21
|
+
})
|
|
22
|
+
machineStateStore.updateMachineState({ data: properties })
|
|
23
|
+
machineStateStore.markDeviceShadowReady()
|
|
24
|
+
|
|
25
|
+
return true
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resetDeviceShadowCache() {
|
|
29
|
+
cachedDeviceId = ''
|
|
30
|
+
inflightPromise = null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function fetchAndStoreDeviceShadow(deviceId: string, { force = false } = {}) {
|
|
34
|
+
if (!deviceId) {
|
|
35
|
+
return Promise.resolve(false)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const machineStateStore = useMachineStateStore()
|
|
39
|
+
|
|
40
|
+
if (!force && cachedDeviceId === deviceId && machineStateStore.deviceShadowReady) {
|
|
41
|
+
return Promise.resolve(true)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!force && inflightPromise && cachedDeviceId === deviceId) {
|
|
45
|
+
return inflightPromise
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
cachedDeviceId = deviceId
|
|
49
|
+
inflightPromise = getDeviceAllProperty({ deviceIds: deviceId })
|
|
50
|
+
.then((res) => {
|
|
51
|
+
const success = applyDeviceShadowProperties(res?.data?.[0]?.properties)
|
|
52
|
+
|
|
53
|
+
if (!success) {
|
|
54
|
+
cachedDeviceId = ''
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return success
|
|
58
|
+
})
|
|
59
|
+
.catch((error) => {
|
|
60
|
+
console.error('[DeviceShadow] 获取 shadow 失败:', error)
|
|
61
|
+
cachedDeviceId = ''
|
|
62
|
+
return false
|
|
63
|
+
})
|
|
64
|
+
.finally(() => {
|
|
65
|
+
inflightPromise = null
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return inflightPromise
|
|
69
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { useAppStore } from '@/stores/app'
|
|
2
|
+
import { EventSourcePolyfill } from 'event-source-polyfill'
|
|
3
|
+
|
|
4
|
+
export type SseListener = (...args: unknown[]) => void
|
|
5
|
+
|
|
6
|
+
interface SseServiceOptions {
|
|
7
|
+
autoReconnect?: boolean
|
|
8
|
+
maxReconnectAttempts?: number
|
|
9
|
+
reconnectDelay?: number
|
|
10
|
+
maxReconnectDelay?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class SseService {
|
|
14
|
+
private eventSource: EventSourcePolyfill | null = null
|
|
15
|
+
private listeners: Record<string, SseListener[]> = {}
|
|
16
|
+
private isConnected = false
|
|
17
|
+
private autoReconnect: boolean
|
|
18
|
+
private accessToken: string
|
|
19
|
+
private maxReconnectAttempts: number
|
|
20
|
+
private reconnectDelay: number
|
|
21
|
+
private maxReconnectDelay: number
|
|
22
|
+
private reconnectAttempts = 0
|
|
23
|
+
private reconnectTimer: number | null = null
|
|
24
|
+
private missedPingCount = 0
|
|
25
|
+
private readonly maxMissedPings = 3
|
|
26
|
+
private lastPingTime = Date.now()
|
|
27
|
+
private messageCheckTimer: number | null = null
|
|
28
|
+
private lastMessageTime = Date.now()
|
|
29
|
+
private isPingReconnecting = false
|
|
30
|
+
private handleVisibilityChange: (() => void) | null = null
|
|
31
|
+
private handleNetworkOnline: (() => void) | null = null
|
|
32
|
+
private handleNetworkOffline: (() => void) | null = null
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly url: string,
|
|
36
|
+
options: SseServiceOptions = {},
|
|
37
|
+
) {
|
|
38
|
+
this.autoReconnect = options.autoReconnect ?? true
|
|
39
|
+
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5
|
|
40
|
+
this.reconnectDelay = options.reconnectDelay ?? 2000
|
|
41
|
+
this.maxReconnectDelay = options.maxReconnectDelay ?? 30000
|
|
42
|
+
this.accessToken = useAppStore().accessToken
|
|
43
|
+
this.connect()
|
|
44
|
+
this.setupEventListeners()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private setupEventListeners() {
|
|
48
|
+
this.handleVisibilityChange = this.onVisibilityChange.bind(this)
|
|
49
|
+
this.handleNetworkOnline = this.onNetworkOnline.bind(this)
|
|
50
|
+
this.handleNetworkOffline = this.onNetworkOffline.bind(this)
|
|
51
|
+
document.addEventListener('visibilitychange', this.handleVisibilityChange)
|
|
52
|
+
window.addEventListener('online', this.handleNetworkOnline)
|
|
53
|
+
window.addEventListener('offline', this.handleNetworkOffline)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private removeEventListeners() {
|
|
57
|
+
if (this.handleVisibilityChange) {
|
|
58
|
+
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
|
|
59
|
+
}
|
|
60
|
+
if (this.handleNetworkOnline) {
|
|
61
|
+
window.removeEventListener('online', this.handleNetworkOnline)
|
|
62
|
+
}
|
|
63
|
+
if (this.handleNetworkOffline) {
|
|
64
|
+
window.removeEventListener('offline', this.handleNetworkOffline)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private onVisibilityChange() {
|
|
69
|
+
if (document.visibilityState === 'visible' && !this.isConnected && this.autoReconnect) {
|
|
70
|
+
console.log('[SSE] 页面可见,自动重新连接')
|
|
71
|
+
this.resetReconnectAttempts()
|
|
72
|
+
this.connect()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private onNetworkOnline() {
|
|
77
|
+
if (!this.isConnected && this.autoReconnect) {
|
|
78
|
+
console.log('[SSE] 网络恢复,自动重新连接')
|
|
79
|
+
this.resetReconnectAttempts()
|
|
80
|
+
this.connect()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private onNetworkOffline() {
|
|
85
|
+
console.log('[SSE] 网络断开,连接可能受影响')
|
|
86
|
+
this.handleConnectionLoss()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private resetReconnectAttempts() {
|
|
90
|
+
this.reconnectAttempts = 0
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private startMessageCheck() {
|
|
94
|
+
if (this.messageCheckTimer) {
|
|
95
|
+
window.clearInterval(this.messageCheckTimer)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.lastMessageTime = Date.now()
|
|
99
|
+
this.messageCheckTimer = window.setInterval(() => {
|
|
100
|
+
const timeSinceLastMessage = Date.now() - this.lastMessageTime
|
|
101
|
+
|
|
102
|
+
if (timeSinceLastMessage > 30000 && !this.isPingReconnecting) {
|
|
103
|
+
console.log('[SSE] 30秒未收到任何消息,网络可能存在问题')
|
|
104
|
+
this.handleConnectionLoss()
|
|
105
|
+
|
|
106
|
+
if (this.messageCheckTimer) {
|
|
107
|
+
window.clearInterval(this.messageCheckTimer)
|
|
108
|
+
this.messageCheckTimer = null
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}, 5000)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private handleMessage(event: MessageEvent) {
|
|
115
|
+
this.lastMessageTime = Date.now()
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const data = JSON.parse(event.data) as { messageType?: string }
|
|
119
|
+
|
|
120
|
+
if (data.messageType === 'PING' || data.messageType) {
|
|
121
|
+
this.lastPingTime = Date.now()
|
|
122
|
+
this.missedPingCount = 0
|
|
123
|
+
this.isPingReconnecting = false
|
|
124
|
+
} else {
|
|
125
|
+
const timeSinceLastPing = Date.now() - this.lastPingTime
|
|
126
|
+
|
|
127
|
+
if (timeSinceLastPing > 15000) {
|
|
128
|
+
this.missedPingCount += 1
|
|
129
|
+
console.log(`[SSE] 未收到 ping 事件 ${this.missedPingCount}/${this.maxMissedPings} 次`)
|
|
130
|
+
|
|
131
|
+
if (this.missedPingCount >= this.maxMissedPings) {
|
|
132
|
+
this.isPingReconnecting = true
|
|
133
|
+
this.handleConnectionLoss()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error('[SSE] 消息解析错误:', error)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
this.emit('message', event)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
connect() {
|
|
145
|
+
if (this.eventSource) {
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
this.eventSource = new EventSourcePolyfill(this.url, {
|
|
151
|
+
headers: {
|
|
152
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
153
|
+
'app-id': 'roki_app_h5',
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
this.eventSource.onopen = () => {
|
|
158
|
+
console.log('[SSE] 连接已建立')
|
|
159
|
+
this.isConnected = true
|
|
160
|
+
this.resetReconnectAttempts()
|
|
161
|
+
this.lastPingTime = Date.now()
|
|
162
|
+
this.lastMessageTime = Date.now()
|
|
163
|
+
this.missedPingCount = 0
|
|
164
|
+
this.isPingReconnecting = false
|
|
165
|
+
this.startMessageCheck()
|
|
166
|
+
this.emit('open')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this.eventSource.onerror = (error) => {
|
|
170
|
+
this.isConnected = false
|
|
171
|
+
console.log('[SSE] 连接错误:', error)
|
|
172
|
+
|
|
173
|
+
if (this.eventSource?.readyState === EventSource.CLOSED) {
|
|
174
|
+
console.log('[SSE] 连接已关闭')
|
|
175
|
+
this.emit('close')
|
|
176
|
+
this.handleConnectionLoss()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
this.emit('error', error)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.eventSource.onmessage = this.handleMessage.bind(this)
|
|
183
|
+
} catch (error) {
|
|
184
|
+
console.log('[SSE] 连接创建失败:', error)
|
|
185
|
+
this.emit('error', error)
|
|
186
|
+
this.handleConnectionLoss()
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private handleReconnect() {
|
|
191
|
+
this.disconnect()
|
|
192
|
+
|
|
193
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
194
|
+
console.log(`[SSE] 自动重连失败,已达到最大重连次数 ${this.maxReconnectAttempts}`)
|
|
195
|
+
this.emit('maxReconnectReached')
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const delay =
|
|
200
|
+
this.reconnectAttempts === 0
|
|
201
|
+
? 0
|
|
202
|
+
: Math.min(
|
|
203
|
+
this.reconnectDelay * 1.5 ** (this.reconnectAttempts - 1),
|
|
204
|
+
this.maxReconnectDelay,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
this.reconnectAttempts += 1
|
|
208
|
+
console.log(`[SSE] 将在 ${delay}ms 后进行第 ${this.reconnectAttempts} 次自动重连`)
|
|
209
|
+
this.emit('reconnecting', { attempt: this.reconnectAttempts, delay })
|
|
210
|
+
|
|
211
|
+
this.reconnectTimer = window.setTimeout(() => {
|
|
212
|
+
this.connect()
|
|
213
|
+
}, delay)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
emit(event: string, data?: unknown) {
|
|
217
|
+
const eventListeners = this.listeners[event]
|
|
218
|
+
if (!eventListeners?.length) {
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
eventListeners.forEach((listener) => {
|
|
223
|
+
try {
|
|
224
|
+
listener(data)
|
|
225
|
+
} catch (error) {
|
|
226
|
+
console.error(`[SSE] 事件监听器执行错误 (${event}):`, error)
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
disconnect() {
|
|
232
|
+
this.isConnected = false
|
|
233
|
+
|
|
234
|
+
if (this.reconnectTimer) {
|
|
235
|
+
clearTimeout(this.reconnectTimer)
|
|
236
|
+
this.reconnectTimer = null
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (this.messageCheckTimer) {
|
|
240
|
+
clearInterval(this.messageCheckTimer)
|
|
241
|
+
this.messageCheckTimer = null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (this.eventSource) {
|
|
245
|
+
this.eventSource.close()
|
|
246
|
+
this.eventSource = null
|
|
247
|
+
this.emit('close')
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
on(event: string, callback: SseListener) {
|
|
252
|
+
if (!this.listeners[event]) {
|
|
253
|
+
this.listeners[event] = []
|
|
254
|
+
}
|
|
255
|
+
this.listeners[event].push(callback)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
off(event: string, callback: SseListener) {
|
|
259
|
+
if (!this.listeners[event]) {
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
removeAllListeners(event?: string) {
|
|
266
|
+
if (event) {
|
|
267
|
+
this.listeners[event] = []
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
this.listeners = {}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
get connected() {
|
|
274
|
+
return this.isConnected
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
destroy() {
|
|
278
|
+
this.autoReconnect = false
|
|
279
|
+
this.disconnect()
|
|
280
|
+
this.removeEventListeners()
|
|
281
|
+
this.removeAllListeners()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private handleConnectionLoss() {
|
|
285
|
+
this.isConnected = false
|
|
286
|
+
if (this.autoReconnect) {
|
|
287
|
+
this.handleReconnect()
|
|
288
|
+
} else {
|
|
289
|
+
this.disconnect()
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { watch } from 'vue'
|
|
2
|
+
import { storeToRefs } from 'pinia'
|
|
3
|
+
import { useAppStore } from '@/stores/app'
|
|
4
|
+
import { useMachineStateStore } from '@/stores/machineState'
|
|
5
|
+
import { fetchAndStoreDeviceShadow, resetDeviceShadowCache } from '@/utils/deviceShadow'
|
|
6
|
+
|
|
7
|
+
/** 应用启动时拉取一次 shadow,子页面直接读 store */
|
|
8
|
+
export function useDeviceShadow() {
|
|
9
|
+
const appStore = useAppStore()
|
|
10
|
+
const machineStateStore = useMachineStateStore()
|
|
11
|
+
const { deviceId } = storeToRefs(appStore)
|
|
12
|
+
|
|
13
|
+
watch(
|
|
14
|
+
deviceId,
|
|
15
|
+
(newId, oldId) => {
|
|
16
|
+
if (newId !== oldId) {
|
|
17
|
+
resetDeviceShadowCache()
|
|
18
|
+
machineStateStore.resetDeviceShadowReady()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!newId) {
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
void fetchAndStoreDeviceShadow(newId)
|
|
26
|
+
},
|
|
27
|
+
{ immediate: true },
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { nextTick, watch } from 'vue'
|
|
2
|
+
import { storeToRefs } from 'pinia'
|
|
3
|
+
import { useRouter } from 'vue-router'
|
|
4
|
+
import { getDeviceStatus } from '@/api/deviceInfo'
|
|
5
|
+
import { isDeviceOnline, isDevicePowerOn, resolvePowState } from '@/constants/device'
|
|
6
|
+
import { normalDeviceApiBase } from '@config/env'
|
|
7
|
+
import { useAppStore } from '@/stores/app'
|
|
8
|
+
import { useMachineStateStore } from '@/stores/machineState'
|
|
9
|
+
import { randomNum } from '@/utils/random'
|
|
10
|
+
import type { SseListener } from '@/utils/sse'
|
|
11
|
+
|
|
12
|
+
const OFFLINE_STATUS_REFRESH_MS = 3000
|
|
13
|
+
|
|
14
|
+
function throttle<T extends (...args: never[]) => void>(fn: T, wait: number) {
|
|
15
|
+
let last = 0
|
|
16
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
17
|
+
|
|
18
|
+
return (...args: Parameters<T>) => {
|
|
19
|
+
const now = Date.now()
|
|
20
|
+
const remaining = wait - (now - last)
|
|
21
|
+
|
|
22
|
+
if (remaining <= 0) {
|
|
23
|
+
if (timer) {
|
|
24
|
+
clearTimeout(timer)
|
|
25
|
+
timer = null
|
|
26
|
+
}
|
|
27
|
+
last = now
|
|
28
|
+
fn(...args)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!timer) {
|
|
33
|
+
timer = setTimeout(() => {
|
|
34
|
+
last = Date.now()
|
|
35
|
+
timer = null
|
|
36
|
+
fn(...args)
|
|
37
|
+
}, remaining)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 应用级 SSE:建立长连接并同步设备在线/开关机/工作状态。
|
|
44
|
+
* shadow 全量属性仅在 App 启动时拉取一次;此处通过 SSE 增量更新 store。
|
|
45
|
+
*/
|
|
46
|
+
export function useGlobalSse() {
|
|
47
|
+
const router = useRouter()
|
|
48
|
+
const appStore = useAppStore()
|
|
49
|
+
const machineStateStore = useMachineStateStore()
|
|
50
|
+
const { updateMachineState } = machineStateStore
|
|
51
|
+
const { deviceId, userId, sseClient, machineOnlineStatus } = storeToRefs(appStore)
|
|
52
|
+
|
|
53
|
+
let connectedForDeviceId = ''
|
|
54
|
+
|
|
55
|
+
const refreshDeviceOnlineStatus = throttle(() => {
|
|
56
|
+
if (!deviceId.value) {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getDeviceStatus([deviceId.value])
|
|
61
|
+
.then((statusRes) => {
|
|
62
|
+
appStore.updateState({
|
|
63
|
+
machineOnlineStatus: isDeviceOnline(statusRes.data?.[0]?.status),
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
.catch((error) => {
|
|
67
|
+
console.error('[SSE] 刷新设备在线状态失败:', error)
|
|
68
|
+
})
|
|
69
|
+
}, OFFLINE_STATUS_REFRESH_MS)
|
|
70
|
+
|
|
71
|
+
const onGlobalSseMessage: SseListener = (data) => {
|
|
72
|
+
const event = data as MessageEvent
|
|
73
|
+
let res: {
|
|
74
|
+
messageType?: string
|
|
75
|
+
data?: Record<string, unknown>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
res = JSON.parse(event.data || '{}')
|
|
80
|
+
} catch {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!res?.data) {
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log('🌹🌹 【 SSE 】🌹🌹', res)
|
|
89
|
+
|
|
90
|
+
const powState = resolvePowState(res.data)
|
|
91
|
+
|
|
92
|
+
if (res.messageType === 'OFFLINE') {
|
|
93
|
+
appStore.updateState({
|
|
94
|
+
machineOnlineStatus: false,
|
|
95
|
+
})
|
|
96
|
+
void nextTick(() => {
|
|
97
|
+
void router.replace('/')
|
|
98
|
+
})
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (res.messageType === 'ONLINE') {
|
|
103
|
+
appStore.updateState({
|
|
104
|
+
machineOnlineStatus: true,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!machineOnlineStatus.value) {
|
|
109
|
+
refreshDeviceOnlineStatus()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (powState != null) {
|
|
113
|
+
appStore.updateState({
|
|
114
|
+
machinePowerStateReady: true,
|
|
115
|
+
machineOpenStatus: isDevicePowerOn(powState),
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (res.messageType === 'EVENT' || res.messageType === 'REPORT_PROPERTY') {
|
|
120
|
+
void updateMachineState(res)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const bindGlobalListeners = () => {
|
|
125
|
+
if (!sseClient.value) {
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
sseClient.value.off('message', onGlobalSseMessage)
|
|
130
|
+
sseClient.value.on('message', onGlobalSseMessage)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const destroySseClient = () => {
|
|
134
|
+
if (!sseClient.value) {
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
sseClient.value.destroy()
|
|
139
|
+
sseClient.value = null
|
|
140
|
+
connectedForDeviceId = ''
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const initSseConnection = () => {
|
|
144
|
+
if (!deviceId.value || !userId.value) {
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (sseClient.value) {
|
|
149
|
+
if (connectedForDeviceId === deviceId.value) {
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
destroySseClient()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const url = `${normalDeviceApiBase}/rest/iot/api/device/property/connect?uid=${userId.value}&connectId=${randomNum(10)}&deviceIds=${deviceId.value}`
|
|
157
|
+
|
|
158
|
+
appStore.initSSEClient(url)
|
|
159
|
+
connectedForDeviceId = deviceId.value
|
|
160
|
+
|
|
161
|
+
sseClient.value?.on('open', () => {
|
|
162
|
+
console.log('SSE连接成功建立')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
sseClient.value?.on('error', () => {
|
|
166
|
+
console.log('SSE连接异常,正在自动重试...')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
sseClient.value?.on('maxReconnectReached', () => {
|
|
170
|
+
console.warn('SSE重连失败,已达到最大重连次数')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
sseClient.value?.on('reconnecting', ((data) => {
|
|
174
|
+
const payload = data as { attempt: number }
|
|
175
|
+
console.log(`SSE正在进行第${payload.attempt}次重连...`)
|
|
176
|
+
}) satisfies SseListener)
|
|
177
|
+
|
|
178
|
+
sseClient.value?.on('close', () => {
|
|
179
|
+
console.log('SSE连接关闭,正在清理...')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
bindGlobalListeners()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
watch(
|
|
186
|
+
[deviceId, userId],
|
|
187
|
+
() => {
|
|
188
|
+
initSseConnection()
|
|
189
|
+
},
|
|
190
|
+
{ immediate: true },
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
watch(sseClient, (client) => {
|
|
194
|
+
if (client) {
|
|
195
|
+
bindGlobalListeners()
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
}
|
|
@@ -1,33 +1,135 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { storeToRefs } from 'pinia'
|
|
2
4
|
import WelcomeInfo from '@/components/WelcomeInfo.vue'
|
|
5
|
+
import { setDeviceFunctionProperty } from '@/api/deviceInfo'
|
|
3
6
|
import { nativeApi } from '@/api/nativeApi'
|
|
7
|
+
import {
|
|
8
|
+
DEVICE_POWER_FUNCTION,
|
|
9
|
+
DEVICE_POWER_STATE,
|
|
10
|
+
DEVICE_TOAST,
|
|
11
|
+
isDevicePowerOff,
|
|
12
|
+
parseDevicePowerState,
|
|
13
|
+
} from '@/constants/device'
|
|
14
|
+
import { useAppStore, useMachineStateStore } from '@/stores'
|
|
15
|
+
import { DeviceParams } from '@/utils/deviceParams'
|
|
16
|
+
import iconMore from '@/assets/images/home/icon-more.png'
|
|
17
|
+
import iconPower from '@/assets/images/home/icon-power.png'
|
|
4
18
|
|
|
5
|
-
const
|
|
19
|
+
const appStore = useAppStore()
|
|
20
|
+
const machineStateStore = useMachineStateStore()
|
|
21
|
+
const { deviceName, machineOnlineStatus } = storeToRefs(appStore)
|
|
22
|
+
const { deviceData } = storeToRefs(machineStateStore)
|
|
23
|
+
|
|
24
|
+
const homeHeaderTitle = computed(
|
|
25
|
+
() => String(deviceName.value ?? '').trim() || '燃气热水器',
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const deviceShadowProperties = computed(
|
|
29
|
+
() => deviceData.value.data ?? {},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const devicePowerState = computed(() =>
|
|
33
|
+
parseDevicePowerState(
|
|
34
|
+
deviceShadowProperties.value.powerState ??
|
|
35
|
+
deviceShadowProperties.value.powState,
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const devicePowerOff = computed(() => isDevicePowerOff(devicePowerState.value))
|
|
40
|
+
|
|
41
|
+
const deviceShutdownWhileOnline = computed(
|
|
42
|
+
() => devicePowerOff.value && machineOnlineStatus.value,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
function showTip(message: string) {
|
|
46
|
+
if (import.meta.env.DEV) {
|
|
47
|
+
console.warn('[Tip]', message)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
6
50
|
|
|
7
51
|
function onBack() {
|
|
8
52
|
history.back()
|
|
9
53
|
}
|
|
10
54
|
|
|
11
|
-
function
|
|
55
|
+
async function invokePowerToggle(
|
|
56
|
+
params: Record<string, unknown>,
|
|
57
|
+
successToast?: string,
|
|
58
|
+
) {
|
|
59
|
+
const payload = DeviceParams.create({
|
|
60
|
+
function: DEVICE_POWER_FUNCTION.POWER_CONTROL,
|
|
61
|
+
params,
|
|
62
|
+
}).toPayload()
|
|
63
|
+
|
|
64
|
+
const res = await setDeviceFunctionProperty(payload)
|
|
65
|
+
if (res != null && successToast) {
|
|
66
|
+
showTip(successToast)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function onPowerIconClick() {
|
|
71
|
+
if (!machineOnlineStatus.value) {
|
|
72
|
+
showTip(DEVICE_TOAST.OFFLINE)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ps = devicePowerState.value
|
|
77
|
+
|
|
78
|
+
if (ps === DEVICE_POWER_STATE.OFF) {
|
|
79
|
+
await invokePowerToggle({ powerCtrl: 1 }, DEVICE_TOAST.POWER_ON_SUCCESS)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
ps === DEVICE_POWER_STATE.READY ||
|
|
85
|
+
ps === DEVICE_POWER_STATE.WORKING
|
|
86
|
+
) {
|
|
87
|
+
await invokePowerToggle({ powerCtrl: 0 }, DEVICE_TOAST.POWER_OFF_SUCCESS)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
showTip(DEVICE_TOAST.OFFLINE)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function onMoreIconClick() {
|
|
95
|
+
if (deviceShutdownWhileOnline.value) {
|
|
96
|
+
showTip(DEVICE_TOAST.POWER_OFF)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
12
100
|
void nativeApi.skipDeviceMore('5.0')
|
|
13
101
|
}
|
|
14
102
|
</script>
|
|
15
103
|
|
|
16
104
|
<template>
|
|
17
105
|
<div class="page home-page">
|
|
18
|
-
<RokiNavBar :title="
|
|
106
|
+
<RokiNavBar :title="homeHeaderTitle" @click-left="onBack">
|
|
19
107
|
<template #right>
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
108
|
+
<div class="home-page__icon-group">
|
|
109
|
+
<span
|
|
110
|
+
role="button"
|
|
111
|
+
tabindex="0"
|
|
112
|
+
class="home-page__icon-btn"
|
|
113
|
+
:class="{ 'home-page__icon-btn--muted': devicePowerOff }"
|
|
114
|
+
aria-label="电源"
|
|
115
|
+
@click.stop="onPowerIconClick"
|
|
116
|
+
@keydown.enter.prevent="onPowerIconClick"
|
|
117
|
+
@keydown.space.prevent="onPowerIconClick"
|
|
118
|
+
>
|
|
119
|
+
<img :src="iconPower" alt="" />
|
|
120
|
+
</span>
|
|
121
|
+
<span
|
|
122
|
+
role="button"
|
|
123
|
+
tabindex="0"
|
|
124
|
+
class="home-page__icon-btn"
|
|
125
|
+
aria-label="更多"
|
|
126
|
+
@click.stop="onMoreIconClick"
|
|
127
|
+
@keydown.enter.prevent="onMoreIconClick"
|
|
128
|
+
@keydown.space.prevent="onMoreIconClick"
|
|
129
|
+
>
|
|
130
|
+
<img :src="iconMore" alt="" />
|
|
131
|
+
</span>
|
|
132
|
+
</div>
|
|
31
133
|
</template>
|
|
32
134
|
</RokiNavBar>
|
|
33
135
|
|
|
@@ -44,15 +146,37 @@ function onMore() {
|
|
|
44
146
|
padding: 0 18px 24px;
|
|
45
147
|
}
|
|
46
148
|
|
|
47
|
-
.home-
|
|
149
|
+
.home-page__icon-group {
|
|
150
|
+
display: inline-flex;
|
|
151
|
+
align-items: center;
|
|
152
|
+
column-gap: 10px;
|
|
153
|
+
flex-shrink: 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.home-page__icon-btn {
|
|
48
157
|
display: inline-flex;
|
|
49
158
|
align-items: center;
|
|
50
159
|
justify-content: center;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
160
|
+
margin: 0;
|
|
161
|
+
padding: 0;
|
|
162
|
+
border: none;
|
|
163
|
+
background: transparent;
|
|
164
|
+
line-height: 0;
|
|
165
|
+
cursor: pointer;
|
|
166
|
+
flex-shrink: 0;
|
|
167
|
+
-webkit-tap-highlight-color: transparent;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.home-page__icon-btn img {
|
|
171
|
+
display: block;
|
|
172
|
+
width: 24px;
|
|
173
|
+
height: 24px;
|
|
174
|
+
flex-shrink: 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.home-page__icon-btn--muted img {
|
|
178
|
+
opacity: 0.6;
|
|
179
|
+
filter: grayscale(1);
|
|
56
180
|
}
|
|
57
181
|
|
|
58
182
|
.home-page__spacer {
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/// <reference types="vite/client" />
|
|
2
2
|
|
|
3
|
+
declare module 'event-source-polyfill' {
|
|
4
|
+
export class EventSourcePolyfill extends EventSource {
|
|
5
|
+
constructor(url: string, options?: { headers?: Record<string, string> })
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
3
9
|
declare module '*.png' {
|
|
4
10
|
const src: string
|
|
5
11
|
export default src
|