@roki-h5/create-roki-app 0.1.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roki-h5/create-roki-app",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Roki H5 项目脚手架",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,6 +21,7 @@
21
21
  "templates/h5/scripts",
22
22
  "templates/h5/src",
23
23
  "templates/h5/index.html",
24
+ "templates/h5/public",
24
25
  "templates/h5/package.json",
25
26
  "templates/h5/postcss.config.js",
26
27
  "templates/h5/tsconfig.json",
@@ -10,6 +10,8 @@
10
10
  <meta name="mobile-web-app-capable" content="yes" />
11
11
  <meta name="format-detection" content="telephone=no" />
12
12
  <meta name="apple-touch-fullscreen" content="yes" />
13
+ <link rel="icon" type="image/png" href="/favicon.png" />
14
+ <link rel="icon" href="/favicon.ico" sizes="any" />
13
15
  <title>%VITE_APP_TITLE%</title>
14
16
  </head>
15
17
  <body>
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@roki-h5/scaffold-template",
3
3
  "private": true,
4
- "version": "0.1.0",
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
@@ -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
+ }
@@ -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,
@@ -5,3 +5,4 @@ const pinia = createPinia()
5
5
  export default pinia
6
6
  export { useAppStore } from './app'
7
7
  export type { AppStateUpdates } from './app'
8
+ export { useMachineStateStore } from './machineState'
@@ -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.0.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,8 @@
1
+ export function randomNum(length: number) {
2
+ const str = '0123456789'
3
+ let result = ''
4
+ for (let i = length; i > 0; i -= 1) {
5
+ result += str[Math.floor(Math.random() * str.length)]
6
+ }
7
+ return result
8
+ }
@@ -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 appTitle = import.meta.env.VITE_APP_TITLE
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 onMore() {
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="appTitle" @click-left="onBack">
106
+ <RokiNavBar :title="homeHeaderTitle" @click-left="onBack">
19
107
  <template #right>
20
- <span
21
- role="button"
22
- tabindex="0"
23
- class="home-page__more"
24
- aria-label="更多"
25
- @click.stop="onMore"
26
- @keydown.enter.prevent="onMore"
27
- @keydown.space.prevent="onMore"
28
- >
29
-
30
- </span>
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-page__more {
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
- width: 42px;
52
- height: 42px;
53
- font-size: 20px;
54
- line-height: 1;
55
- color: rgba(0, 0, 0, 0.85);
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