整体效果
源码在最下面
登录页
注册页
主页
聊天室
联系人页面
用户详情页
项目启动
前端
前端使用vue3 + vite
- 安装依赖
npm install
- 启动项目
npm run dev
后端
后端使用express + routing-controller + sequelize
- 安装依赖
npm install
- 配置数据库
在lib/QQ_DB.sql里有数据库表结构,本地数据库导入创建即可(要先创建一个QQ_DB数据库再导入sql文件,如何导入sql文件可上网搜)。创建好后最好先自己连接查询看看有没有问题(最好使用mysql8.0以上版本)
在这里推荐一个vscode插件,可以很方便的查看数据库,操作数据库和创建数据库
连接服务
连接好后就可以看到这样的UI(数据库部分信息已打码)
当然这是我初始化好之后的
创建好数据库后,在lib/config/index.ts里配置数据库连接参数
/**后端 - 数据库相关配置 */
export const databaseConfig = {
/**数据库host */
host: "localhost",
/**数据库用户 */
user: "xxxxxx",
/**数据库密码 */
pass: "xxxxx",
};
- 配置文件上传路径 在lib/config/index.ts里,将该路径修改为自己想要存储在本机的路径
/**在本机上存文件的路径的基础路径(绝对路径) */
export const uploadPath = "/Users/john/images/";
- 最后启动项目
npm start
注意事项
需要注意的是,本地运行时,如果是在pc端启动后台,同时在手机登录前端进行测试,需要电脑和手机在同一局域网内,可以使用手机开启热点,电脑连接手机热点,同时需要配置:
前端配置
需要先获取当前主机IP (如何查看当前主机IP可以上网搜一下,不同系统的查询方式不一样) .env.development文件需要配置VITE_IP
VITE_ENV=development
VITE_URL=/api
#当前主机IP,默认是localhost,但是如果通过使用电脑连手机热点,在手机访问电脑开启的前端项目,则需要配置为具体当前电脑#IP。如何获取电脑IP可自行上网搜索。例如:192.1.1.1
VITE_IP=localhost
前端在vite.config.ts更改启动IP与代理IP
export default defineConfig({
server: {
host: 'yourIp',//你的主机ip
port: 8888,
// open: true
proxy: {
'/api': {
target: 'http://yourIp:3000',//后台默认在3000端口启动,需要配置代理
changeOrigin: true, // 是否更改请求头中的 Origin
}
}
}
});
后端配置
后端配置app.ts(找到app.listen函数并配置)
app.listen(3000, "yourIp");
h5套壳
新建模板
打开HBuilder-X(没有的需要下载www.dcloud.io/hbuilderx.h… ),项目 -> 新建5+App项目 -> Hello H5+模板
配置manifest.json文件
基础配置
AppId直接获取即可,应用入口地址填项目部署机器的地址,本地启动的项目可以填自己本机IP
模块配置:所有权限都不需要
权限配置:记得把android.permission.READ_CONTACTS权限去掉,不然打包时会报错
源码视图:去掉默认样式
开始打包
右键项目 -> 发行 -> 云打包
总体路由架构
技术细节
由于涉及到技术较多,这里不好全部一一分析,所以我便找了些关键的技术点解释一下,详细的可以看源码。希望各位看官能给点建议~
实时聊天实现
说到实时聊天,大家肯定第一想到的就是WebSocket。但其实里边还有很多细节,比如:对方不在线时,发送的信息该怎么处理,该什么时候初始化WebSocket等。下面来谈谈我的实现(包括前端后端)(以下仅为个人的愚见,各位大佬有啥更好的方法可以指出)
聊天页面里的聊天记录由两大部分组成。一部分是历史聊天记录,在组件初始化时从数据库获取;一部分是实时聊天记录,在接收到消息时,或发出消息时,在前端临时存储,并在组件卸载前一并发送到后端存储到数据库里。这样可以避免在聊天过程中需要频繁的进行数据库的读取操作。
WebSocket通信的封装(包括前端后端)
这里是基于 juejin.cn/post/737136… 这位大佬的文章进行封装的
前端实现
封装WebSocket操作
以下代码均基于 juejin.cn/post/737136… 这位大佬的文章,侵权联系我删
//以下代码均基于 https://juejin.cn/post/7371365854012276747 这位大佬的文章,侵权联系我删
//src/util/WebSocket/index.ts
class Log {
private static console = true;
log(title: string, text: string) {
if (!Log.console) return;
if (import.meta.env.MODE === 'production') return;
const color = '#ff4d4f';
console.log(
`%c ${title} %c ${text} %c`,
`background:${color};border:1px solid ${color}; padding: 1px; border-radius: 2px 0 0 2px; color: #fff;`,
`border:1px solid ${color}; padding: 1px; border-radius: 0 2px 2px 0; color: ${color};`,
'background:transparent'
);
}
closeConsole() {
Log.console = false;
}
}
class EventDispatcher extends Log {
private listeners: { [type: string]: Function[] } = {};
protected addEventListener(type: string, listener: Function) {
if (!this.listeners[type]) {
this.listeners[type] = [];
}
if (this.listeners[type].indexOf(listener) === -1) {
this.listeners[type].push(listener);
}
}
protected removeEventListener(type: string) {
this.listeners[type] = [];
}
protected dispatchEvent(type: string, data: any) {
const listenerArray = this.listeners[type] || [];
if (listenerArray.length === 0) return;
listenerArray.forEach(listener => {
listener.call(this, data);
});
}
}
import type { ChatHistoryType } from "@/views/Dialog/api";
export class WebSocketClient extends EventDispatcher {
/**socket链接 */
private url = "";
/**socket实例 */
private socket: WebSocket | null = null;
/**重连次数 */
private reconnectAttempts = 0;
/**最大重连数 */
private maxReconnectAttempts = 5;
/**重连间隔 */
private reconnectInterval = 10000; // 10 seconds
/**发送心跳数据间隔 */
private heartbeatInterval = 1000 * 30;
/**计时器id */
private heartbeatTimer?: number;
/**彻底终止ws */
private stopWs = false;
// *构造函数
constructor(url: string) {
super();
this.url = url;
}
// >生命周期钩子
onopen(callBack: Function) {
this.addEventListener("open", callBack);
}
onmessage(callBack: Function) {
this.addEventListener("message", callBack);
}
onclose(callBack: Function) {
this.addEventListener("close", callBack);
}
onerror(callBack: Function) {
this.addEventListener("error", callBack);
}
// >消息发送
public send(message: string): void {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(message);
} else {
console.error("[WebSocket] 未连接");
}
}
// !初始化连接
public connect(): void {
if (this.reconnectAttempts === 0) {
this.log("WebSocket", `初始化连接中... ${this.url}`);
}
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
return;
}
this.socket = new WebSocket(this.url);
// !websocket连接成功
this.socket.onopen = (event) => {
this.stopWs = false;
// 重置重连尝试成功连接
this.reconnectAttempts = 0;
// 在连接成功时停止当前的心跳检测并重新启动
this.startHeartbeat();
this.log(
"WebSocket",
`连接成功,等待服务端数据推送[onopen]... ${this.url}`
);
this.dispatchEvent("open", event);
};
this.socket.onmessage = (event) => {
this.dispatchEvent("message", event);
this.startHeartbeat();
};
this.socket.onclose = (event) => {
if (this.reconnectAttempts === 0) {
this.log("WebSocket", `连接断开[onclose]... ${this.url}`);
}
if (!this.stopWs) {
this.handleReconnect();
}
this.dispatchEvent("close", event);
};
this.socket.onerror = (event) => {
if (this.reconnectAttempts === 0) {
this.log("WebSocket", `连接异常[onerror]... ${this.url}`);
}
this.closeHeartbeat();
this.dispatchEvent("error", event);
};
}
// > 断网重连逻辑
private handleReconnect(): void {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
this.log(
"WebSocket",
`尝试重连... (${this.reconnectAttempts}/${this.maxReconnectAttempts}) ${this.url}`
);
setTimeout(() => {
this.connect();
}, this.reconnectInterval);
} else {
this.closeHeartbeat();
this.log("WebSocket", `最大重连失败,终止重连: ${this.url}`);
}
}
// >关闭连接
public close(): void {
if (this.socket) {
this.stopWs = true;
this.socket.close();
this.socket = null;
this.removeEventListener("open");
this.removeEventListener("message");
this.removeEventListener("close");
this.removeEventListener("error");
}
this.closeHeartbeat();
}
// >开始心跳检测 -> 定时发送心跳消息
private startHeartbeat(): void {
if (this.stopWs) return;
if (this.heartbeatTimer) {
this.closeHeartbeat();
}
this.heartbeatTimer = setInterval(() => {
if (this.socket) {
this.socket.send(JSON.stringify({ type: "heartBeat", data: {} }));
this.log("WebSocket", "送心跳数据...");
} else {
console.error("[WebSocket] 未连接");
}
}, this.heartbeatInterval);
}
// >关闭心跳
private closeHeartbeat(): void {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = undefined;
}
}
export interface ChatMsgType {
type: "chat";
data: ChatHistoryType;
}
export interface MsgType {
type: "chat" | "error";
data: any;
}
封装WebSocket全局初始化
//src/util/initWs/index.ts
import { StateKey } from "@/store/useChatStore";
import { jsonParse, showTip } from "..";
import { WebSocketClient, type ChatMsgType, type MsgType } from "../WebSocket";
import { useChatStore } from "@/store/useChatStore";
import pinia from "@/store/store";
import { logout } from "../logout";
const chatStore = useChatStore(pinia);
const { setChatState, setWsInstance } = chatStore;
const getInitWsFn = () => {
/**加锁,保证全局只能初始化一次 */
let isInit = false;
/**websocket实例 */
let ws: WebSocketClient;
return {
/**
* 初始化ws实例和回调
* @param user_name 当前登录的用户名
* @returns
*/
initWs(user_name: string) {
if (isInit) {
return;
}
// 创建websocket实例,同时将当前用户名当作参数传递,方便后端存储各个用户的websocket实例
ws = new WebSocketClient(
`ws://192.168.121.176:3000/mySocketUrl?user_name=${user_name}`
);
/**websocket连接 */
ws.connect();
/**处理接收消息逻辑(以下仅为本项目中处理接收消息的逻辑,各位可以根据自身项目需要进行编写) */
ws.onmessage((e: MessageEvent<string>) => {
const res = jsonParse<MsgType>(e.data);
if (res.type === "chat") {
/**将接收到的聊天消息存储到pinia中,方便全局使用 */
setChatState(res.data.from, res.data, StateKey.CURRENT_CHAT_STATE);
}
});
//将websocket实例存到pinia中,方便在项目的任何地方都可以调用ws.send来发送消息
setWsInstance(ws);
//初始化完成后加锁,避免再次初始化
isInit = true;
},
/** 销毁ws实例*/
destoryWs() {
if (!ws) {
return;
}
ws.close();
isInit = false;
},
};
};
export const { initWs, destoryWs } = getInitWsFn();
使用
在需要初始化的地方直接引入initWs函数调用即可
// 在App.vue中调用初始化
import { initWs } from "./util/initWs";
onMounted(async () => {
if (localStore.getItem("token")) {
console.log("app.vue - 已登录");
initWs(userState.value!.user_name);
}
发送消息,需要从pinia中取出我们存储的ws实例(在initWs函数中会把ws实例存储到pinia库中,当然这个pinia库需要你自己创建并引入)
import { useChatStore } from "@/store/useChatStore";
const chatStore = useChatStore();
const { ws_instance } = chatStore;
ws_instance.send(JSON.stringify(msg));
后端代码
封装后台websocket
import QQ_DB from "../../database/all/qq_db";
import { isHaveChat } from "../../src/controllers/chatHistory.controller";
import { ChatMsgType, MsgType } from "../../types/others";
import { logSpecial } from "../io/log";
import { jsonParse } from "../other";
import WebSocket from "../../node_modules/@types/ws/index";
enum TARGET_USER {
ALL = "all",
}
interface ClientObjType {
/**客户端当前登录用户的qq号(用于区分各个客户端) */
user_name: string;
ws_instance: WebSocket.WebSocket;
}
// 存储所有连接的客户端
export const clients = new Set<ClientObjType>();
/**
* 初始化websocket实例,并记录
* @param ws websocket实例
* @param user_name 客户端当前登录用户的qq号(用于区分各个客户端)
*/
export const initAndAddWs = (ws: WebSocket.WebSocket, user_name: string) => {
const obj = {
user_name: user_name,
ws_instance: ws,
};
clients.add(obj);
// 这里是本项目特定的处理消息逻辑,可以根据自己项目进行修改
ws.on("message", function (msg: any) {
const res = jsonParse<ChatMsgType>(msg);
if (res.type === "chat") {
/**自己发给自己的信息就不发了 */
if (res.data.to === user_name) {
return;
}
//发送消息
sendMsgToTargetUser(res, res.data.to);
}
});
ws.on("close", function (e: any) {
logSpecial("连接关闭", user_name);
clients.delete(obj);
});
};
/**
* 发送消息给指定客户端
* @param message 消息
* @param target_user 目标客户端的用户名
*/
function sendMsgToTargetUser(
message: MsgType,
target_user: string | TARGET_USER
) {
clients.forEach(async (client) => {
if (target_user === TARGET_USER.ALL) {
client.ws_instance.send(JSON.stringify(message));
return;
}
if (
client.user_name === target_user &&
client.ws_instance.readyState === 1
) {
logSpecial(message);
logSpecial("找到你啦");
client.ws_instance.send(JSON.stringify(message));
} else {
// 用户不在线,先把信息存到数据库 且当前信息为聊天信息
if (message.type === "chat") {
//先判断该消息是不是存储过了,存储过了就不存了
if (!(await isHaveChat(message.data))) {
await QQ_DB.add("chat_history", message.data);
}
}
}
});
}
/**
* 判断该用户是否已经连接了
* @param clientObj 当前请求连接的客户端
* @returns
*/
export const judgeClientIsHave = (user_name: string) => {
let flag = false;
logSpecial(clients);
clients.forEach((item) => {
if (item.user_name === user_name) {
flag = true;
}
});
return flag;
};
/**
* 查询该用户是否已经连接,并清除掉已连接的客户端
*/
export const judgeClientIsHaveAndClearClient = (user_name: string) => {
clients.forEach((item) => {
if (item.user_name === user_name) {
sendMsgToTargetUser(
{ type: "error", data: { msg: "你的账号已在别处登录" } },
item.user_name
);
disconnect(item.user_name)
}
});
};
/**
* 断开某个用户的连接
* @param user_name 用户qq号
*/
export const disconnect = (user_name: string) => {
clients.forEach((item) => {
if (item.user_name === user_name) {
item.ws_instance.close();
clients.delete(item);
}
});
};
使用websocket
// 这里使用expressWs库
import expressWs from "express-ws";
import { initAndAddWs } from "./util/WebSocket/initAndAddWs";
const route = express.Router();
const app = express();
expressWs(app);
/**
* route.ws('/url',(ws, req)=>{ })
* 建立WebSocket服务,并指定对应接口url,及相应回调
* ws为实例化的对象,req即为请求
*
* ws.send方法用来向客户端发送信息
* ws.on方法用于监听事件(如监听message事件,或监听close事件)
* */
route.ws("/mySocketUrl", (ws, req) => {
const params = req.query;
logSpecial('连接', params["user_name"])
initAndAddWs(ws, params["user_name"] as string);
});
app.use(route);
app.listen(3000, () => {
console.log(` App is running at http://localhost:3000\n`);
console.log(" Press CTRL-C to stop\n");
});
前端源码(gitee链接):gitee.com/yangqinhang…
前端源码(GitHub链接):github.com/yangqinhang…
后端源码(gitee链接):gitee.com/yangqinhang…
后端源码(GitHub链接):github.com/yangqinhang…
miniQQ移动端:http://8.138.95.105/ (注:此为移动端界面,PC端打开样式会比较奇怪)
miniQQ App端apk包:pan.baidu.com/s/1RUJdwlfW… 提取码:zqhw
特别注意⚠️:移动端和app端仅供体验,请勿用于其他用途。且我会定期清理服务器,若某个文件或图片无法加载了可能就是被我删了