Hivyo, hivi karibuni nilikuwa na mradi ambapo nilihitaji kipengele cha mazungumzo. mawazo yangu ya kwanza yalikuwa kama tu kuunganisha chombo cha sasa kama Jivo au LiveChat, lakini sikutaka kutegemea bidhaa za tatu kwa kitu ambacho kinaweza kujengwa moja kwa moja kwenye jopo langu la usimamizi. Katika chapisho hili, nitakuja kupitia jinsi nilivyojenga: usanifu, mazingira ya sockets na hali, na vipengele vya UI ambavyo vimeunganisha yote. Kwa nini ? Admirali wa Admirali wa imeundwa ili iwe inapatikana. Pamoja na njia ya msingi ya faili, hooks, na vipengele vinavyoweza kukuzuia, haina kufungwa - inakuwezesha kutekeleza vipengele vya kibinafsi. Hiyo ndiyo niliyohitaji kwa ajili ya mazungumzo: sio tu CRUD, lakini ujumbe wa wakati halisi ambao bado unaingiliwa kwa usahihi kwenye panel. Admirali wa Mipango ya Chat Hapa ni jinsi ya kuandaa mambo: Core components ChatPage - ukurasa mkuu wa chat ChatSidebar – orodha ya mazungumzo na maoni ya awali ChatPanel – hufanya mazungumzo yaliyochaguliwa MessageFeed - Mstari wa ujumbe MessageInput – kuingia na faili ya upload Context providers SocketContext - kusimamia uhusiano wa WebSocket ChatContext - kusimamia mazungumzo na hali ya ujumbe Ukurasa Mkuu wa Chat Pamoja na njia ya Admiral, kuanzisha ukurasa mpya ulikuwa rahisi. // pages/chat/index.tsx import ChatPage from '@/src/crud/chat' export default ChatPage Hii ilikuwa ya kutosha kufanya ukurasa huu inapatikana katika . /chat Utekelezaji wa msingi ulikuwa : src/crud/chat/index.tsx // src/crud/chat/index.tsx import React from 'react' import { Card } from '@devfamily/admiral' import { usePermissions, usePermissionsRedirect } from '@devfamily/admiral' import { SocketProvider } from './contexts/SocketContext' import { ChatProvider } from './contexts/ChatContext' import ChatSidebar from './components/ChatSidebar' import ChatPanel from './components/ChatPanel' import styles from './Chat.module.css' export default function ChatPage() { const { permissions, loaded, isAdmin } = usePermissions() const identityPermissions = permissions?.chat?.chat usePermissionsRedirect({ identityPermissions, isAdmin, loaded }) return ( <SocketProvider> <ChatProvider> <Card className={styles.page}> <PageTitle title="Corporate chat" /> <div className={styles.chat}> <ChatSidebar /> <ChatPanel /> </div> </Card> </ChatProvider> </SocketProvider> ) } Hapa, nilifungua ukurasa wa na ya , na kutumia hooks ya Admiral kwa ruhusa na redirect. SocketProvider ChatProvider Utawala wa Uunganisho wa WebSocket na SocketContext Kwa ajili ya mazungumzo ya wakati halisi, nilichagua Centrifuge. nilitaka mantiki yote ya uhusiano katika sehemu moja, hivyo niliunda : SocketContext // src/crud/chat/SocketContext.tsx import React from 'react' import { Centrifuge } from 'centrifuge' import { createContext, ReactNode, useContext, useEffect, useRef, useState } from 'react' import { useGetIdentity } from '@devfamily/admiral' const SocketContext = createContext(null) export const SocketProvider = ({ children }: { children: ReactNode }) => { const { identity: user } = useGetIdentity() const [lastMessage, setLastMessage] = useState(null) const centrifugeRef = useRef(null) const subscribedRef = useRef(false) useEffect(() => { if (!user?.ws_token) return const WS_URL = import.meta.env.VITE_WS_URL if (!WS_URL) { console.error('❌ Missing VITE_WS_URL in env') return } const centrifuge = new Centrifuge(WS_URL, { token: user.ws_token, // Initializing the WebSocket connection with a token }) centrifugeRef.current = centrifuge centrifugeRef.current.connect() // Subscribing to the chat channel const sub = centrifugeRef.current.newSubscription(`admin_chat`) sub.on('publication', function (ctx: any) { setLastMessage(ctx.data); }).subscribe() // Cleaning up on component unmount return () => { subscribedRef.current = false centrifuge.disconnect() } }, [user?.ws_token]) return ( <SocketContext.Provider value={{ lastMessage, centrifuge: centrifugeRef.current }}> {children} </SocketContext.Provider> ) } export const useSocket = () => { const ctx = useContext(SocketContext) if (!ctx) throw new Error('useSocket must be used within SocketProvider') return ctx } Mtazamo huu ulihusisha kuanzisha uhusiano, usajili, na kusafisha. sehemu zingine za programu zilizotumika tu . useSocket() Kuendesha hali ya chat na ChatContext Kisha, nilihitaji kukusanya mazungumzo, kupakia ujumbe, kutuma ujumbe mpya, na kujibu updates ya WebSocket. : ChatContext // src/crud/chat/ChatContext.tsx import React, { useRef } from "react"; import { createContext, useContext, useEffect, useState, useRef, useCallback, } from "react"; import { useSocket } from "./SocketContext"; import { useUrlState } from "@devfamily/admiral"; import api from "../api"; const ChatContext = createContext(null); export const ChatProvider = ({ children }) => { const { lastMessage } = useSocket(); const [dialogs, setDialogs] = useState([]); const [messages, setMessages] = useState([]); const [selectedDialog, setSelectedDialog] = useState(null); const [urlState] = useUrlState(); const { client_id } = urlState; const fetchDialogs = useCallback(async () => { const res = await api.dialogs(); setDialogs(res.data || []); }, []); const fetchMessages = useCallback(async (id) => { const res = await api.messages(id); setMessages(res.data || []); }, []); useEffect(() => { fetchMessages(client_id); }, [fetchMessages, client_id]); useEffect(() => { fetchDialogs(); }, [fetchDialogs]); useEffect(() => { if (!lastMessage) return; fetchDialogs(); setMessages((prev) => [...prev, lastMessage.data]); }, [lastMessage]); const sendMessage = useCallback( async (value, onSuccess, onError) => { try { const res = await api.send(value); if (res?.data) setMessages((prev) => [...prev, res.data]); fetchDialogs(); onSuccess(); } catch (err) { onError(err); } }, [messages] ); // Within this context, you can extend the logic to: // – Mark messages as read (api.read()) // – Group messages by date, and more. return ( <ChatContext.Provider value={{ dialogs, messages: groupMessagesByDate(messages), selectedDialog, setSelectedDialog, sendMessage, }} > {children} </ChatContext.Provider> ); }; export const useChat = () => { const ctx = useContext(ChatContext); if (!ctx) throw new Error("useChat must be used within ChatProvider"); return ctx; }; Hii ilifanya kila kitu - kukusanya, kuhifadhi, kuboresha - katika sehemu moja. Mfano wa API Client Niliongeza mteja mdogo wa API kwa maombi: // src/crud/chat/api.ts import _ from '../../config/request' import { apiUrl } from '@/src/config/api' const api = { dialogs: () => _.get(`${apiUrl}/chat/dialogs`)(), messages: (id) => _.get(`${apiUrl}/chat/messages/${id}`)(), send: (data) => _.postFD(`${apiUrl}/chat/send`)({ data }), read: (data) => _.post(`${apiUrl}/chat/read`)({ data }), } export default api Viungo vya UI: Sidebar + Panel + Input Baada ya hapo nilikwenda kwenye uwanja wa ndege. Mchezo wa // src/crud/chat/components/ChatSidebar.tsx import React from "react"; import styles from "./ChatSidebar.module.scss"; import ChatSidebarItem from "../ChatSidebarItem/ChatSidebarItem"; import { useChat } from "../../model/ChatContext"; function ChatSidebar({}) { const { dialogs } = useChat(); if (!dialogs.length) { return ( <div className={styles.empty}> <span>No active активных dialogs</span> </div> ); } return <div className={styles.list}> {dialogs.map((item) => ( <ChatSidebarItem key={item.id} data={item} /> ))} </div> } export default ChatSidebar; Mtazamo wa Chat // src/crud/chat/components/ChatSidebarItem.tsx import React from "react"; import { Badge } from '@devfamily/admiral' import dayjs from "dayjs"; import { BsCheck2, BsCheck2All } from "react-icons/bs"; import styles from "./ChatSidebarItem.module.scss"; function ChatSidebarItem({ data }) { const { client_name, client_id, last_message, last_message_ } = data; const [urlState, setUrlState] = useUrlState(); const { client_id } = urlState; const { setSelectedDialog } = useChat(); const onSelectDialog = useCallback(() => { setUrlState({ client_id: client.id }); setSelectedDialog(data); }, [order.id]); return ( <div className={`${styles.item} ${isSelected ? styles.active : ""}`} onClick={onSelectDialog} role="button" > <div className={styles.avatar}>{client_name.charAt(0).toUpperCase()}</div> <div className={styles.content}> <div className={styles.header}> <span className={styles.name}>{client_name}</span> <span className={styles.time}> {dayjs(last_message_).format("HH:mm")} {message.is_read ? ( <BsCheck2All size="16px" /> ) : ( <BsCheck2 size="16px" /> )} </span> </div> <span className={styles.preview}>{last_message.text}</span> {unread_count > 0 && ( <Badge>{unread_count}</Badge> )} </div> </div> ); } export default ChatSidebarItem; ChatPani ya // src/crud/chat/components/ChatPanel.tsx import React from "react"; import { Card } from '@devfamily/admiral'; import { useChat } from "../../contexts/ChatContext"; import MessageFeed from "../MessageFeed"; import MessageInput from "../MessageInput"; import styles from "./ChatPanel.module.scss"; function ChatPanel() { const { selectedDialog } = useChat(); if (!selectedDialog) { return ( <Card className={styles.emptyPanel}> <div className={styles.emptyState}> <h3>Choose the dialog</h3> <p>Choose the dialog from the list to start conversation</p> </div> </Card> ); } return ( <div className={styles.panel}> <MessageFeed /> <div className={styles.divider} /> <MessageInput /> </div> ); } export default ChatPanel; Ujumbe wa // src/crud/chat/components/MessageFeed.tsx import React, { useRef, useEffect } from "react"; import { BsCheck2, BsCheck2All } from "react-icons/bs"; import { useChat } from "../../contexts/ChatContext"; import MessageItem from "../MessageItem"; import styles from "./MessageFeed.module.scss"; function MessageFeed() { const { messages } = useChat(); const scrollRef = useRef(null); useEffect(() => { scrollRef.current?.scrollIntoView({ behavior: "auto" }); }, [messages]); return ( <div ref={scrollRef} className={styles.feed}> {messages.map((group) => ( <div key={group.date} className={styles.dateGroup}> <div className={styles.dateDivider}> <span>{group.date}</span> </div> {group.messages.map((msg) => ( <div className={styles.message}> {msg.text && <p>{msg.text}</p>} {msg.image && ( <img src={msg.image} alt="" style={{ maxWidth: "200px", borderRadius: 4 }} /> )} {msg.file && ( <a href={msg.file} target="_blank" rel="noopener noreferrer"> Скачать файл </a> )} <div style={{ fontSize: "0.8rem", opacity: 0.6 }}> {dayjs(msg.created_at).format("HH:mm")} {msg.is_read ? <BsCheck2All /> : <BsCheck2 />} </div> </div> ))} </div> ))} </div> ); } export default MessageFeed; ujumbe wa // src/crud/chat/components/MessageInput.tsx import React from "react"; import { ChangeEventHandler, useCallback, useEffect, useRef, useState, } from "react"; import { FiPaperclip } from "react-icons/fi"; import { RxPaperPlane } from "react-icons/rx"; import { Form, Button, useUrlState, Textarea } from "@devfamily/admiral"; import { useChat } from "../../model/ChatContext"; import styles from "./MessageInput.module.scss"; function MessageInput() { const { sendMessage } = useChat(); const [urlState] = useUrlState(); const { client_id } = urlState; const [values, setValues] = useState({}); const textRef = useRef < HTMLTextAreaElement > null; useEffect(() => { setValues({}); setErrors(null); }, [client_id]); const onSubmit = useCallback( async (e?: React.FormEvent<HTMLFormElement>) => { e?.preventDefault(); const textIsEmpty = !values.text?.trim()?.length; sendMessage( { ...(values.image && { image: values.image }), ...(!textIsEmpty && { text: values.text }), client_id, }, () => { setValues({ text: "" }); }, (err: any) => { if (err.errors) { setErrors(err.errors); } } ); }, [values, sendMessage, client_id] ); const onUploadFile: ChangeEventHandler<HTMLInputElement> = useCallback( (e) => { const file = Array.from(e.target.files || [])[0]; setValues((prev: any) => ({ ...prev, image: file })); e.target.value = ""; }, [values] ); const onChange = useCallback((e) => { setValues((prev) => ({ ...prev, text: e.target.value })); }, []); const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => { if ((e.code === "Enter" || e.code === "NumpadEnter") && !e.shiftKey) { onSubmit(); e.preventDefault(); } }, [onSubmit]); return ( <form className={styles.form} onSubmit={onSubmit}> <label className={styles.upload}> <input type="file" onChange={onUploadFile} className={styles.visuallyHidden} /> <FiPaperclip size="24px" /> </label> <Textarea value={values.text ?? ""} onChange={onChange} rows={1} onKeyDown={onKeyDown} placeholder="Написать сообщение..." ref={textRef} className={styles.textarea} /> <Button view="secondary" type="submit" disabled={!values.image && !values.text?.trim().length} className={styles.submitBtn} > <RxPaperPlane /> </Button> </form> ); } export default MessageInput; Stili ya Nilijenga kwa kutumia mabadiliko ya CSS ya Admiral ili kudumisha kila kitu kwa utaratibu: .chat { border-radius: var(--radius-m); border: 2px solid var(--color-bg-border); background-color: var(--color-bg-default); } .message { padding: var(--space-m); border-radius: var(--radius-s); background-color: var(--color-bg-default); } Kuongeza Maelezo Pia niliongeza taarifa kwa ujumbe mpya wakati mtumiaji hakuwa akiangalia mazungumzo hayo: import { useNotifications } from '@devfamily/admiral' const ChatContext = () => { const { showNotification } = useNotifications() useEffect(() => { if (!lastMessage) return if (selectedDialog?.client_id !== lastMessage.client_id) { showNotification({ title: 'New message', message: `${lastMessage.client_name}: ${lastMessage.text || 'Image'}`, type: 'info', duration: 5000 }) } }, [lastMessage, selectedDialog, showNotification]) } Mwisho wa Na tu kama hiyo, badala ya kutumia zana za tatu, niliunda moja kwa moja kwenye paneli yangu ya usimamizi inayotokana na Admiral. njia ya Admiral, mazingira, hooks, na mfumo wa kubuni ilifanya inawezekana kujenga mazungumzo ya wakati halisi ambayo ilihisi ya asili kwa paneli. Matokeo yalikuwa mazungumzo kamili ya kibinafsi: ujumbe wa wakati halisi, mazungumzo, uhariri wa faili, na taarifa - yote iliyounganishwa na chini ya udhibiti wangu. Angalia, na ujue kile unachokiona!