From 66028d934a38520b1b9e7be94ae51ad9fdcd117a Mon Sep 17 00:00:00 2001 From: lethdat Date: Sat, 7 Feb 2026 21:56:05 +0700 Subject: [PATCH] feat: Implement TinyMqttClient for MQTT communication and enhance video components with loading states --- src/client.ts | 1 + src/lib/liteMqtt.ts | 118 ++++++++++++++++++ src/main.ts | 2 +- src/routes/index.ts | 10 ++ src/routes/video/DetailVideo.vue | 15 ++- src/routes/video/Videos.vue | 43 +------ .../video/components/Detail/VideoEditForm.vue | 32 ++++- .../components/Detail/VideoInfoHeader.vue | 42 ++----- .../video/components/Detail/VideoSkeleton.vue | 24 ++-- src/routes/video/components/VideoGrid.vue | 17 ++- src/routes/video/components/VideoTable.vue | 17 ++- 11 files changed, 217 insertions(+), 104 deletions(-) create mode 100644 src/lib/liteMqtt.ts diff --git a/src/client.ts b/src/client.ts index e3f6d69..5fb29ae 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,6 +2,7 @@ import { hydrateQueryCache } from '@pinia/colada'; import 'uno.css'; import PiniaSharedState from './lib/PiniaSharedState'; import { createApp } from './main'; + async function render() { const { app, router, queryCache, pinia } = createApp(); pinia.use(PiniaSharedState({enable: true, initialize: true})); diff --git a/src/lib/liteMqtt.ts b/src/lib/liteMqtt.ts new file mode 100644 index 0000000..9bcf41f --- /dev/null +++ b/src/lib/liteMqtt.ts @@ -0,0 +1,118 @@ +export type MessageCallback = (topic: string, payload: string) => void; +export class TinyMqttClient { + private ws: WebSocket | null = null; + private encoder = new TextEncoder(); + private decoder = new TextDecoder(); + private worker: Worker | null = null; + + constructor( + private url: string, + private topics: string[], + private onMessage: MessageCallback + ) {} + + public connect(): void { + this.ws = new WebSocket(this.url, 'mqtt'); + this.ws.binaryType = 'arraybuffer'; + + this.ws.onopen = () => { + this.sendConnect(); + }; + + this.ws.onmessage = (e) => this.handlePacket(new Uint8Array(e.data)); + this.ws.onclose = () => this.stopHeartbeatWorker(); + } + public disconnect(): void { + this.ws?.close(); + this.stopHeartbeatWorker(); + } + private sendConnect(): void { + const clientId = `ws_worker_${Math.random().toString(16).slice(2, 8)}`; + const idBytes = this.encoder.encode(clientId); + // Keep-alive 60s + const packet = new Uint8Array([ + 0x10, 12 + idBytes.length, + 0x00, 0x04, 0x4d, 0x51, 0x54, 0x54, 0x04, 0x02, 0x00, 0x3c, + 0x00, idBytes.length, ...idBytes + ]); + this.ws?.send(packet); + } + + private startHeartbeatWorker(): void { + if (this.worker) return; + + // Tạo nội dung Worker dưới dạng chuỗi + const workerCode = ` + let timer = null; + self.onmessage = (e) => { + if (e.data === 'START') { + timer = setInterval(() => self.postMessage('TICK'), 30000); + } else if (e.data === 'STOP') { + clearInterval(timer); + } + }; + `; + + const blob = new Blob([workerCode], { type: 'application/javascript' }); + this.worker = new Worker(URL.createObjectURL(blob)); + + this.worker.onmessage = (e) => { + if (e.data === 'TICK' && this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(new Uint8Array([0xC0, 0x00])); // Gửi PINGREQ + } + }; + + this.worker.postMessage('START'); + } + + private stopHeartbeatWorker(): void { + if (this.worker) { + this.worker.postMessage('STOP'); + this.worker.terminate(); + this.worker = null; + console.log('🛑 Worker stopped'); + } + } + + private handlePacket(data: Uint8Array): void { + const type = data[0] & 0xF0; + switch (type) { + case 0x20: // CONNACK + this.startHeartbeatWorker(); + this.subscribe(); + break; + case 0xD0: // PINGRESP + break; + case 0x30: // PUBLISH + this.parsePublish(data); + break; + } + } + + private subscribe(): void { + let payload: number[] = []; + this.topics.forEach(t => { + const b = this.encoder.encode(t); + payload.push(0x00, b.length, ...Array.from(b), 0x00); + }); + const packet = new Uint8Array([0x82, 2 + payload.length, 0x00, 0x01, ...payload]); + this.ws?.send(packet); + } + + private parsePublish(data: Uint8Array): void { + const tLen = (data[2] << 8) | data[3]; + const topic = this.decoder.decode(data.slice(4, 4 + tLen)); + const payload = this.decoder.decode(data.slice(4 + tLen)); + this.onMessage(topic, payload); + } +} + +// --- Cách dùng --- +// const client = new TinyMqttClient( +// 'ws://your-emqx:8083', +// ['sensor/temp', 'sensor/humi', 'system/ping'], +// (topic, msg) => { +// console.log(`[${topic}]: ${msg}`); +// } +// ); +// client.connect(); \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index ec2bee2..52c0616 100644 --- a/src/main.ts +++ b/src/main.ts @@ -45,7 +45,7 @@ export function createApp() { pinia, plugins: [ (context) => { - console.log("PiniaColada plugin initialized for store:", context); + // console.log("PiniaColada plugin initialized for store:", context); } ], queryOptions: { diff --git a/src/routes/index.ts b/src/routes/index.ts index 2b6c725..34d2ba2 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -8,6 +8,7 @@ import { } from "vue-router"; import { useAuthStore } from "@/stores/auth"; import { inject } from "vue"; +import { TinyMqttClient } from "@/lib/liteMqtt"; type RouteData = RouteRecordRaw & { meta?: ResolvableValue & { requiresAuth?: boolean }; @@ -194,11 +195,20 @@ const createAppRouter = () => { router.beforeEach((to, from, next) => { const auth = useAuthStore(); const head = inject(headSymbol); + let client: any; (head as any).push(to.meta.head || {}); if (to.matched.some((record) => record.meta.requiresAuth)) { if (!auth.user) { + if(client?.disconnect) (client as any)?.disconnect(); next({ name: "login" }); } else { + client = new TinyMqttClient( + 'wss://broker.emqx.io:8084/mqtt', + [['ecos1231231'+auth.user.id+'#'].join("/")], + (topic, msg) => console.log(`Tín hiệu nhận được [${topic}]:`, msg) + ); + + client.connect(); next(); } } else { diff --git a/src/routes/video/DetailVideo.vue b/src/routes/video/DetailVideo.vue index 3d3981b..fc21251 100644 --- a/src/routes/video/DetailVideo.vue +++ b/src/routes/video/DetailVideo.vue @@ -144,17 +144,16 @@ const videoInfos = computed(() => { -
- +
+ -
+
- - -
+ v-model:description="form.description" @save="handleSave" @toggle-edit="toggleEdit" :saving="saving" /> +
+

Video Details

diff --git a/src/routes/video/Videos.vue b/src/routes/video/Videos.vue index 3347c89..07bfbe5 100644 --- a/src/routes/video/Videos.vue +++ b/src/routes/video/Videos.vue @@ -136,42 +136,8 @@ watch([searchQuery, selectedStatus, limit, page], () => { @search="handleSearch" @filter="handleFilter" /> - - -
- -
-
- -
- - -
- - -
-
-
-
- -
-
-
- -
- - -
- - - -
-
-
-
- -
+

{{ error }}

- - + - +
diff --git a/src/routes/video/components/Detail/VideoEditForm.vue b/src/routes/video/components/Detail/VideoEditForm.vue index 7c1688d..46a4f67 100644 --- a/src/routes/video/components/Detail/VideoEditForm.vue +++ b/src/routes/video/components/Detail/VideoEditForm.vue @@ -2,11 +2,14 @@ defineProps<{ title: string; description: string; + saving: boolean; }>(); const emit = defineEmits<{ 'update:title': [value: string]; 'update:description': [value: string]; + save: []; + toggleEdit: []; }>(); @@ -14,21 +17,38 @@ const emit = defineEmits<{
-
-
+
+ + + + +
diff --git a/src/routes/video/components/Detail/VideoInfoHeader.vue b/src/routes/video/components/Detail/VideoInfoHeader.vue index fc12756..bd03d37 100644 --- a/src/routes/video/components/Detail/VideoInfoHeader.vue +++ b/src/routes/video/components/Detail/VideoInfoHeader.vue @@ -5,15 +5,12 @@ import Tag from 'primevue/tag'; const props = defineProps<{ video: ModelVideo; - isEditing: boolean; - saving: boolean; }>(); const emit = defineEmits<{ reload: []; toggleEdit: []; delete: []; - save: []; }>(); const formatFileSize = (bytes?: number): string => { @@ -52,7 +49,7 @@ const formatDate = (dateStr?: string): string => {
-

+

{{ video.title }}

{{ video.description }} @@ -71,31 +68,9 @@ const formatDate = (dateStr?: string): string => {

- - - - - - +
diff --git a/src/routes/video/components/Detail/VideoSkeleton.vue b/src/routes/video/components/Detail/VideoSkeleton.vue index 4206aa2..5c40919 100644 --- a/src/routes/video/components/Detail/VideoSkeleton.vue +++ b/src/routes/video/components/Detail/VideoSkeleton.vue @@ -3,9 +3,9 @@ import Skeleton from 'primevue/skeleton';