這是一個DEMO!
這是一個參照 Fiora 項目的原型但技術棧徹底不同的DEMO!
這是一個大三🐕爲了找實習而寫的DEMO!javascript
你們能夠給我這個可憐的大三臭弟弟一個star🐎html
讓各位看官老爺笑話了,因爲本人在設計上沒有任何天賦,所以該項目以 Fiora 項目其中的一個主題做爲了項目原型進行設計與開發。前端
首頁(遊客狀態)java
登陸界面git
聯繫人信息界面github
因爲時間和精力有限(一時全棧一時爽,一直全棧...),該項目僅實現了一些基本功能:web
另外,還使用了HTML5的一些新API,算法
在客戶端,基於Socket.IO-client,每一個用戶會獲得一個socket實例,實例中的socketId爲惟一標識符。chrome
import client from 'socket.io-client'; // 爲 URL 中的路徑名指定的名稱空間返回一個新的 Socket 實例 app.socket = client(socketUrl); // 對於已註冊用戶,咱們還須要攜帶查詢參數來肯定用戶身份 app.socket = client(socketUrl,{ query: { userId } }); 複製代碼
服務端處理socket鏈接typescript
/** * 當有新的鏈接創建時調用該函數,已登陸用戶更新自身的socketId並加入全部羣聊 * @param socket */ @UseFilters(new CustomWsExceptionFilter()) async handleConnection(socket: Socket) { const socketId = socket.id; // 獲取握手的細節信息,包括查詢參數對象以及客戶端IP const {query,address as clentIp} = socket.handshake; if(query.userId) { const userId = query.userId; await this.websocketService.updateSocketMes(socketId,clientIp,userId); await this.websocketService.userJoinRooms(socket,userId); } } 複製代碼
除了socket通訊以外,其餘接口皆不涉及websocket協議。
用戶須要提供用戶名和密碼來進行註冊。密碼會經過bcrypt進行加鹽處理
export async function genSaltPassword(pass: string): Promise<string> { const salt = await bcrypt.genSalt(saltRounds); const hash = await bcrypt.hash(pass,salt); return hash; } 複製代碼
註冊成功後(用戶名不重複)會得到隨機頭像並加入默認羣組
// 得到隨機頭像 const randomAvatarUrl = await this.randomAvatarService.getRandomAvatarUrl(); // 加入默認羣組 const defaultGroup = await this.groupService.getDefaultGroup(); await this.groupRelationshipService.createGroupRelationship({ user_id: userBasicMes.user_id, group_id: defaultGroup.group_id, top_if: 0, group_member_type: '普通' }) 複製代碼
用戶登陸分爲經過帳號密碼進行登陸和經過客戶端存儲的token進行自動登陸
當經過帳密進行登陸時,服務端會驗證帳密的正確性
async validatePassword(userName: string, password: string): Promise<boolean> { // 驗證該用戶是否存在(經過用戶名進行查詢)以及密碼是否匹配 return this.userService.checkUserExistence({ user_name: userName }) && this.userService.checkUserPassword(userName, password); } 複製代碼
對於密碼匹配,bcrypt會根據用戶給定的密碼和註冊時存入數據庫的加鹽密碼進行比對
if(user) { saltPassword = user.user_saltPassword; return await bcrypt.compare(password,saltPassword); } 複製代碼
當身份驗證經過後,服務端會生成一個jwt返回給客戶端用於後續的自動登陸處理
// 導入jwt-simple import * as jwt from "jwt-simple"; async genJWT(userName: string) { const userId = (await this.userService.getUserBasicMes({user_name: userName})).user_id; const payload = { userId, enviroment: 'web', // 設置jwt的過時時間 expires: Date.now() + jwtConfig.tokenExpiresTime }; // 使用默認編碼算法(HS256)進行編碼 const token = jwt.encode(payload,jwtConfig.jwtSecret); return token; } 複製代碼
最後,更新用戶的登陸狀態,socketId及其餘信息
await this.userService.updateUser({ user_id },{ user_lastLogin_time: datetime, user_state: 1, user_socketId, user_preferSetting_id }) 複製代碼
經過jwt自動登陸的,服務端須要驗證jwt的有效性
async validateToken(accessToken: string): Promise<boolean> { // 對token進行解碼 const decodedToken = this.decodeToken(accessToken); // token過時的處理 if(decodedToken.expires < Date.now()) { throw new HttpException("Token已過時,請從新登陸",HttpStatus.FORBIDDEN); } if(this.userService.checkUserExistence({user_id: decodedToken.usrId})) { return true; } else { throw new HttpException("無效Token,此爲非法登陸",HttpStatus.FORBIDDEN); } } 複製代碼
驗證經過後,更新用戶相關信息,用戶登陸成功!
客戶端經過socket實例emit一個message事件來發送消息
socket.emit("message",{ messageFromId, messageToId, messageType, // 消息類型(私聊仍是羣聊) messageContent, messageContentType // 消息內容類型(可分爲text,img...) },res => { // ...成功發送後的回調 }) // 發送消息失敗時的處理 socket.once('messageException', err => { const {message} = err; showMessage(app,'warning',message,3000,true); }) 複製代碼
服務端會在websocket網關監聽到該事件的發生
@UseFilters(new CustomWsExceptionFilter()) @SubscribeMessage('message') async sendMessage(@ConnectedSocket() socket: Socket, @MessageBody() mes: any) { return await this.chatingMessageService.sendMessage(mes,socket); } 複製代碼
而後將消息進行持久化到數據庫中併發送給目標(用戶或者羣聊)
// 持久化消息 messageId = await this.messageService.createMessage(mes); // 經過目標羣聊名(惟一)發送消息到羣聊中 socket.to(groupName).emit('groupMemberMessage',{ messageFromUser, messageTarget: messageTarget, messageId, messageContent, messageContentType, messageType, messageCreatedTime }); // 經過目標用戶的socketId發送私聊消息 socket.to(targetSocketId).emit('messageFromFriend',{ messageFromUser, messageTarget: messageTarget, messageId, messageContent, messageContentType, messageType, messageCreatedTime }); 複製代碼
客戶端經過監聽messageFromFriend和groupMemberMessage事件分別接受私聊消息和羣聊消息
socket.on('messageFromFriend', resolveReceiveMes) socket.on('groupMemberMessage', resolveReceiveMes) 複製代碼
接受到消息以後,該消息會被加入到對應的消息列表中,
// 若是該消息對應的對話信息不存在,那麼建立相應的對話信息框 if(!store.state.messageMap[dialogTargetId]) { await store.dispatch('resolveMessageMap',{ dialogId, dialogTargetId, page: 1, limit: 50 }) } // 若是存在則直接將消息壓入到消息列表中 else store.commit('addNewMessage',{ dialogId,dialogTargetId,messageContentMes }) 複製代碼
另外,若是用戶容許了桌面消息通知功能,那麼會經過獲得(以正則的方式)發送來的消息內容類型以來顯示對應的消息
if(store.state.notification) { // ... if (/image\/(gif|jpeg|jpg|png|svg)/g.test(message.messageContentType)) { notificationContent = `[圖片]` } else { notificationContent = resolveEmoji(message,'messageContent'); } if(messageType === 0) { notificationTile = `好友 ${notifiFromName} 向您發來了一條新消息:`; notificationAvatar = notifiFromAvatar; } else { notificationTile = `羣組 ${notifiTargetName} 新增一條成員信息`; notificationAvatar = notifiTargetAvatar; notificationContent = `${notifiFromName}: ` + notificationContent; } createNotification(notificationTile,{ body: notificationContent, icon: notificationAvatar }) } 複製代碼
客戶端經過分頁的方式來獲取歷史消息。getHistoryMessages接口接受如下三個參數:
爲了每次切換對話框時不須要從新去請求歷史消息,所以,客戶端會在Vuex中全局設置一個messageMap的哈希表來保存已經獲得的消息數據
/** * 處理messageMap * 具體爲在messageMap中建立一個以dialogId爲鍵的消息列表 * @param param0 * @param option */ async resolveMessageMap({commit},option) { const {dialogId,dialogTargetId,page,limit,app} = option; const messages = await historyMessages(dialogId,page,limit); // page大於1確保當一個對話沒有任何信息時,不會提示沒有更多歷史消息了 if((!messages || messages.length === 0) && page > 1) { noMoreMessage(app); } commit('setMessageMap',{dialogTargetId,dialogId,messages}); } 複製代碼
另外,客戶端採用無限上滑的方式來獲取更多的歷史消息
// 當scrollTop等於scrollHeight-clientHeight時,表示滑動條滑倒了對話框的頂端,那麼就加載更多的歷史消息 if(Math.floor(element.scrollTop)+1 < element.scrollHeight - element.clientHeight) { // getHistoryMessages } // 爲了不某些手速快的男孩子在數據返回以前屢次觸發事件,在這裏設置了一個滾動條回彈5px以及防抖處理 element.scrollTop = 5; import * as _ from "lodash"; _.debounce(); 複製代碼
scrollTop和scrollHight的圖示以下:
對話列表項中最後一條消息的時間會根據實際時間與當前時間的間隔而顯示出不一樣的格式
export function resolveTime(time,option) { const mesDatetime = new Date(time); // 獲取當前時間戳 const {year: curYear, month: curMonth, date: curDate} = getDatetimeMes(new Date()); const { year: mesYear, month: mesMonth, date: mesDate } = getDatetimeMes(mesDatetime); // 若是是今天的消息,那麼只返回時間,例如: 15:00 if(curYear === mesYear && curMonth === mesMonth && curDate === mesDate) { return mesDatetime.toTimeString().slice(0,5); } else if(curYear === mesYear && curMonth === mesMonth) { switch (curDate - mesDate) { case 1 : return '昨天' + option; // 昨天的消息,只返回 "昨天" case 2: return '前天' + option; // 前天的消息,只返回 "前天" default: return mesDatetime.toLocaleDateString().slice(5) + option; // 返回日期,例如 2/1 } } else if(curYear === mesYear){ return mesDatetime.toLocaleDateString().slice(5) + option; // 若是是往年的消息,返回年月日,例如2019/2/1 } else { return mesDatetime.toLocaleDateString() + option; } } 複製代碼
聯繫人列表根據聯繫人首字母的大寫進行分類,若是首字母爲數字或其餘非[A-Z]形式,則放入「#」類中。
// 引入cnchar來獲取首字母 var cnchar = require('cnchar'); /** * 根據首字母進行分類(非26個大寫字母則分到#) * @param allFriendsMes */ classifyFriendsByChar(allFriendsMes: Array<UserBasicMes>) { const friendsCharMap: Map<string,Array<UserBasicMes>> = new Map(); allFriendsMes.forEach(friendMes => { const firstLetter: string = cnchar.spell(friendMes.user_name,'array','first')[0].toLocaleUpperCase(); const pattern = /[A-Z]/g; if(pattern.test(firstLetter)) { // 若是首字母爲[A-Z],那麼加入到以該字母爲鍵名的鍵值(數組)中 this.solveCharMap(friendsCharMap,firstLetter,friendMes); } else { // 不然加入到以 "#" 爲鍵名的鍵值(數組)中 this.solveCharMap(friendsCharMap,'#',friendMes); } }) const friendsList: {[char: string]: UserBasicMes[]} = {}; friendsCharMap.forEach((friendsMes,char)=>{ // 將數組內(同一類別)的好友根據unicode進行排序 this.sortFriends(friendsMes); friendsList[char] = friendsMes; }) return friendsList; } 複製代碼
圖片和截圖在發送前都會被上傳到服務器做爲靜態資源,而後將該靜態資源的url進行持久化以及做爲消息進行發送。
前端經過element ui的文件上傳組件進行文件上傳,在上傳以前,咱們須要確保上傳的文件爲圖片且大小符合預期:
beforeImageUpload(file) { const imagepattern = /image\/(gif|jpeg|jpg|png|svg)/g; const isJPG = imagepattern.test(file.type); const isLt2M = file.size / 1024 / 1024 < 2; if (!isJPG) { showMessage(this,'error','您當前只能上傳圖片哦',0,true) } else if (!isLt2M) { showMessage(this,'error','上傳圖片大小不能超過 2MB!',0,true) } else { this.$store.commit('setImageLoading',true); } } 複製代碼
首先咱們須要設置一個靜態資源服務器,使得咱們能夠經過url的方式來獲取獲得靜態資源
// 將該目錄設置爲靜態資源目錄 app.useStaticAssets(join(__dirname,'../public/','static'), { prefix: '/static/', }); 複製代碼
後端會經過FileInterceptor()裝飾器和@UploadedFile()裝飾器來得到文件對象並交由provider進行處理
@Post('upload') @UseInterceptors(FileInterceptor('file')) async uploadFile(@UploadedFile() file) { return await this.uploadService.getUrl(file); } 複製代碼
獲取到文件對象以後,咱們須要將文件流寫入到靜態資源目錄中,
async saveFile(file): Promise<string> { const datetime: number = Date.now(); const fileName: string = datetime+file.originalname; // 文件的buffer const fileBuffer: Buffer = file.buffer; const filePath: string = join(__dirname,'../../public/','static',fileName); const staticFilePath: string = staticDirPath + fileName; // 建立一個寫入流 const writeFile: WriteStream = createWriteStream(filePath); return await new Promise((resolve,reject)=> { writeFile.write(fileBuffer,(error: Error) => { if(error) { throw new HttpException('文件上傳失敗',HttpStatus.FORBIDDEN); } resolve(staticFilePath); }); }) } 複製代碼
經過瀏覽器全局對象window中是否有全局屬性Notification來判斷當前瀏覽器是否支持Notification
export function supportNotification(): boolean { return ("Notification" in window); } 複製代碼
若是支持,咱們能夠從Notification.permission中獲取當前是否容許受權通知。其有三種可能:
爲了更少程度的打擾用戶,對於選擇denied的用戶咱們不會再此彈出受權通知提示,只有當permission依然爲default時,咱們會經過Notification.requestPermission()來請求向用戶獲取權限
if (Notification.permission === 'defalut') { Notification.requestPermission(function (permission) { // 若是用戶贊成,就能夠向他們發送通知 if (permission === "granted") { var notification = new Notification("Hi there!"); } }); } 複製代碼
在開發版本中,Web截圖功能的實現主要依賴於庫html2canvas和canvas來實現。其主要思路以下:
設置兩個canvas,其中原頁面在最下層,中間一層經過html2canvas將當前頁面經過讀取DOM並應用樣式,從而生成爲canvas圖片,最上層用於截圖效果的實現
const middleCanvas = await html2canvas(document.body); const topCanvas = document.createElement("canvas"); // 設置最上層canvas的寬高爲body的寬高 const {offsetWidth,offsetHeight} = document.body; topCanvas.width = offsetWidth; topCanvas.height = offsetHeight; 複製代碼
實現截圖效果,監聽鼠標的按下,移動,和鬆開
// 鼠標按下事件,獲取到鼠標按下的位置做爲截圖的初始位置 onMousedown(e) { const {clipState} = this.$store.state; if(!clipState) return; const {offsetX,offsetY} = e; this.start = { startX: offsetX, startY: offsetY } } // 鼠標移動事件,用來生成截圖的區域 onMousemove(e) { if(!this.start) return; const {start,clipArea} = this; this.fillClipArea(start.startX,start.startY,e.offsetX-start.startX,e.offsetY-start.startY); } // 鼠標鬆開,截圖結束,將canvas轉化爲圖片進行下一步操做 onMouseup(e) { this.canvasToClipImage(this.bodyCanvas); this.start = null; } 複製代碼
fillClipArea(生成截圖區域)函數的實現
fillClipArea(x,y,w,h) { // 獲取繪製canvas接口對象 const ctx = this.topcanvas.getContext('2d'); if(!ctx) return; ctx.fillStyle = 'rgba(0,0,0,0.6)'; // 設置截圖區域的填充顏色 ctx.strokeStyle="green"; // 設置截圖區域的輪廓 const width = document.body.offsetWidth; const height = document.body.offsetHeight; // 每次移動位置時,都須要擦除以前的繪製內容進行從新繪製(不然沒法獲得想要的效果) ctx.clearRect(0,0,width,height); // 開始建立一條新路徑 ctx.beginPath(); // 建立遮罩層 ctx.globalCompositeOperation = "source-over"; ctx.fillRect(0,0,width,height); //畫框 ctx.globalCompositeOperation = 'destination-out'; ctx.fillRect(x,y,w,h); //描邊 ctx.globalCompositeOperation = "source-over"; // 將路徑的起點移動到(x,y)座標 ctx.moveTo(x,y); // 繪製一個矩形 // lineto方法會使用直線鏈接子路徑的終點到x,y座標的方法(並不會真正地繪製) ctx.lineTo(x+w,y); ctx.lineTo(x+w,y+h); ctx.lineTo(x,y+h); ctx.lineTo(x,y); // 繪製當前已有的路徑 ctx.stroke(); // 從當前點到起始點繪製一條直線路徑,若是圖形已是封閉的或者只有一個點,那麼此方法不會作任何操做。 ctx.closePath(); this.clipArea = { x, y, w, h } } 複製代碼
canvasToClipImage(canvas轉爲截圖)函數的實現
canvasToClipImage(canvas) { if(!canvas) return; // 建立一個新的canvas用於將截到的canvas數據繪製上去 const newCanvas = document.createElement("canvas"); const {x,y,w,h} = this.clipArea; newCanvas.width = w; newCanvas.height = h; // 獲取中間層canvas(也就是經過html2canvas將body繪製成的canvas)的繪製接口對象 const canvasCtx = this.middleCanvas.getContext('2d'); const newCanvasCtx = newCanvas.getContext('2d'); //獲取到截圖區域的圖像數據(值得注意的是:它會得到區域隱含的像素數據,所以,截圖效果並不十分理想) const imageData = canvasCtx.getImageData(0,0,w,h); // 將該圖像數據繪製到新建立的canvas上 newCanvasCtx.putImageData(imageData,0,0); // 將該canvas轉化爲 data URI const dataUrl = newCanvas.toDataURL("image/png"); console.log(dataUrl); //this.downloadImg(dataUrl); } 複製代碼