@roki-h5/create-roki-app 0.1.5 → 0.1.7

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.5",
3
+ "version": "0.1.7",
4
4
  "description": "Roki H5 项目脚手架",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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.3",
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,73 @@
1
+ /** shadow / SSE 中开关机字段名 */
2
+ export const DEVICE_PROPERTY_KEY = {
3
+ POW_STATE: 'powState',
4
+ } as const
5
+
6
+ /** 设备开关机状态 powState(调料机等) */
7
+ export const DEVICE_POW_STATE = {
8
+ OFF: '0',
9
+ ON: '1',
10
+ } as const
11
+
12
+ /**
13
+ * 热水器 shadow powerState(与 SSE 一致)
14
+ * 0 关机 / 1 就绪 / 2 工作中
15
+ */
16
+ export const DEVICE_POWER_STATE = {
17
+ OFF: 0,
18
+ READY: 1,
19
+ WORKING: 2,
20
+ } as const
21
+
22
+ export const DEVICE_POWER_FUNCTION = {
23
+ POWER_CONTROL: 'powerControl',
24
+ } as const
25
+
26
+ export const DEVICE_TOAST = {
27
+ OFFLINE: '设备已离线',
28
+ POWER_OFF: '热水器已关机',
29
+ POWER_ON_SUCCESS: '热水器已准备就绪',
30
+ POWER_OFF_SUCCESS: '热水器已关机',
31
+ } as const
32
+
33
+ export function parseDevicePowerState(raw: unknown) {
34
+ if (raw === undefined || raw === null || raw === '') return null
35
+ const n = Number(raw)
36
+ return Number.isNaN(n) ? null : n
37
+ }
38
+
39
+ export function isDevicePowerOff(powerState: number | null) {
40
+ return powerState === DEVICE_POWER_STATE.OFF
41
+ }
42
+
43
+ export function resolvePowState(source: Record<string, unknown> | null | undefined) {
44
+ if (source == null || typeof source !== 'object') {
45
+ return null
46
+ }
47
+
48
+ const value = source[DEVICE_PROPERTY_KEY.POW_STATE] ?? source.powerState
49
+
50
+ if (value == null || value === '') {
51
+ return null
52
+ }
53
+
54
+ return String(value)
55
+ }
56
+
57
+ export function normalizePowState(value: unknown) {
58
+ if (value == null || value === '') {
59
+ return null
60
+ }
61
+
62
+ return String(value)
63
+ }
64
+
65
+ export function isDevicePowerOn(powState: unknown) {
66
+ const normalized = normalizePowState(powState)
67
+
68
+ if (normalized != null) {
69
+ return normalized === DEVICE_POW_STATE.ON
70
+ }
71
+
72
+ return Number(powState) === 1
73
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 设备在线状态
3
+ * POST /rest/iot/api/device/info/state → data[0].status
4
+ */
5
+ export const DEVICE_ONLINE_STATUS = {
6
+ /** 离线 */
7
+ OFFLINE: 0,
8
+ /** 在线 */
9
+ ONLINE: 1,
10
+ } as const
11
+
12
+ export type DeviceOnlineStatusCode =
13
+ (typeof DEVICE_ONLINE_STATUS)[keyof typeof DEVICE_ONLINE_STATUS]
14
+
15
+ /** status 码 → 展示文案 */
16
+ export const DEVICE_ONLINE_STATUS_LABEL: Record<
17
+ DeviceOnlineStatusCode,
18
+ string
19
+ > = {
20
+ [DEVICE_ONLINE_STATUS.OFFLINE]: '离线',
21
+ [DEVICE_ONLINE_STATUS.ONLINE]: '在线',
22
+ }
23
+
24
+ export function parseDeviceOnlineStatus(raw: unknown) {
25
+ if (raw === undefined || raw === null || raw === '') return null
26
+ const n = Number(raw)
27
+ return Number.isNaN(n) ? null : n
28
+ }
29
+
30
+ /** status === 1 为在线 */
31
+ export function isDeviceOnline(status: unknown) {
32
+ return Number(status) === DEVICE_ONLINE_STATUS.ONLINE
33
+ }
34
+
35
+ export function getDeviceOnlineStatusLabel(status: unknown) {
36
+ const code = parseDeviceOnlineStatus(status)
37
+ if (code === DEVICE_ONLINE_STATUS.OFFLINE) {
38
+ return DEVICE_ONLINE_STATUS_LABEL[DEVICE_ONLINE_STATUS.OFFLINE]
39
+ }
40
+ if (code === DEVICE_ONLINE_STATUS.ONLINE) {
41
+ return DEVICE_ONLINE_STATUS_LABEL[DEVICE_ONLINE_STATUS.ONLINE]
42
+ }
43
+ return ''
44
+ }
@@ -1,6 +1,8 @@
1
- import { ref } from 'vue'
1
+ import { computed, ref } from 'vue'
2
2
  import { defineStore } from 'pinia'
3
3
  import { getWelcomeMessage, type WelcomeMessageData } from '@/api/welcome'
4
+ import { isDeviceOnline } from '@/constants/deviceOnline'
5
+ import { SseService } from '@/utils/sse'
4
6
 
5
7
  function formatRequestDate(date = new Date()) {
6
8
  const pad = (n: number) => String(n).padStart(2, '0')
@@ -18,6 +20,8 @@ export interface AppStateUpdates {
18
20
  deviceId?: string
19
21
  deviceGuid?: string
20
22
  userId?: string
23
+ machineOpenStatus?: boolean | null
24
+ machinePowerStateReady?: boolean
21
25
  }
22
26
 
23
27
  export const useAppStore = defineStore('app', () => {
@@ -40,6 +44,24 @@ 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
+ * 设备在线状态码:info/state → data[0].status
53
+ * 0 离线 / 1 在线;未拉取前为 null
54
+ */
55
+ const deviceOnlineStatusCode = ref<number | null>(null)
56
+ /** 是否在线(未拉取前默认 true,避免首屏误显示离线) */
57
+ const machineOnlineStatus = computed(() => {
58
+ const status = deviceOnlineStatusCode.value
59
+ if (status === null || status === undefined) return true
60
+ return isDeviceOnline(status)
61
+ })
62
+ /** SSE 客户端 */
63
+ const sseClient = ref<SseService | null>(null)
64
+
43
65
  function setAppName(name: string) {
44
66
  appName.value = name
45
67
  }
@@ -48,6 +70,19 @@ export const useAppStore = defineStore('app', () => {
48
70
  accessToken.value = token
49
71
  }
50
72
 
73
+ function setDeviceOnlineStatus(status: number) {
74
+ deviceOnlineStatusCode.value = status
75
+ }
76
+
77
+ function initSSEClient(url: string) {
78
+ if (sseClient.value) {
79
+ console.log('[SSE] 已存在连接,不重复创建')
80
+ return sseClient.value
81
+ }
82
+ sseClient.value = new SseService(url)
83
+ return sseClient.value
84
+ }
85
+
51
86
  function resetWelcomeCopy() {
52
87
  welcomeCopy.value = {}
53
88
  }
@@ -78,6 +113,8 @@ export const useAppStore = defineStore('app', () => {
78
113
  deviceId,
79
114
  deviceGuid,
80
115
  userId,
116
+ machineOpenStatus,
117
+ machinePowerStateReady,
81
118
  } as const
82
119
 
83
120
  Object.entries(updates).forEach(([key, value]) => {
@@ -101,8 +138,15 @@ export const useAppStore = defineStore('app', () => {
101
138
  userId,
102
139
  accessToken,
103
140
  welcomeCopy,
141
+ machineOpenStatus,
142
+ machinePowerStateReady,
143
+ deviceOnlineStatusCode,
144
+ machineOnlineStatus,
145
+ sseClient,
104
146
  setAppName,
105
147
  setAccessToken,
148
+ setDeviceOnlineStatus,
149
+ initSSEClient,
106
150
  resetWelcomeCopy,
107
151
  loadWelcomeMessage,
108
152
  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,196 @@
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 { isDevicePowerOn, resolvePowState } from '@/constants/device'
6
+ import { DEVICE_ONLINE_STATUS, isDeviceOnline } from '@/constants/deviceOnline'
7
+ import { normalDeviceApiBase } from '@config/env'
8
+ import { useAppStore } from '@/stores/app'
9
+ import { useMachineStateStore } from '@/stores/machineState'
10
+ import { randomNum } from '@/utils/random'
11
+ import type { SseListener } from '@/utils/sse'
12
+
13
+ const OFFLINE_STATUS_REFRESH_MS = 3000
14
+
15
+ function throttle<T extends (...args: never[]) => void>(fn: T, wait: number) {
16
+ let last = 0
17
+ let timer: ReturnType<typeof setTimeout> | null = null
18
+
19
+ return (...args: Parameters<T>) => {
20
+ const now = Date.now()
21
+ const remaining = wait - (now - last)
22
+
23
+ if (remaining <= 0) {
24
+ if (timer) {
25
+ clearTimeout(timer)
26
+ timer = null
27
+ }
28
+ last = now
29
+ fn(...args)
30
+ return
31
+ }
32
+
33
+ if (!timer) {
34
+ timer = setTimeout(() => {
35
+ last = Date.now()
36
+ timer = null
37
+ fn(...args)
38
+ }, remaining)
39
+ }
40
+ }
41
+ }
42
+
43
+ /**
44
+ * 应用级 SSE:建立长连接并同步设备在线/开关机/工作状态。
45
+ * shadow 全量属性仅在 App 启动时拉取一次;此处通过 SSE 增量更新 store。
46
+ */
47
+ export function useGlobalSse() {
48
+ const router = useRouter()
49
+ const appStore = useAppStore()
50
+ const machineStateStore = useMachineStateStore()
51
+ const { updateMachineState } = machineStateStore
52
+ const { deviceId, userId, sseClient, machineOnlineStatus } = storeToRefs(appStore)
53
+
54
+ let connectedForDeviceId = ''
55
+
56
+ const refreshDeviceOnlineStatus = throttle(() => {
57
+ if (!deviceId.value) {
58
+ return
59
+ }
60
+
61
+ getDeviceStatus([deviceId.value])
62
+ .then((statusRes) => {
63
+ const status = statusRes.data?.[0]?.status
64
+ if (status != null) {
65
+ appStore.setDeviceOnlineStatus(status)
66
+ }
67
+ })
68
+ .catch((error) => {
69
+ console.error('[SSE] 刷新设备在线状态失败:', error)
70
+ })
71
+ }, OFFLINE_STATUS_REFRESH_MS)
72
+
73
+ const onGlobalSseMessage: SseListener = (data) => {
74
+ const event = data as MessageEvent
75
+ let res: {
76
+ messageType?: string
77
+ data?: Record<string, unknown>
78
+ }
79
+
80
+ try {
81
+ res = JSON.parse(event.data || '{}')
82
+ } catch {
83
+ return
84
+ }
85
+
86
+ if (!res?.data) {
87
+ return
88
+ }
89
+
90
+ console.log('🌹🌹 【 SSE 】🌹🌹', res)
91
+
92
+ const powState = resolvePowState(res.data)
93
+
94
+ if (res.messageType === 'OFFLINE') {
95
+ appStore.setDeviceOnlineStatus(DEVICE_ONLINE_STATUS.OFFLINE)
96
+ void nextTick(() => {
97
+ void router.replace('/')
98
+ })
99
+ return
100
+ }
101
+
102
+ if (res.messageType === 'ONLINE') {
103
+ appStore.setDeviceOnlineStatus(DEVICE_ONLINE_STATUS.ONLINE)
104
+ }
105
+
106
+ if (!machineOnlineStatus.value) {
107
+ refreshDeviceOnlineStatus()
108
+ }
109
+
110
+ if (powState != null) {
111
+ appStore.updateState({
112
+ machinePowerStateReady: true,
113
+ machineOpenStatus: isDevicePowerOn(powState),
114
+ })
115
+ }
116
+
117
+ if (res.messageType === 'EVENT' || res.messageType === 'REPORT_PROPERTY') {
118
+ void updateMachineState(res)
119
+ }
120
+ }
121
+
122
+ const bindGlobalListeners = () => {
123
+ if (!sseClient.value) {
124
+ return
125
+ }
126
+
127
+ sseClient.value.off('message', onGlobalSseMessage)
128
+ sseClient.value.on('message', onGlobalSseMessage)
129
+ }
130
+
131
+ const destroySseClient = () => {
132
+ if (!sseClient.value) {
133
+ return
134
+ }
135
+
136
+ sseClient.value.destroy()
137
+ sseClient.value = null
138
+ connectedForDeviceId = ''
139
+ }
140
+
141
+ const initSseConnection = () => {
142
+ if (!deviceId.value || !userId.value) {
143
+ return
144
+ }
145
+
146
+ if (sseClient.value) {
147
+ if (connectedForDeviceId === deviceId.value) {
148
+ return
149
+ }
150
+
151
+ destroySseClient()
152
+ }
153
+
154
+ const url = `${normalDeviceApiBase}/rest/iot/api/device/property/connect?uid=${userId.value}&connectId=${randomNum(10)}&deviceIds=${deviceId.value}`
155
+
156
+ appStore.initSSEClient(url)
157
+ connectedForDeviceId = deviceId.value
158
+
159
+ sseClient.value?.on('open', () => {
160
+ console.log('SSE连接成功建立')
161
+ })
162
+
163
+ sseClient.value?.on('error', () => {
164
+ console.log('SSE连接异常,正在自动重试...')
165
+ })
166
+
167
+ sseClient.value?.on('maxReconnectReached', () => {
168
+ console.warn('SSE重连失败,已达到最大重连次数')
169
+ })
170
+
171
+ sseClient.value?.on('reconnecting', ((data) => {
172
+ const payload = data as { attempt: number }
173
+ console.log(`SSE正在进行第${payload.attempt}次重连...`)
174
+ }) satisfies SseListener)
175
+
176
+ sseClient.value?.on('close', () => {
177
+ console.log('SSE连接关闭,正在清理...')
178
+ })
179
+
180
+ bindGlobalListeners()
181
+ }
182
+
183
+ watch(
184
+ [deviceId, userId],
185
+ () => {
186
+ initSseConnection()
187
+ },
188
+ { immediate: true },
189
+ )
190
+
191
+ watch(sseClient, (client) => {
192
+ if (client) {
193
+ bindGlobalListeners()
194
+ }
195
+ })
196
+ }
@@ -1,33 +1,156 @@
1
1
  <script setup lang="ts">
2
+ import { computed, onMounted } from 'vue'
3
+ import { storeToRefs } from 'pinia'
2
4
  import WelcomeInfo from '@/components/WelcomeInfo.vue'
5
+ import { getDeviceStatus, 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, deviceId, 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
  }
102
+
103
+ async function loadDeviceOnlineStatus() {
104
+ const id = String(deviceId.value ?? '').trim()
105
+ if (!id) return
106
+
107
+ try {
108
+ const res = await getDeviceStatus([id])
109
+ const status = res?.data?.[0]?.status
110
+ if (status != null) {
111
+ appStore.setDeviceOnlineStatus(status)
112
+ }
113
+ } catch (e) {
114
+ if (import.meta.env.DEV) {
115
+ console.warn('[Home] loadDeviceOnlineStatus failed', e)
116
+ }
117
+ }
118
+ }
119
+
120
+ onMounted(() => {
121
+ void loadDeviceOnlineStatus()
122
+ })
14
123
  </script>
15
124
 
16
125
  <template>
17
126
  <div class="page home-page">
18
- <RokiNavBar :title="appTitle" @click-left="onBack">
127
+ <RokiNavBar :title="homeHeaderTitle" @click-left="onBack">
19
128
  <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>
129
+ <div class="home-page__icon-group">
130
+ <span
131
+ role="button"
132
+ tabindex="0"
133
+ class="home-page__icon-btn"
134
+ :class="{ 'home-page__icon-btn--muted': devicePowerOff }"
135
+ aria-label="电源"
136
+ @click.stop="onPowerIconClick"
137
+ @keydown.enter.prevent="onPowerIconClick"
138
+ @keydown.space.prevent="onPowerIconClick"
139
+ >
140
+ <img :src="iconPower" alt="" />
141
+ </span>
142
+ <span
143
+ role="button"
144
+ tabindex="0"
145
+ class="home-page__icon-btn"
146
+ aria-label="更多"
147
+ @click.stop="onMoreIconClick"
148
+ @keydown.enter.prevent="onMoreIconClick"
149
+ @keydown.space.prevent="onMoreIconClick"
150
+ >
151
+ <img :src="iconMore" alt="" />
152
+ </span>
153
+ </div>
31
154
  </template>
32
155
  </RokiNavBar>
33
156
 
@@ -44,15 +167,37 @@ function onMore() {
44
167
  padding: 0 18px 24px;
45
168
  }
46
169
 
47
- .home-page__more {
170
+ .home-page__icon-group {
171
+ display: inline-flex;
172
+ align-items: center;
173
+ column-gap: 10px;
174
+ flex-shrink: 0;
175
+ }
176
+
177
+ .home-page__icon-btn {
48
178
  display: inline-flex;
49
179
  align-items: center;
50
180
  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);
181
+ margin: 0;
182
+ padding: 0;
183
+ border: none;
184
+ background: transparent;
185
+ line-height: 0;
186
+ cursor: pointer;
187
+ flex-shrink: 0;
188
+ -webkit-tap-highlight-color: transparent;
189
+ }
190
+
191
+ .home-page__icon-btn img {
192
+ display: block;
193
+ width: 24px;
194
+ height: 24px;
195
+ flex-shrink: 0;
196
+ }
197
+
198
+ .home-page__icon-btn--muted img {
199
+ opacity: 0.6;
200
+ filter: grayscale(1);
56
201
  }
57
202
 
58
203
  .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