لقد كان لدي مؤخرا مشروع حيث كنت بحاجة إلى ميزة الدردشة.فكرة أولى كان من شأنها أن تدمج أداة موجودة مثل Jivo أو LiveChat، ولكن لم أريد أن تعتمد على منتجات طرف ثالث لأي شيء يمكن إنشاؤه مباشرة في منصة إدارة الخاص بي. في هذه المقالة، سأناقش كيف وضعته: الأرثوذكسية، والتقاليد لـ sockets و state، ومكونات UI التي اتصلت بها جميعها. لماذا ? الممثل الممثل تم تصميمها لتوسيعها.على سبيل المثال، مع توجيهات أساسية على الملفات، والملابس، والمكونات مرنة، فإنه لا يغلقك - فإنه يوفر لك مساحة لتنفيذ ميزات مخصصة.هذا هو ما كنت بحاجة إلى الترجمة: ليس فقط CRUD، ولكن رسائل في الوقت الحقيقي التي لا تزال تتوافق بشكل مباشر مع الكاميرا. الممثل شاشة التخزين هذه هي الطريقة التي وضعت فيها الأشياء: Core components ChatPage - صفحة الشاشة الرئيسية ChatSidebar – قائمة المحادثات مع مشاهدات ChatPanel – يجعل الشاشة المختارة MessageFeed - الشريط من الرسائل MessageInput - الدخول مع تحميل الملف Context providers SocketContext – إدارة اتصالات WebSocket ChatContext – إدارة المناقشات ومستوى الرسائل الصفحة الرئيسية Chat مع تدريب Admiral ، كان إعداد صفحة جديدة بسيطة. // pages/chat/index.tsx import ChatPage from '@/src/crud/chat' export default ChatPage هذا ما يكفي لتوفير هذه الصفحة في . /chat وبدأت تنفيذها الرئيسي في : 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> ) } هنا، سجلت الصفحة في و ثم استخدم كوكتيل Admiral لتسمية وإعادة توجيه. SocketProvider ChatProvider إدارة اتصالات WebSocket مع SocketContext للاتصال في الوقت الحقيقي، اخترت Centrifuge. أريد كل منطق الاتصال في مكان واحد، لذلك اخترت : 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 } هذا السياق يتعامل مع تثبيت الاتصال والتسجيل والتنظيف. . useSocket() إدارة الوضع في الصوت مع ChatContext بعد ذلك، كنت بحاجة إلى الحصول على المناقشات، وتحميل الرسائل، وإرسال آخرين، والرد على التحديثات 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; }; هذا كان يحافظ على كل شيء - الحصول على ، وحفظ ، وتحديث - في مكان واحد. مثال عميل API لقد أضفت عميل API صغير للطلبات: // 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 مكونات UI: Sidebar + Panel + Input ثم ذهبت إلى UI layer. شاشة // 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; كاشفين // 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; شاشات // 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; الرسائل // 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; رسائل // 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; نموذج لقد صممت ذلك باستخدام المتغيرات CSS من Admiral للحفاظ على كل شيء متوازنًا: .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); } إضافة الإشعارات لقد أضيف أيضًا إشعارات للرسائل الجديدة عندما لم تظهر المستخدم هذه الشاشة: 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]) } النتيجة وبالطبع، بدلاً من استخدام أدوات من جهة ثانية، قم بتطويرها مباشرة في نافذة إدارة Admiral.Admiral هو الطريق، والمناقشات، والسياسات والتصميم، وقد تمكن من إنشاء محادثة في الوقت الحقيقي الذي يشعر بالذات في نافذة. كان النتيجة اتصال خاص بالكامل: الرسائل في الوقت الحقيقي، والاتصالات، والرسائل، والرسائل - كلها متكاملة تحت سيطرتي. قم بتحقق ذلك، وأرسل لي ما تفكر!