基于Node.js跨平台局域网消息传输与QNAP部署
WisdomEquan Lv2

0x1 引言

0x1.1 目的与背景

当我刚进入大学时,我得到了我的第一台游戏本。当时,我经常需要在手机和电脑之间传输文件和文档。最初,我使用QQ和手机热点来进行传输,这显得相当繁琐。后来,我尝试了蓝牙传输,但速度过慢。尽管我使用了小米的热点文件传输功能,但仅为了传输简单的文本或图片,这样的操作还是过于复杂。

毕业后,我的生活出现了很大的改变。拥有了路由器后,内网传输变得相对简单。但当我获得了iPhone和MacBook,我发现跨操作系统传输文件并不简单。例如,从iPhone向Windows传输文本或链接时,我还需依赖QQ等软件。尝试使用Windows的SMB共享服务和MacBook的文件共享都有其局限性和问题。

后来,我转向开源的云盘解决方案,但传输速度的限制使我非常不满意。直到我获得了QNAP,大部分的文件传输问题得到了解决。然而,在局域网下保持Windows和MacBook数据的一致性,尤其是文本数据,依然是一个问题。显然,依赖QQ这样的方式效率低下。

因此,创建一个简单的网页成为了一种必要,让任何设备仅通过输入一个地址就能发送消息,避免了下载和设置各种软件的麻烦。

0x1.2 目标受众

  1. 大学生和职场新人:他们经常在不同的设备之间传输文件和文档,尤其是在手机和电脑之间,需要一个高效的解决方案。

  2. 跨操作系统用户:特别是那些使用iPhone和Windows或MacBook的人,他们经常面临操作系统之间的传输难题。

  3. 网络技术爱好者:对于那些寻求局域网下文件和数据传输解决方案的人,他们可能已经尝试了各种方法,但仍然在寻找更加高效的方法。

  4. 对效率有追求的人:不想依赖于多个应用程序和软件,而是寻求一个集中的、简化的解决方案来处理日常的文件和数据传输任务。

  5. 云存储和网络存储用户:特别是那些使用开源云盘或QNAP这样的解决方案的人,他们在寻找更快、更直接的传输方法。

0x2 必要的理论知识解释

0x2.1 基础概念

  1. 文件传输

    • 定义:文件传输指的是从一个设备、系统或者位置将文件或数据移动到另一个设备、系统或位置的过程。
    • 应用:例如,通过USB、网络或云服务将文档从电脑传输到手机。
  2. 操作系统差异

    • 定义:不同的操作系统(如Windows、MacOS、Linux等)在架构、功能和界面上有所不同。
    • 应用:某些软件可能只在特定操作系统上运行,或在不同系统上有不同的表现。
  3. 局域网(LAN)原理

    • 定义:局域网是一个限定在较小地理范围内(如家庭、办公室)的计算机网络。
    • 应用:局域网使得同一位置的多台设备可以共享资源,如打印机或文件。
  4. 蓝牙和热点传输

    • 定义:蓝牙是一种无线技术标准,用于短距离数据交换,而热点传输则是通过Wi-Fi将文件从一个设备分享到另一个设备。
    • 应用:例如,用蓝牙耳机听音乐或使用手机热点给电脑上网。
  5. 云存储与网络存储

    • 定义:云存储是通过互联网存储数据在远程服务器上的服务;网络存储则是在局域网内提供集中化的数据存储。
    • 应用:如,使用Google Drive或Dropbox来存储文件或使用NAS来在家庭网络中共享文件。
  6. 跨平台兼容性

    • 定义:指的是软件或应用在多个操作系统或设备上的运行能力。
    • 应用:例如,一个应用既可以在Android上运行,也可以在iOS上运行。
  7. Web技术基础

    • 定义:关于如何构建和呈现互联网内容的技术和原理。
    • 应用:例如,使用HTML、CSS和JavaScript来构建网页。
  8. Node.js

    • 定义:是一个允许在服务器上运行JavaScript的运行时环境。
    • 应用:例如,构建后端API或Web应用。
  9. HTML

    • 定义:是用来描述网页结构的标记语言。
    • 应用:例如,定义网页中的标题、段落和链接。
  10. CSS

  • 定义:是用于描述网页外观和格式的语言。
  • 应用:例如,设置字体、颜色和布局。
  1. Socket
  • 定义:是计算机之间进行通信的端点。
  • 应用:例如,实时聊天应用。
  1. Server
  • 定义:是为其他计算机或应用提供资源、服务或数据的系统或应用。
  • 应用:例如,Web服务器存储和提供网页内容。
  1. Docker
  • 定义:是一个平台,用于创建、运行和管理容器化的应用。
  • 应用:例如,为应用提供一致的运行环境。
  1. IP地址
  • 定义:是分配给联网设备的数字地址,用于识别和定位。
  • 应用:例如,访问特定的网站或远程服务器。
  1. User Agent
  • 定义:是描述浏览器或其他客户端如何与服务器通信的字符串。
  • 应用:例如,网站可以识别用户的设备和浏览器,并据此提供优化的内容。

0x2.2 相关技术背景

随着技术的日益发展,文件传输和数据共享已经成为现代工作和生活中不可或缺的部分。无论是学生、企业家还是日常用户,我们都经常面临跨设备、跨操作系统的数据传输挑战。

  1. 文件传输:这是一种基本的需求,涉及将文件从一个设备传送到另一个设备。尽管有许多可用的方法,例如USB、蓝牙和热点传输,但它们都有自己的局限性和速度问题。

  2. 操作系统差异:由于Windows、MacOS和其他操作系统的内在差异,许多应用和服务在跨操作系统通信时面临挑战。

  3. 局域网原理:局域网为设备提供了一种在有限的地理范围内共享资源的方式。例如,家庭或办公室内的设备可以通过局域网共享打印机或文件。

  4. 蓝牙与热点传输:尽管这两种技术都提供了一种无线传输方式,但它们通常受限于传输距离和速度。

  5. 云存储与网络存储:为了解决大文件传输的问题,许多用户转向了云存储或网络存储解决方案,如Google Drive或NAS。

  6. 跨平台兼容性:这是当今技术世界中的一大挑战。由于多样性的增加,需要开发可以在多个平台上运行的应用。

  7. Web技术基础:HTML、CSS和JavaScript等技术为创建跨平台解决方案提供了基础,允许用户通过浏览器访问应用和服务。

  8. 服务器和后端技术:Node.js提供了一个强大的平台,用于构建后端服务和API,而Socket技术使实时通信成为可能。这些技术经常被用于创建动态、实时的Web应用。

  9. 容器化技术:Docker为开发者提供了一种方式,将其应用及其所有依赖性封装在一个容器中,确保其在任何环境中的一致性和可移植性。

  10. 网络识别:IP地址和User Agent允许设备和网络服务识别请求的来源,从而为用户提供个性化的体验和内容。

0x3 Node.js原型开发

0x3.1 环境配置

初始化项目

1
2
mkdir realtime-text
cd realtime-text

安装Node.js,最好选择LTS版本,在项目根目录执行

1
2
3
npm init -y
npm install express socket.io
npm install multer

0x3.2 基本功能实现

创建服务器 (server.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = socketIo(server);

app.use('/socket.io', express.static('node_modules/socket.io/client-dist/'));

app.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html');
});

io.on('connection', (socket) => {
console.log('a user connected');

socket.on('send text', (text) => {
io.emit('receive text', text);
});

socket.on('disconnect', () => {
console.log('user disconnected');
});
});

server.listen(3000, () => {
console.log('listening on *:3000');
});

创建前端页面 (index.html)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!doctype html>
<html>
<head>
<title>Realtime Text</title>
</head>
<body>
<textarea id="textArea" rows="10" cols="50"></textarea>
<button id="sendButton">Send</button>
<hr>
<h3>Received Text:</h3>
<div id="receivedText"></div>

<script src="/socket.io/socket.io.js"></script>
<script>
var socket = io();

document.getElementById('sendButton').onclick = function() {
var text = document.getElementById('textArea').value;
socket.emit('send text', text);
};

socket.on('receive text', function(text) {
document.getElementById('receivedText').innerText = text;
});
</script>
</body>
</html>

启动服务器

本地服务器默认地址:http://localhost:3000/

1
node server.js

创建启动服务器脚本 (run.bat)

1
2
node server.js
pause

0x4 按需复制,无需深究学习

不想学习思路直接运行代码,按照以下步骤操作,如果需要上传NAS请继续阅读 0xA Docker封装与测试

创建对应的目录结构,并把index.htmlserver.js这两个的代码复制到你的网站根目录下

下载JetBrains Mono字体,解压JetBrainsMono-Regular.ttfJetBrainsMono-Regular.woff2到网站根目录

现在你的目录结构应该是这样的

1
2
3
4
5
6
7
8
 D:\realtime_text 的目录
2023/08/27 04:51 11,332 index.html
2023/01/14 23:20 273,900 JetBrainsMono-Regular.ttf
2023/01/14 23:20 92,164 JetBrainsMono-Regular.woff2
2023/08/27 01:47 <DIR> node_modules
2023/08/27 01:12 52 run.bat
2023/08/27 01:49 1,942 server.js
2023/08/27 13:27 <DIR> uploads

0x4.1 index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
<!doctype html>
<html>
<head>
<title>Realtime Text</title>
<style>
[data-theme="dark"] input, [data-theme="dark"] textarea, [data-theme="dark"] button {
color: white; /* 将文字颜色设置为白色 */
border-color: white; /* 如果需要, 也可以设置边框颜色 */
}

:root {
/* 浅色模式的设置 */
--text-color: black;
--background-color: white;
--input-bg: #fff;
--container-bg: #fff;
--header-bg: #fff;
--button-color: black; /* 新增 */
}

[data-theme="dark"] {
/* 深色模式的设置 */
--text-color: black; /* 文本颜色为黑色 */
--background-color: white; /* 背景颜色为白色 */
}

.bottom-controls {
display: flex;
justify-content: center;
}
.bottom-controls > div {
display: flex; /* 这确保了内部容器的子元素水平对齐 */
align-items: center;
}
/* 为固定底部工具栏添加填充 */
div[style*="position: fixed; bottom: 0;"] {
padding-left: 10px;
padding-right: 10px;
}
/* 保证body的背景颜色被应用 */
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: var(--background-color) !important;
color: var(--text-color);
font-family: "JetBrainsMono", Arial, sans-serif;
}
.container {
border: 1px solid #ddd;
background-color: #fff;
}
/* 小屏幕的样式(如iphone) */
@media (max-width: 500px) {
.bottom-controls {
justify-content: flex-start; /* 这将使内部容器与起始位置对齐 */
}
.bottom-controls > div {
flex-direction: column; /* 这确保了内部容器的子容器是垂直堆叠的 */
align-items: center;
width: 100%;
}

/* 更新底部控件的容器样式 */
div[style*="position: fixed; bottom: 0;"] {
background-color: var(--container-bg);

padding-left: 10px;
padding-right: 10px;
display: flex;
flex-direction: column; /* 确保控件垂直堆叠 */
}

/* 确保所有控件拉伸以填充可用宽度 */
#deviceName, #textArea, #imageUpload, #sendButton {
background-color: var(--container-bg);

width: 100%;
box-sizing: border-box; /* 确保填充和边框包含在宽度中 */
margin-bottom: 5px; /* 添加控件之间的间距 */
}

/* 增加高度,便于触摸 */
#deviceName, #textArea, #sendButton {
height: 25px;
}
/* 在邮件列表的底部添加填充 */
#messages {
padding-bottom: 70px; /* 根据底部工具栏的高度调整此值 */
}
#deleteButton{
width: 100%;
height: 25px;
}
.container{
max-height: 480px;
}
#themeToggle{
width: 100%;
height: 25px;
margin-bottom: 5px;
}
}
/* 大屏幕的样式(例如,Windows, MacBook) */
@media (min-width: 501px) {
#deviceName, #textArea, #imageUpload, #sendButton {
background-color: var(--input-bg);

height: 60px;
font-size: 18px;
padding: 10px;
margin-bottom: 10px;
}
#textArea {
width: 50%;
}
#sendButton {
padding: 10px 20px; /* 调整按钮的填充 */
margin-left: 10px; /* 在发送按钮的左侧添加一些边距,以保持间距 */
margin-right: 5px;
width: 100px;
}
#deleteButton{
margin-left: 5px;
width: 150px;
height: 50px;
}
#themeToggle{
margin-right: 10px;
width: 100px;
height: 50px;
}
}
body, button {
background-color: var(--bg-color);
color: var(--text-color);
}
/* 为了确保按钮的文本颜色能够在两种主题下都清晰可见 */
button {
color: var(--button-color);
}
/* 底部控件的文本颜色 */
.bottom-controls input,
.bottom-controls textarea,
.bottom-controls button {
color: var(--text-color);
border-color: var(--text-color);
}
.container {
background-color: var(--container-bg);
overflow-y: auto;
min-width: 350px;
position: sticky;
margin-bottom: 100px;
}
body.dark-theme {
/* 深色模式的设置 */
--text-color: white;
--background-color: #555;
--input-bg: #555;
--container-bg: #444;
--header-bg: #444;
--button-color: white; /* 新增 */
}
#header {
background-color: var(--header-bg);
}
@font-face {
font-family: "JetBrainsMono"; /* 可以为字体起一个名字 */
src: url('JetBrainsMono-Regular.woff2') format('woff2'), /* 最优先 */
url('JetBrainsMono-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}

</style>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
</head>
<body>

<div class="container">
<div id="header" style="background-color: var(--container-bg);">
<ul id="messages" style="max-height: 70vh"></ul>
</div>
</div>

<!-- 调整底部工具栏结构 -->
<div class="bottom-controls" style="position: fixed; bottom: 0; width: 100%; padding: 5px; box-shadow: 0 -2px 5px rgba(0,0,0,0.1); background-color: var(--container-bg);">
<div style="display: flex; align-items: center;">
<button id="themeToggle">切换主题</button>
<input type="text" id="deviceName" placeholder="Device Name">
<input type="file" id="imageUpload" accept="image/*">
<textarea id="textArea" placeholder="写点什么?!"></textarea>
<button id="sendButton">发送</button>
<button id="deleteButton">删除选中消息</button>
</div>
</div>

<script src="/socket.io/socket.io.js"></script>
<script>
window.addEventListener('load', function() {
setTimeout(function() {
window.scrollTo(0, 1);
}, 0);

// 请求历史消息
fetch('/messages')
.then(response => response.json())
.then(data => {
const messagesDiv = document.getElementById('messages');
data.messages.forEach(message => {
const p = document.createElement('p');
p.textContent = message;
messagesDiv.appendChild(p);
});
})
.catch(error => {
console.error('获取消息错误:', error);
});
});


document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight;


var socket = io();

document.getElementById('sendButton').onclick = function() {
var text = document.getElementById('textArea').value;
var deviceName = document.getElementById('deviceName').value || "匿名设备";
var imageFile = document.getElementById('imageUpload').files[0];
var listCheckBox = document.getElementById('listCheckBox');

if (imageFile) {
var formData = new FormData();
formData.append('image', imageFile);
formData.append('deviceName', deviceName);
formData.append('text', text);
formData.append('listCheckBox', listCheckBox);

fetch('/upload-image', {
method: 'POST',
body: formData
}).then(response => response.json()).then(data => {
socket.emit('send text', data);
});
} else {
socket.emit('send text', {text, deviceName });
}
// 在所有情况下都清除textArea和图片选择器的值
document.getElementById('textArea').value = '';
document.getElementById('imageUpload').value = ''; // 这将清除文件输入选择器的内容
};

var messageId = 0; // 初始化一个全局变量
socket.on('receive text', function(data) {
var li = document.createElement("li");

// 创建复选框
var checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "messageCheckbox";
li.appendChild(checkbox); // 将复选框添加到列表项中

var time = data.timestamp || new Date().toLocaleString(); // 获取时间戳,如果没有则使用当前时间

var content = `[${data.deviceName} @ ${time}]: `; // 显示设备名和时间
if (data.text.startsWith("```") && data.text.endsWith("```")) {
// 如果文本以 "```" 开头和结尾,将其识别为代码
content += `<pre>${data.text.slice(3, -3)}</pre>`;
} else {
content += `${data.text}`;
}

if (data.imageUrl) {
content += `<br><img src="${data.imageUrl}" alt="Uploaded image" style="max-width: 300px;">`;
}

li.innerHTML += content;
document.getElementById('messages').appendChild(li);

li.setAttribute('data-message-id', messageId); // 为每个消息设置唯一ID
messageId++;

console.log("设备名称和IP地址:", data.deviceName);
});

// 发送HTTP DELETE请求到后端来删除消息
document.getElementById('deleteButton').onclick = function() {
var selectedMessages = document.querySelectorAll('.messageCheckbox:checked');
var messageIdsToDelete = [];
selectedMessages.forEach(function(checkbox) {
var li = checkbox.closest('li');
messageIdsToDelete.push(li.getAttribute('data-message-id'));
li.remove();
});
socket.emit('delete messages', messageIdsToDelete);

// 向后端发送删除请求
messageIdsToDelete.forEach(id => {
fetch(`/message/${id}`, {
method: 'DELETE'
}).then(response => {
if (!response.ok) {
// 以某种方式处理错误响应
console.error('从服务器删除消息失败');
}
});
});
};


socket.on('delete messages', function(messageIds) {
messageIds.forEach(function(messageId) {
var li = document.querySelector('li[data-message-id="' + messageId + '"]');
if (li) li.remove();
});
io.emit('delete messages', messageIds); // 向所有客户端广播消息ID,以供删除
});



document.addEventListener('click', function(event) {
// 如果消息中的图像被点击
if (event.target.tagName === 'IMG' && event.target.closest('#messages')) {
var modal = document.getElementById('imageModal');
var modalImage = document.getElementById('modalImage');

modalImage.src = event.target.src;
modal.style.display = 'flex';

modal.addEventListener('click', function() {
modal.style.display = 'none';
});
}
});

// 按下'Escape'关闭模式
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
var modal = document.getElementById('imageModal');
modal.style.display = 'none';
}
});

function getDeviceNameFromUserAgent() {
var userAgent = navigator.userAgent || navigator.vendor || window.opera;

// Windows Phone必须先行一步,因为它的用户界面也包含“Android”。
if (/windows phone/i.test(userAgent)) {
return "Windows Phone";
}

if (/android/i.test(userAgent)) {
return "Android Device";
}

// iOS检测
if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) {
return "iOS Device";
}

if (/macintosh|mac os x/i.test(userAgent)) {
return "Mac";
}

if (/windows/i.test(userAgent)) {
return "Windows PC";
}

return "匿名设备";
}

// 使用函数来自动填充设备名称
document.getElementById('deviceName').value = getDeviceNameFromUserAgent();

// 切换主题
document.getElementById('themeToggle').addEventListener('click', function() {
document.body.classList.toggle('dark-theme');
});


socket.on('load history', function(history) {
history.forEach(data => {
var li = document.createElement("li");
var content = `[${data.deviceName}]: ${data.text}`;
if (data.imageUrl) {
content += `<br><img src="${data.imageUrl}" alt="Uploaded image" style="max-width: 300px;">`;
}
li.innerHTML = content;
document.getElementById('messages').appendChild(li);
});
});

</script>

<!-- 图像模块 -->
<div id="imageModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); z-index: 9999; align-items: center; justify-content: center;">
<img id="modalImage" src="" style="max-width: 90%; max-height: 90%;">
</div>

</body>
</html>

0x4.2 server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
const multer = require('multer');
const path = require('path');
const sqlite3 = require('sqlite3').verbose();

const app = express();
const server = http.createServer(app);
const io = socketIo(server);

const messageHistory = [];


// 创建或打开数据库
const db = new sqlite3.Database('./messages.db');

// 创建message表,如果存在则什么都不做
// 包含id、content、deviceName、 ipAddress、 timestamp字段
db.run("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT, deviceName TEXT, ipAddress TEXT, timestamp TEXT)", (err) => {
if (err) {
console.error("创建/检查表错误:", err);
return;
}
console.log("表检查/创建成功");
});



// 在服务器启动时从数据库加载消息历史记录
db.all("SELECT id, content, deviceName, ipAddress, timestamp FROM messages", [], (err, rows) => {
if (err) {
console.error("从数据库获取历史消息出错:", err);
return;
}
// 遍历数据库每条记录
rows.forEach(row => {
const message = {
id: row.id, // 确保这里保留 id
text: row.content,
deviceName: row.deviceName,
ipAddress: row.ipAddress,
timestamp: row.timestamp
};
// 将数据库中的数据保存在 messageHistory
messageHistory.push(message);
});
console.log("从数据库加载历史消息");
});


app.use(express.json());

app.post('/send', (req, res) => {
const messageContent = req.body.message;

if (messageContent) {
// 存储消息到数据库
db.run("INSERT INTO messages (content, deviceName, ipAddress, timestamp) VALUES (?, ?, ?, ?)", [data.text, data.deviceName, clientIpAddress, data.timestamp],
(err) => {
if (err) {
console.error("插入数据库出错:", err);
return;
}
console.log('收到的信息:', data.text || messageContent);
});
} else {
res.status(400).send({error: "消息内容为空"});
}
});


app.use('/socket.io', express.static('node_modules/socket.io/client-dist/'));

app.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html');
});

io.on('connection', (socket) => {
console.log('有一个用户连接');

function formatMessageForEmission(originalMessage) {
return {
...originalMessage,
deviceName: `${originalMessage.deviceName} (${originalMessage.ipAddress})`
};

}

// 发送历史消息给新连接的用户
messageHistory.forEach((message) => {
socket.emit('receive text', formatMessageForEmission(message));
// console.log("format的设备和IP地址:",message)
});




socket.on('send text', (data) => {
// 获取客户端的IP地址
let clientIpAddress = socket.request.connection.remoteAddress.replace(/^::ffff:/, ''); // 清除IPv6前缀

if (clientIpAddress === '::1' || clientIpAddress === '127.0.0.1') {
clientIpAddress = 'Localhost';
}

// 添加ipAddress字段到data对象
data.ipAddress = clientIpAddress;

// 默认设备名称
if (!data.deviceName || data.deviceName.trim() === "") {
data.deviceName = "匿名设备";
}

// 添加消息发送时间
data.timestamp = new Date().toLocaleString();

// 添加消息到数据库中
db.run("INSERT INTO messages (content, deviceName, ipAddress, timestamp) VALUES (?, ?, ?, ?)",
[data.text, data.deviceName, clientIpAddress, data.timestamp],
function(err) { // 使用函数关键字以便访问 this.lastID
if (err) {
console.error("插入数据库出错:", err);
return;
}
data.id = this.lastID; // 获取新插入的行的 ID
messageHistory.push(data); // 将新消息添加到历史记录
console.log('通过套接字收到的消息:', data.text);
}
);

io.emit('receive text', formatMessageForEmission(data));
});

socket.on('disconnect', () => {
console.log('有一个用户断开连接');
});
});

// 注意:仅使用一个侦听器,app.listen 或 server.listen
server.listen(3000, () => {
console.log('服务器地址:http://localhost:3000');
});

const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/')
},
filename: (req, file, cb) => {
cb(null, Date.now() + path.extname(file.originalname))
}
});

const upload = multer({ storage: storage });

app.post('/upload-image', upload.single('image'), (req, res) => {
let imageUrl = `/uploads/${req.file.filename}`;
res.json({ text: req.body.text, deviceName: req.body.deviceName, imageUrl: imageUrl });
});

app.use('/uploads', express.static(path.join(__dirname, 'uploads')));

// 静态文件处理
app.use('/css', express.static(path.join(__dirname, 'css')));
app.use('/js', express.static(path.join(__dirname, 'js')));
app.use('/', express.static(path.join(__dirname, 'JetBrainsMono-Regular.ttf')));
app.use('/', express.static(path.join(__dirname, 'JetBrainsMono-Regular.woff2')));

app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});

app.delete('/message/:id', (req, res) => {
const messageId = req.params.id;
if (messageId) {
db.run("DELETE FROM messages WHERE id=?", [messageId], (err) => {
if (err) {
console.error("从数据库删除消息出错:", err);
res.status(500).send({error: "删除消息失败"});
return;
}

// 从 messageHistory 数组中删除消息
const index = messageHistory.findIndex(message => message.id === Number(messageId));
if (index !== -1) {
messageHistory.splice(index, 1);
}

res.send({status: "消息已删除"});
});
} else {
res.status(400).send({error: "无效的消息ID"});
}
});

启动服务器

1
node server.js

0x4.3 直接下载已打包好的

避免重复上传已打包文件参考:局域网文件和消息传输

请确保您具有吾爱破解的账号,且用户组为锋芒初露,本帖的权限设置旨在防止第三方网站未经告知私自获取

要继续学习请往下看

0x5 界面图像优化与适配

0x5.1 消息展示与设备自适应

0x5.1.1 设备信息消息中心

  • 美化布局:使用简单的内联CSS让布局居中
  • 显示多条消息:而不是只覆盖显示一条
  • 显示设备名称/IP:为简化起见,将让用户输入一个设备名称。关于IP地址,我们可以从请求头中获取

更新index.html如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<!doctype html>
<html>
<head>
<title>Realtime Text</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f4f4f4;
}
.container {
padding: 20px;
border: 1px solid #ddd;
background-color: #fff;
}
</style>
</head>
<body>
<div class="container">
<input id="deviceName" placeholder="Enter Device Name" />
<textarea id="textArea" rows="4" cols="50" placeholder="Type your message..."></textarea>
<button id="sendButton">Send</button>
<hr>
<h3>Received Messages:</h3>
<ul id="messages"></ul>
</div>

<script src="/socket.io/socket.io.js"></script>
<script>
var socket = io();

document.getElementById('sendButton').onclick = function() {
var text = document.getElementById('textArea').value;
var deviceName = document.getElementById('deviceName').value || "Unknown";
socket.emit('send text', { text, deviceName });
};

socket.on('receive text', function(data) {
var li = document.createElement("li");
li.innerText = `[${data.deviceName}]: ${data.text}`;
document.getElementById('messages').appendChild(li);
});
</script>
</body>
</html>

server.js中,我们也要做相应的修改以支持新的消息格式并发送设备的IP地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
io.on('connection', (socket) => {
console.log('a user connected');

socket.on('send text', (data) => {
let clientIpAddress = socket.request.headers['x-forwarded-for'] || socket.request.connection.remoteAddress;
data.ipAddress = clientIpAddress;
io.emit('receive text', data);
});

socket.on('disconnect', () => {
console.log('user disconnected');
});
});

这样,当用户发送消息时,它们将以[DeviceName]: Message的格式显示,并在服务器端的console中记录IP地址。

我们可以在server.js中获取连接的客户端的局域网IP地址,并将其与设备名称一同发送到前端。

首先,修改server.js的消息处理部分以包含IP地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
io.on('connection', (socket) => {
console.log('a user connected');

socket.on('send text', (data) => {
// 获取客户端的IP地址,清除IPv6前缀,使其更易读
let clientIpAddress = socket.request.connection.remoteAddress.replace(/^::ffff:/, '');
// 如果从运行服务器的同一台机器上连接,将会显示“Localhost”而不是::1或127.0.0.1
if (clientIpAddress === '::1' || clientIpAddress === '127.0.0.1') {
clientIpAddress = 'Localhost';
}


// 默认设备名称
if (!data.deviceName || data.deviceName.trim() === "") {
data.deviceName = "匿名设备";
}

// 将设备名和IP地址结合在一起
data.deviceName = `${data.deviceName} (${clientIpAddress})`;

io.emit('receive text', data);
});

socket.on('disconnect', () => {
console.log('user disconnected');
});
});

更新index.html如下:

1
2
3
4
5
6
7
var socket = io();

document.getElementById('sendButton').onclick = function() {
var text = document.getElementById('textArea').value;
var deviceName = document.getElementById('deviceName').value || "匿名设备";
socket.emit('send text', { text, deviceName });
};

现在,在发送消息时,如果用户没有输入设备名称,它将显示为“匿名设备”,并且每个消息都会在设备名称后面带有IP地址。

当用户发送消息时,它们将以 [DeviceName (IP Address)]: Message 的格式显示。

0x5.1.2 iPhone页面自适应

现在我希望在iPhone的浏览器中更好的显示,因为我在iPhone中需要先缩小页面,然后在移动到页面中间再放大才能继续输入消息

为了确保页面在移动设备上正常显示,需要添加一个viewport元数据标签来控制页面的缩放。

index.html文件的<head>部分,添加以下meta标签:

1
<meta name="viewport" content="width=device-width, initial-scale=1.0">

所以<head>部分现在应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<head>
<title>Realtime Text</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f4f4f4;
}
.container {
padding: 20px;
border: 1px solid #ddd;
background-color: #fff;
}
</style>
</head>

添加此元数据标签后,页面应该会根据设备的屏幕大小自动调整缩放,并且在iPhone等移动设备上应该可以正常显示。

0x5.2 图像优化与传输

0x5.2.1 图像传输逻辑

  1. 在前端添加一个文件输入以选择图片
  2. 上传图片到服务器
  3. 服务器保存图片并返回一个可访问的URL
  4. 在客户端中显示图片

修改index.html来添加文件输入,在<div class="container">内添加一个文件输入:

1
<input type="file" id="imageUpload" accept="image/*">

并修改发送按钮的逻辑来检查是否选择了图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
document.getElementById('sendButton').onclick = function() {
var text = document.getElementById('textArea').value;
var deviceName = document.getElementById('deviceName').value || "匿名设备";
var imageFile = document.getElementById('imageUpload').files[0];

if (imageFile) {
var formData = new FormData();
formData.append('image', imageFile);
formData.append('deviceName', deviceName);
formData.append('text', text);

fetch('/upload-image', {
method: 'POST',
body: formData
}).then(response => response.json()).then(data => {
socket.emit('send text', data);
});
} else {
socket.emit('send text', { text, deviceName });
}
};

multer在服务器上处理图像上传,它是一个node.js中间件,用于处理multipart/form-data,主要用于上传文件

然后,在server.js中添加以下代码来处理图片上传:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const multer = require('multer');
const path = require('path');

const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/')
},
filename: (req, file, cb) => {
cb(null, Date.now() + path.extname(file.originalname))
}
});

const upload = multer({ storage: storage });

app.post('/upload-image', upload.single('image'), (req, res) => {
let imageUrl = `/uploads/${req.file.filename}`;
res.json({ text: req.body.text, deviceName: req.body.deviceName, imageUrl: imageUrl });
});

app.use('/uploads', express.static(path.join(__dirname, 'uploads')));

确保在服务器目录中创建一个名为uploads的文件夹来存储上传的图片

修改前端显示逻辑,修改socket.on('receive text', ...)逻辑以处理可能的图片消息:

1
2
3
4
5
6
7
8
9
socket.on('receive text', function(data) {
var li = document.createElement("li");
var content = `[${data.deviceName}]: ${data.text}`;
if (data.imageUrl) {
content += `<br><img src="${data.imageUrl}" alt="Uploaded image" style="max-width: 300px;">`;
}
li.innerHTML = content;
document.getElementById('messages').appendChild(li);
});

现在,应该能够在前端选择图片,上传到服务器,并在接收消息时看到图片显示

0x5.2.2 图片显示与优化

现在我发现在发送多条消息时,发送消息的控件显示在屏幕之外时无法再继续输入消息了

这个问题出现的原因是,随着消息的增加,页面内容增长,但浏览器并没有自动滚动以保持输入控件在可见范围内。

为了解决这个问题,我们可以强制页面在添加新消息后滚动到底部。这样,输入框和按钮始终保持在屏幕上的可见范围内

首先更新HTML结构,我们需要对HTML的结构进行一些修改以分离消息列表和输入区域。将输入区域和按钮放入一个固定的底部工具栏中,使其始终位于屏幕底部。

index.html<div class="container">应该更改为以下结构:

1
2
3
4
5
6
7
8
9
10
<div class="container">
<ul id="messages" style="max-height: 80vh; overflow-y: auto;"></ul>

<div style="position: fixed; bottom: 0; width: 100%; background-color: white; padding: 10px;">
<input type="text" id="deviceName" placeholder="Device Name">
<textarea id="textArea" placeholder="Type your message..."></textarea>
<input type="file" id="imageUpload" accept="image/*">
<button id="sendButton">Send</button>
</div>
</div>

上述更改将消息列表的最大高度限制为视口的80%,并为其添加了滚动条。发送消息的工具栏被固定在底部。

添加自动滚动逻辑,在每次添加消息到列表后,执行滚动到底部的逻辑

socket.on('receive text', ...)的末尾添加以下代码:

1
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight;

这段代码会将消息列表滚动到最底部,确保新消息总是可见的。

这些更改应该确保无论发送多少消息,输入框和按钮始终保持在屏幕上的可见范围内。

消息多发导致标签被遮挡

我们需要对HTML和CSS进行调整以确保”Received Messages:”标签始终可见。

以下是如何修改的,修改HTML结构

index.html中,将”Received Messages:”标签从当前的位置移到工具栏上方,并为其添加一个包围元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div class="container">
<div id="header" style="background-color: white; padding: 10px;">
Received Messages:
</div>

<ul id="messages" style="max-height: 70vh; overflow-y: auto;"></ul>

<div style="position: fixed; bottom: 0; width: 100%; background-color: white; padding: 10px;">
<input type="text" id="deviceName" placeholder="Device Name">
<textarea id="textArea" placeholder="Type your message..."></textarea>
<input type="file" id="imageUpload" accept="image/*">
<button id="sendButton">Send</button>
</div>
</div>

调整CSS样式,修改<ul id="messages">的样式来考虑标题的高度。确保消息列表在标题和工具栏之间:

1
2
3
4
5
#messages {
margin-top: 30px;
max-height: 70vh;
overflow-y: auto;
}

这些更改应该确保”Received Messages:”标签不会被挡住,并且在添加消息时,列表仍然会正确滚动。

0x5.3 iPhone界面与交互优化

0x5.3.1 点击图片放大原尺寸

为了实现图片点击放大的效果,可以使用一个简单的模态窗口(modal)。当用户点击图片时,模态窗口会显示,并且在其中展示原始尺寸的图片。当用户点击模态窗口之外的任何位置或按下Esc键时,模态窗口会关闭。

index.html<body> 末尾添加模态窗口的HTML结构:

1
2
3
4
<!-- Image Modal -->
<div id="imageModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); z-index: 9999; align-items: center; justify-content: center;">
<img id="modalImage" src="" style="max-width: 90%; max-height: 90%;">
</div>

添加显示和关闭模态窗口的逻辑,在现有的JavaScript代码下添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
document.addEventListener('click', function(event) {
// If an image inside the messages is clicked
if (event.target.tagName === 'IMG' && event.target.closest('#messages')) {
var modal = document.getElementById('imageModal');
var modalImage = document.getElementById('modalImage');

modalImage.src = event.target.src;
modal.style.display = 'flex';

modal.addEventListener('click', function() {
modal.style.display = 'none';
});
}
});

// Close modal on pressing 'Escape'
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
var modal = document.getElementById('imageModal');
modal.style.display = 'none';
}
});

这些代码首先会检查是否点击了消息列表中的图片。如果是,则显示模态窗口并设置图片的源。点击模态窗口的任何位置都会关闭它。如果按下’Escape’键,也会关闭模态窗口。

现在,当点击图片时,应该能够在模态窗口中放大显示图片,而点击模态窗口的任何位置或按下Esc键都会关闭它。

0x5.3.2 iPhone浏览器send按钮超屏

这个问题可能是由于固定底部工具栏的内容太多或样式设置不合适,在某些屏幕尺寸或设备上导致了溢出。为了解决这个问题,我们需要对工具栏的布局和样式进行调整,确保在较小的屏幕上也能适应。

我们可以采用以下策略:

  1. 将输入框和发送按钮放在一行。
  2. 调整宽度和样式以适应不同的屏幕尺寸。
  3. 使用flex布局简化样式和布局。

更新HTML结构,在index.html中,将工具栏部分修改为:

1
2
3
4
5
6
7
8
<div style="position: fixed; bottom: 0; width: 100%; background-color: white; padding: 5px; box-shadow: 0 -2px 5px rgba(0,0,0,0.1);">
<div style="display: flex; align-items: center;">
<input type="text" id="deviceName" placeholder="Device Name" style="flex: 1; margin-right: 5px;">
<input type="file" id="imageUpload" accept="image/*" style="margin-right: 5px;">
<textarea id="textArea" placeholder="Type your message..." style="flex: 2; margin-right: 5px;"></textarea>
<button id="sendButton">Send</button>
</div>
</div>

调整样式,确保每个元素都有合适的宽度和间距,使其适应小屏幕。这里,我们使用了flex布局,让deviceNametextArea元素根据剩余空间动态调整其宽度。

现在,工具栏应该能够更好地适应较小的屏幕,如iPhone的浏览器,并且发送按钮应该始终可见和可用。可能需要根据实际设备和屏幕尺寸进行进一步的微调。

0x5.3.3 iPhone输入框超出屏幕

为了在iPhone的浏览器中获得更好的响应式布局,我们可以采用以下策略:

  1. 使用媒体查询来针对不同的屏幕尺寸进行样式调整。
  2. 在较小的屏幕上,考虑简化布局,可能需要将一些控件移至其他位置。

更新HTML结构,在index.html中,继续使用上一个给出的工具栏HTML结构。

添加CSS媒体查询,我们将为小屏幕(如iPhone)添加特定的样式。为此,在的CSS中添加以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* 大屏幕的默认样式 */
#deviceName, #textArea {
flex: 1;
margin-right: 5px;
}

/* 小屏幕的样式(如iphone) */
@media (max-width: 500px) {
#deviceName {
display: none; /* 在小屏幕上隐藏设备名称输入 */
}

#imageUpload {
width: 40px; /* 根据需要调整此值 */
margin-right: 5px;
}

#textArea {
flex: 3; /* 给文本区域更多的空间 */
}

#sendButton {
flex-shrink: 0; /* 防止按钮收缩 */
}
}

这里的做法是,当屏幕宽度小于或等于500px时(可以根据需要调整此值),隐藏了deviceName输入框以节省空间,并给予textArea更多的空间。此外,还调整了上传按钮和发送按钮的尺寸和样式,以更好地适应小屏幕。

这些调整应该会改进iPhone浏览器上的布局,使其更加友好,可能需要根据实际设备和屏幕尺寸进行进一步的微调

0x5.3.4 iPhone按钮输入框重叠修正

为了实现这个目标,我们需要对较小屏幕的布局进行改进,使其变为多行排列。

更新HTML结构,在index.html中,继续使用上一个给出的工具栏HTML结构。

调整CSS媒体查询以实现多行排列布局,我们将在小屏幕上采用多行排列布局。添加或修改以下CSS代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/* 大屏幕的默认样式 */
#deviceName, #textArea {
flex: 1;
margin-right: 5px;
}

/* 小屏幕的样式(如iphone) */
@media (max-width: 500px) {
/* 对于较小的屏幕,将伸缩方向更改为列 */
div[style*="position: fixed; bottom: 0;"] > div {
flex-direction: column;
}

#deviceName {
margin-bottom: 5px;
}

#imageUpload {
margin-right: 0; /* 重置margin-right */
margin-bottom: 5px;
width: 100%;
}

#textArea {
margin-right: 0; /* 重置margin-right */
margin-bottom: 5px;
flex: 3;
}

#sendButton {
width: 100%;
margin-bottom: 5px; /* 留出一点间距 */
}
}

这样的改变会使得在小屏幕上,设备名称、图片上传、文本输入框和发送按钮都单独占据一行,而不会相互重叠。这应该会在iPhone上提供更好的用户体验

0x5.3.5 iPhone布局对齐与点击优化

更新媒体查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/* 小屏幕的样式(如iphone) */
@media (max-width: 500px) {
/* 对于较小的屏幕,将伸缩方向更改为列 */
div[style*="position: fixed; bottom: 0;"] > div {
flex-direction: column;
}

#deviceName, #textArea {
margin-right: 0; /* 重置margin-right */
margin-bottom: 5px;
width: 100%; /* 全宽 */
height: 50px; /* 增加高度,便于触摸 */
}

#imageUpload {
margin-right: 0; /* 重置margin-right */
margin-bottom: 5px;
width: 100%; /* 全宽 */
}

#sendButton {
width: 100%; /* 全宽 */
height: 50px; /* 增加高度,便于触摸 */
margin-bottom: 5px; /* 留出一点间距 */
}
}

这些调整会使得在小屏幕上,每一个元素都占满了整个屏幕宽度,并且增大了输入框和按钮的高度,以便用户更容易地进行触摸操作。

这应该会提供更好的用户体验,特别是考虑到iPhone和其他移动设备上的拇指操作。可能还需要根据实际设备和需求进行进一步的微调。

0x5.3.6 控件边缘重叠与iPhone点击

  1. 控件与屏幕边缘的间距问题
  2. iPhone上首次加载时的交互问题

设置控件与屏幕边缘的间距

为了增加边距,可以为工具栏内部的元素添加适当的间距。在的CSS中,可以添加或修改以下样式:

1
2
3
4
5
6
7
/* 为固定底部工具栏添加填充 */
div[style*="position: fixed; bottom: 0;"] {
padding-left: 10px;
padding-right: 10px;
}

/* ... (CSS的其余部分) */

这会为底部工具栏的左右两侧增加10px的间距。可以根据需要调整这个值

iPhone上首次加载时的交互问题

iPhone上的Safari浏览器和其他移动浏览器有一个常见的问题,即当页面首次加载时,固定的元素可能不会响应触摸事件。这是因为浏览器的地址栏和底部工具栏可能会覆盖页面的部分内容。通常,通过滚动页面可以暂时解决这个问题。

为了解决这个问题,可以尝试在页面首次加载时自动滚动页面一小段距离,这样可以确保底部工具栏不会被浏览器的界面元素覆盖。以下是如何实现:

index.html<script>标签内部,添加以下JavaScript代码:

1
2
3
4
5
window.addEventListener('load', function() {
setTimeout(function() {
window.scrollTo(0, 1);
}, 0);
});

上述代码会在页面加载完成后,立即将页面向上滚动1px。这样可以确保底部的固定工具栏不会被浏览器的界面元素覆盖,并且应该可以响应用户的触摸事件

0x5.3.7 iPhone上两侧间距问题

对于在iPhone上的边距问题,我们需要更具体地处理与小屏幕或特定设备相关的CSS样式。

下面是如何更具体地针对较小屏幕(如iPhone)设置边距:

首先,确保已经使用了适当的元数据标签来控制视口。在的HTML文件的<head>部分,添加以下标签:

1
<meta name="viewport" content="width=device-width, initial-scale=1.0">

这会确保页面的宽度匹配设备的宽度,并且页面的初始缩放级别是1.0。

接下来,修改的CSS媒体查询以增加边距。将下面的代码添加或合并到的CSS中:

1
2
3
4
5
6
7
8
9
10
11
12
/* 大屏幕的默认样式 */
/* ... (你现在的样式) */

/* 小屏幕的样式(如iphone) */
@media (max-width: 500px) {
/* ... (你当前的小屏幕风格) */

/* 为整个底部工具栏添加填充 */
div[style*="position: fixed; bottom: 0;"] {
padding: 0 10px; /* 这将在左右两边添加10px的内边距 */
}
}

通过添加这些更改,iPhone上的边距应该会生效,并且页面内容会根据设备的屏幕宽度适当地调整。

0x5.3.8 iPhone输入框宽度异常

这个问题可能是由于paddingwidth: 100%的组合引起的。当我们为一个元素同时设置了paddingwidth: 100%时,该元素的总宽度会超过其父元素的宽度,因为这里的宽度是指内容宽度,而不包括边距、边框或内边距。

为了解决这个问题,可以使用box-sizing属性,将其设置为border-box,这样元素的总宽度将包括内容、内边距和边框(但不包括外边距)。

  1. 保证边距。
  2. 使用flexbox来确保子元素在小屏幕上垂直排列。

在CSS中,找到与小屏幕相关的媒体查询,并进行如下修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@media (max-width: 500px) {
div[style*="position: fixed; bottom: 0;"] {
width: 100%;
padding: 5px;
box-sizing: border-box;
}

#deviceName, #textArea, #imageUpload, #sendButton {
box-sizing: border-box;
width: 100%;
margin-bottom: 5px;
}

#deviceName, #textArea {
height: 50px;
}

#sendButton {
height: 50px;
}
}

这些样式应该确保了在小屏幕设备上,特别是iPhone上,所有的控件都正常排列,并且有足够的边距。

0x5.3.9 iPhone布局校验与滑动优化

  1. 控件应该按照一行行排列: 这需要调整底部控件的样式。当前的代码中已经使用了flex-direction: column,这是正确的,但的底部控件的子控件仍然在一个flex容器中,这使得它们在同一行上。需要分离它们以使它们在单独的行上。
  2. 两侧与屏幕保持10px的间距: 可以在底部控件中设置padding-leftpadding-right10px
  3. 第一次打开页面需要滚动一小段距离: 已经有了这个功能,使用window.scrollTo(0, 1)在页面加载时向下滚动。这通常用于隐藏浏览器的地址栏,但在最新版本的iOS中可能不起作用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* ... 现有的样式 ... */

@media (max-width: 500px) {
/* ... 其他样式 ... */

div[style*="position: fixed; bottom: 0;"] {
padding-left: 10px;
padding-right: 10px;
display: flex;
flex-direction: column;
}

#deviceName, #textArea, #imageUpload, #sendButton {
width: 100%;
box-sizing: border-box;
margin-bottom: 5px;
}

#deviceName, #textArea, #sendButton {
height: 50px;
}
}
1
2
3
4
5
6
7
8
9
<!-- ... 现有的HTML ... -->

<!-- 调整底部工具栏结构 -->
<div style="position: fixed; bottom: 0; width: 100%; background-color: white; padding: 5px; box-shadow: 0 -2px 5px rgba(0,0,0,0.1);">
<input type="text" id="deviceName" placeholder="Device Name">
<input type="file" id="imageUpload" accept="image/*">
<textarea id="textArea" placeholder="Type your message..."></textarea>
<button id="sendButton">Send</button>
</div>

0x5.3.A iPhone布局遮挡与间距修正

  1. 两侧控件的距离问题: 如果右侧和屏幕之间的距离是10px,但左侧并非如此,可能是某些样式或其他浏览器默认样式干扰了。可以考虑为每个子控件设置margin-leftmargin-right,以确保他们都有正确的边距。
  2. 图片消息被底部的输入框挡住: 一个常见的方法是为消息列表添加底部内边距,该边距的高度与底部工具栏的高度相同,这样最后一条消息(无论是文本还是图片)都可以完全滚动到视图中而不被挡住。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* ... 其他样式 ... */

@media (max-width: 500px) {
/* ... 其他样式 ... */

#deviceName, #textArea, #imageUpload, #sendButton {
margin-left: 10px;
margin-right: 10px;
/* ... 现有的其他样式 ... */
}

#messages {
padding-bottom: 70px;
}
}

0x5.4 消息发送与设备名自动填充

0x5.4.1 发送后清除已选图像

为了在发送完消息后清除上次选择的图像,可以在发送消息的函数中清除 <input type="file"> 的值。这将重置文件输入,并确保下次用户点击发送按钮时不会再次发送同一张图片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
document.getElementById('sendButton').onclick = function() {
var text = document.getElementById('textArea').value;
var deviceName = document.getElementById('deviceName').value || "匿名设备";
var imageFile = document.getElementById('imageUpload').files[0];

if (imageFile) {
var formData = new FormData();
formData.append('image', imageFile);
formData.append('deviceName', deviceName);
formData.append('text', text);

fetch('/upload-image', {
method: 'POST',
body: formData
}).then(response => response.json()).then(data => {
socket.emit('send text', data);

// 在此处清除文件输入的值
document.getElementById('imageUpload').value = "";
});
} else {
socket.emit('send text', { text, deviceName });
}
};

以上的修改确保了每次发送图像消息后,文件输入都会被清除。这样,用户在发送下一条消息时,之前选择的图片不会再次被发送

0x5.4.2 自动填充设备名称

为了自动获取设备名称,首先需要明确一个事实:Web 浏览器出于隐私和安全的考虑,并不允许直接获取设备的真实名称。但是,可以使用一些方法来近似识别或标识设备,例如使用浏览器的用户代理字符串。

用户代理 (User Agent) 字符串包含关于浏览器、操作系统和设备的信息,可以通过这些信息来推断或标识用户的设备类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function getDeviceNameFromUserAgent() {
var userAgent = navigator.userAgent || navigator.vendor || window.opera;

// Windows Phone必须先行一步,因为它的用户界面也包含“Android”。
if (/windows phone/i.test(userAgent)) {
return "Windows Phone";
}

if (/android/i.test(userAgent)) {
return "Android Device";
}

// iOS检测
if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) {
return "iOS Device";
}

if (/macintosh|mac os x/i.test(userAgent)) {
return "Mac";
}

if (/windows/i.test(userAgent)) {
return "Windows PC";
}

return "Unknown Device";
}

// 使用函数来自动填充设备名称
document.getElementById('deviceName').value = getDeviceNameFromUserAgent();

此函数根据用户代理字符串来近似地识别设备,并返回一个描述性名称。注意,这只是一个基本的示例,而真实的环境中可能需要更复杂的逻辑来准确识别设备。

这种方法只是提供了一个近似的设备名称,并不能真正的识别具体的设备型号或品牌

0x5.4.3 消息发送后自动清空输入框

为了在发送完消息后自动清除 textArea (你之前的代码中使用这个ID代表消息输入框)中的值,你可以在发送消息的函数中添加一行代码来实现这个功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
document.getElementById('sendButton').onclick = function() {
var text = document.getElementById('textArea').value;
var deviceName = document.getElementById('deviceName').value || "匿名设备";
var imageFile = document.getElementById('imageUpload').files[0];

// 如果选择了图片,则发送图片
if (imageFile) {
var formData = new FormData();
formData.append('image', imageFile);
formData.append('deviceName', deviceName);
formData.append('text', text);

fetch('/upload-image', {
method: 'POST',
body: formData
}).then(response => response.json()).then(data => {
socket.emit('send text', data);
});
} else {
// 如果没有选择图片,则直接发送文本消息
socket.emit('send text', { text, deviceName });
}

// 在所有情况下都清除textArea和图片选择器的值
document.getElementById('textArea').value = '';
document.getElementById('imageUpload').value = ''; // 这将清除文件输入选择器的内容
};

我在函数的底部添加了清除textArea和图片选择器imageUpload的代码。这样,无论是否选择了图片,都会在发送完消息后清除文本框和图片选择器的内容。

0x5.5 大屏适配与控件布局调整

0x5.5.1 Windows居中 iPhone逐行

修改CSS以在大屏幕上对底部控件进行水平居中:

1
2
3
4
5
6
7
8
9
10
11
12
.bottom-controls {
display: flex;
justify-content: center; /* 内容器水平居中 */
}

.bottom-controls > div {
display: flex; /* 这确保了内部容器的子元素水平对齐 */
align-items: center;
}

/* ... 其他样式 ... */

使用媒体查询来特定地为小屏幕(例如iPhone)定义样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* 小屏幕的样式(如iphone) */
@media (max-width: 500px) {
.bottom-controls {
justify-content: flex-start; /* 这将使内部容器与起始位置对齐 */
}
.bottom-controls > div {
flex-direction: column; /* 这确保了内部容器的子容器是垂直堆叠的 */
align-items: center;
width: 100%;
}
#deviceName, #textArea, #imageUpload, #sendButton {
display: block;
width: 100%; /* 确保控件在小屏幕上占满宽度 */
}
/* ... 你的其他小屏幕风格 ... */
}

这样,底部的控件在大屏幕上会水平居中,而在小屏幕(如iPhone)上,则会按照行排列。

0x5.5.2 扩大大屏幕底部控件尺寸

为了在大屏幕上使控件更大,可以使用媒体查询来区分大屏幕和小屏幕的样式。在大屏幕的样式中,可以增加控件的高度和字体大小。

1
2
3
4
5
6
7
8
9
10
11
/* 大屏幕的样式(例如,Windows, MacBook) */
@media (min-width: 501px) {
#deviceName, #textArea, #imageUpload, #sendButton {
height: 60px; /* 增加控件的高度 */
font-size: 18px; /* 增加字体大小以获得更好的可视性 */
padding: 10px; /* 为美观添加一些填充 */
}
#sendButton {
padding: 10px 20px; /* 调整按钮的填充 */
}
}

这段代码将确保在宽度超过501px的屏幕上,控件的高度、字体大小和内部间距都增加了,从而更适合大屏幕使用。可以根据实际需要进一步调整这些值。

0x5.5.3 大屏幕输入框长度及控件间距

为了在大屏幕上调整输入框的长度并在输入框和按钮之间添加合适的距离,可以进行以下修改:

  1. 在大屏幕上为输入框和按钮添加合适的间距
  2. 为大屏幕上的输入框增加宽度
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 大屏幕的样式(例如,Windows, MacBook) */
@media (min-width: 501px) {
#deviceName, #textArea, #imageUpload, #sendButton {
height: 60px;
font-size: 18px;
padding: 10px;
margin-right: 10px;
}
#sendButton {
padding: 10px 20px;
}
#textArea {
width: 60%;
}
/* 可以移除伸缩容器中最后一个元素的右距 */
div[style*="position: fixed; bottom: 0;"] > div > *:last-child {
margin-right: 0;
}
}

这段代码增加了#textArea的宽度并在所有底部控件之间添加了margin-right。为了确保最后一个元素(按钮)不与其父元素的右边界有太大的间隔,我添加了一个特定选择器来移除其右边距

0x5.6 增加深浅色主题

  1. 在HTML中添加一个切换按钮。
  2. 为深色模式定义CSS样式。
  3. 使用JavaScript监听按钮点击事件来切换主题。

更新index.html如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
[data-theme="dark"] input, [data-theme="dark"] textarea, [data-theme="dark"] button {
color: white; /* 将文字颜色设置为白色 */
border-color: white; /* 如果需要, 也可以设置边框颜色 */
}

:root {
/* 浅色模式的设置 */
--text-color: black;
--background-color: white;
--input-bg: #fff;
--container-bg: #fff;
--header-bg: #fff;
--button-color: black; /* 新增 */
}

[data-theme="dark"] {
/* 深色模式的设置 */
--text-color: black; /* 文本颜色为黑色 */
--background-color: white; /* 背景颜色为白色 */
}

.bottom-controls {
display: flex;
justify-content: center;
}
.bottom-controls > div {
display: flex;
align-items: center;
}
/* 为固定底部工具栏添加填充 */
div[style*="position: fixed; bottom: 0;"] {
padding-left: 10px;
padding-right: 10px;
}
/* 保证body的背景颜色被应用 */
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: var(--background-color) !important;
color: var(--text-color);
font-family: "JetBrainsMono", Arial, sans-serif;
}
/* 如果有特定的元素想要应用不同的文本或背景颜色,可以为它们单独设置: */
.element {
color: var(--text-color);
background-color: var(--background-color);
}
.container {
padding: 20px;
border: 1px solid #ddd;
background-color: #fff;
}
/* 小屏幕的样式(如iphone) */
@media (max-width: 500px) {
.bottom-controls {
justify-content: flex-start;
}
.bottom-controls > div {
flex-direction: column;
align-items: center;
width: 100%;
}

/* 更新底部控件的容器样式 */
div[style*="position: fixed; bottom: 0;"] {
background-color: var(--container-bg);

padding-left: 10px;
padding-right: 10px;
display: flex;
flex-direction: column;
}

/* 确保所有控件拉伸以填充可用宽度 */
#deviceName, #textArea, #imageUpload, #sendButton {
background-color: var(--container-bg);

width: 100%;
box-sizing: border-box;
margin-bottom: 5px;
}

/* 增加高度,便于触摸 */
#deviceName, #textArea, #sendButton {
height: 50px;
}
/* 在邮件列表的底部添加填充 */
#messages {
padding-bottom: 70px;
}
}
/* 大屏幕的样式(例如,Windows, MacBook) */
@media (min-width: 501px) {
#deviceName, #textArea, #imageUpload, #sendButton {
background-color: var(--input-bg);

height: 60px;
font-size: 18px;
padding: 10px;
margin-bottom: 10px;
}
#textArea {
width: 50%;
}
#sendButton {
padding: 10px 20px;
margin-left: 10px;
}
}
body, button {
background-color: var(--bg-color);
color: var(--text-color);
}
/* 为了确保按钮的文本颜色能够在两种主题下都清晰可见 */
button {
color: var(--button-color);
}
/* 底部控件的文本颜色 */
.bottom-controls input,
.bottom-controls textarea,
.bottom-controls button {
color: var(--text-color);
border-color: var(--text-color);
}
.container {
background-color: var(--container-bg);
}
body.dark-theme {
/* 深色模式的设置 */
--text-color: white;
--background-color: #555;
--input-bg: #555;
--container-bg: #444;
--header-bg: #444;
--button-color: white; /* 新增 */
}
#header {
background-color: var(--header-bg);
}
1
2
3
4
// 切换主题
document.getElementById('themeToggle').addEventListener('click', function() {
document.body.classList.toggle('dark-theme');
});

现在,当点击”Toggle Theme”按钮,页面的主题应该会在深色和浅色模式之间切换,并且文本应该会根据相应的模式改变颜色。

0x6 导入自定义字体

  1. 选择字体: 首先,需要一个字体文件。这可以是.ttf.woff.woff2.eot等格式。确保拥有使用该字体的权利,特别是如果打算在商业项目中使用。
  2. 字体文件上传: 将字体文件上传到的服务器或CDN,并确保知道文件的URL。
  3. 在CSS中导入字体: 在CSS文件或<style>标签中,使用@font-face规则来定义和导入字体。

下载JetBrains Mono字体,解压JetBrainsMono-Regular.ttfJetBrainsMono-Regular.woff2到网站根目录

1
2
3
4
5
6
7
@font-face {
font-family: "JetBrainsMono"; /* 可以为字体起一个名字 */
src: url('JetBrainsMono-Regular.woff2') format('woff2'), /* 最优先 */
url('JetBrainsMono-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}

在这里,path-to-your-font应该是字体文件的URL。如果有多种格式的字体文件,列出它们都是一个好主意,因为这可以确保更好的兼容性

1
2
3
4
5
/* 保证body的背景颜色被应用 */
body {
/* ... 其他样式 ... */
font-family: "JetBrainsMono", Arial, sans-serif;
}

在这里,"MyCustomFont"是我们为自定义字体定义的名字,Arial, sans-serif是后备字体,用于在某些情况下(例如,如果自定义字体加载失败)

考虑性能:字体文件可能很大,所以考虑只包括真正需要的字体权重和样式。例如,如果只需要常规和加粗,那么不要加载斜体或其他权重

0x7 添加自动加载历史记录功能

如果你想添加一个自动加载历史记录的功能,你需要在服务器端存储这些历史消息。这样,当新用户连接时,你就可以将这些历史消息发送给他们。

更新server.js如下:

在服务器上添加一个变量来存储消息

1
const messageHistory = [];

当接收到新的文本消息时,将它添加到历史记录中

1
2
3
4
5
6
7
8
socket.on('send text', (data) => {
// ...(其他代码不变)

// 将消息添加到历史记录
messageHistory.push(data);

io.emit('receive text', data);
});

当新用户连接时,发送所有历史消息给他们

1
2
3
4
5
6
7
8
9
10
io.on('connection', (socket) => {
console.log('a user connected');

// 发送历史消息给新连接的用户
messageHistory.forEach((message) => {
socket.emit('receive text', message);
});

// ...(其他代码不变)
});

index.html在客户端的 JavaScript 里,你不需要做任何修改。因为客户端已经设置了一个监听函数来处理 ‘receive text’ 事件,所以当它接收到历史消息时,会自动将其添加到消息列表。

这样,当有新用户加入聊天时,服务器就会自动发送所有历史消息给他们

0x8 添加消息代码格式化和时间戳

为了在消息中添加发送时间,可以在服务器端获取当前时间,并将其与其他数据一起发送。同时,也需要在客户端的JavaScript中修改接收数据的部分,以显示这一时间。

首先,在 server.js'send text' 事件处理程序中添加发送时间:

1
2
3
4
5
6
7
8
9
10
11
socket.on('send text', (data) => {
// ...其它代码

// 添加消息发送时间
data.timestamp = new Date().toLocaleString();

// 将新消息添加到历史记录
messageHistory.push(data);

io.emit('receive text', data);
});

修改 index.html 在接收消息部分,添加代码格式支持和时间戳

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
socket.on('receive text', function(data) {
var li = document.createElement("li");
var time = data.timestamp || new Date().toLocaleString(); // 获取时间戳,如果没有则使用当前时间

var content = `[${data.deviceName} @ ${time}]: `; // 显示设备名和时间
if (data.text.startsWith("```") && data.text.endsWith("```")) {
// 如果文本以 "```" 开头和结尾,将其识别为代码
content += `<pre>${data.text.slice(3, -3)}</pre>`;
} else {
content += `${data.text}`;
}

if (data.imageUrl) {
content += `<br><img src="${data.imageUrl}" alt="Uploaded image" style="max-width: 300px;">`;
}

li.innerHTML = content;
document.getElementById('messages').appendChild(li);
});

这样,如果用户在 textArea 输入的文本以 **”```”** 开头和结尾,这段文本将会被认为是代码,并用 <pre> 标签进行包裹,以维持其格式。同时,每条消息也会显示发送时间。

0x9 增加消息删除与数据库

0x9.1 增加消息删除

创建一个复选框要对应每条消息,所以必须设置每条消息和复选框的ID保持一致,在选择复选框时应该同时删除后面的消息

修改index.html文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var messageId = 0;  // 初始化一个全局变量
socket.on('receive text', function(data) {
var li = document.createElement("li");

// 创建复选框
var checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "messageCheckbox";
li.appendChild(checkbox); // 将复选框添加到列表项中

var time = data.timestamp || new Date().toLocaleString(); // 获取时间戳,如果没有则使用当前时间

var content = `[${data.deviceName} @ ${time}]: `; // 显示设备名和时间
if (data.text.startsWith("```") && data.text.endsWith("```")) {
// 如果文本以 "```" 开头和结尾,将其识别为代码
content += `<pre>${data.text.slice(3, -3)}</pre>`;
} else {
content += `${data.text}`;
}

if (data.imageUrl) {
content += `<br><img src="${data.imageUrl}" alt="Uploaded image" style="max-width: 300px;">`;
}

li.innerHTML += content;
document.getElementById('messages').appendChild(li);

li.setAttribute('data-message-id', messageId); // 为每个消息设置唯一ID
messageId++;

console.log("设备名称和IP地址:", data.deviceName);
});

向服务器发送删除请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
document.getElementById('deleteButton').onclick = function() {
var selectedMessages = document.querySelectorAll('.messageCheckbox:checked');
var messageIdsToDelete = [];
selectedMessages.forEach(function(checkbox) {
var li = checkbox.closest('li');
messageIdsToDelete.push(li.getAttribute('data-message-id'));
li.remove();
});
socket.emit('delete messages', messageIdsToDelete);

// 向后端发送删除请求
messageIdsToDelete.forEach(id => {
fetch(`/message/${id}`, {
method: 'DELETE'
}).then(response => {
if (!response.ok) {
// 以某种方式处理错误响应
console.error('从服务器删除消息失败');
}
});
});
};

向所有客户端广播消息ID,以供删除

1
2
3
4
5
6
7
socket.on('delete messages', function(messageIds) {
messageIds.forEach(function(messageId) {
var li = document.querySelector('li[data-message-id="' + messageId + '"]');
if (li) li.remove();
});
io.emit('delete messages', messageIds);
});

0x9.2 前端数据库联动删消息

0x9.2.1 创建数据库

修改server.js文件

1
const db = new sqlite3.Database('./messages.db');

0x9.2.2 创建表

创建message表,如果存在则什么都不做,包含id、content、deviceName、 ipAddress、 timestamp字段

1
2
3
4
5
6
7
db.run("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT, deviceName TEXT, ipAddress TEXT, timestamp TEXT)", (err) => {
if (err) {
console.error("创建/检查表错误:", err);
return;
}
console.log("表检查/创建成功");
});

0x9.2.3 读取数据库所有消息

从数据库中查询所有的消息记录,并将每条消息存储到messageHistory数组中,若查询过程中出现错误,会在控制台中打印错误信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
db.all("SELECT id, content, deviceName, ipAddress, timestamp FROM messages", [], (err, rows) => {
if (err) {
console.error("从数据库获取历史消息出错:", err);
return;
}
// 遍历数据库每条记录
rows.forEach(row => {
const message = {
id: row.id, // 确保这里保留 id
text: row.content,
deviceName: row.deviceName,
ipAddress: row.ipAddress,
timestamp: row.timestamp
};
// 将数据库中的数据保存在 messageHistory
messageHistory.push(message);
});
console.log("从数据库加载历史消息");
});

0x9.2.4 保存前端消息到数据库

定义了一个HTTP POST路由/send,用于从请求体中接收消息内容,并在消息内容存在时将其存储到数据库中,若消息内容为空,则返回一个错误响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
app.post('/send', (req, res) => {
const messageContent = req.body.message;

if (messageContent) {
// 存储消息到数据库
db.run("INSERT INTO messages (content, deviceName, ipAddress, timestamp) VALUES (?, ?, ?, ?)", [data.text, data.deviceName, clientIpAddress, data.timestamp],
(err) => {
if (err) {
console.error("插入数据库出错:", err);
return;
}
console.log('收到的信息:', data.text || messageContent);
});
} else {
res.status(400).send({error: "消息内容为空"});
}
});

0x9.2.5 广播所有消息给用户

当一个用户与服务器建立Socket连接时的操作:首先打印出用户已连接的消息,然后将存储在messageHistory数组中的每条历史消息(经过修改以显示设备名和IP地址)发送给新连接的用户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
io.on('connection', (socket) => {
console.log('有一个用户连接');

function formatMessageForEmission(originalMessage) {
return {
...originalMessage,
deviceName: `${originalMessage.deviceName} (${originalMessage.ipAddress})`
};
}

// 发送历史消息给新连接的用户
messageHistory.forEach((message) => {
socket.emit('receive text', formatMessageForEmission(message));
// console.log("format的设备和IP地址:",message)
});

0x9.2.6 广播Socket客户端消息

当服务器通过Socket接收到客户端发送的send text事件时,首先获取和处理客户端的IP地址,为没有提供设备名称的消息设置默认名称,然后将消息及相关信息添加到数据库中。之后,会修改消息的设备名称,将新消息添加到历史记录数组,并通过Socket广播这条消息给所有连接的客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
socket.on('send text', (data) => {
// 获取客户端的IP地址
let clientIpAddress = socket.request.connection.remoteAddress.replace(/^::ffff:/, ''); // 清除IPv6前缀

if (clientIpAddress === '::1' || clientIpAddress === '127.0.0.1') {
clientIpAddress = 'Localhost';
}

// 添加ipAddress字段到data对象
data.ipAddress = clientIpAddress;

// 默认设备名称
if (!data.deviceName || data.deviceName.trim() === "") {
data.deviceName = "匿名设备";
}

// 添加消息发送时间
data.timestamp = new Date().toLocaleString();

// 添加消息到数据库中
db.run("INSERT INTO messages (content, deviceName, ipAddress, timestamp) VALUES (?, ?, ?, ?)",
[data.text, data.deviceName, clientIpAddress, data.timestamp],
function(err) { // 使用函数关键字以便访问 this.lastID
if (err) {
console.error("插入数据库出错:", err);
return;
}
data.id = this.lastID; // 获取新插入的行的 ID
messageHistory.push(data); // 将新消息添加到历史记录
console.log('通过套接字收到的消息:', data.text);
}
);

io.emit('receive text', formatMessageForEmission(data));
});

0x9.2.7 处理DELETE请求删数据库消息

当收到HTTP DELETE请求时,根据指定的消息ID从数据库中删除相应的消息记录;若删除过程中出现错误,则返回错误信息,否则通知请求者消息已被删除。如果未提供有效的消息ID,则返回“无效的消息ID”错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
app.delete('/message/:id', (req, res) => {
const messageId = req.params.id;
if (messageId) {
db.run("DELETE FROM messages WHERE id=?", [messageId], (err) => {
if (err) {
console.error("从数据库删除消息出错:", err);
res.status(500).send({error: "删除消息失败"});
return;
}

// 从 messageHistory 数组中删除消息
const index = messageHistory.findIndex(message => message.id === Number(messageId));
if (index !== -1) {
messageHistory.splice(index, 1);
}

res.send({status: "消息已删除"});
});
} else {
res.status(400).send({error: "无效的消息ID"});
}
});

0xA Docker封装与测试

部署Node.js网站到QNAP NAS上的一个优雅的方式是使用Docker。这种方法提供了隔离,便于管理和升级,而不会影响主机系统

0xA.1 Docker环境准备

0xA.1.1 创建Dockerfile

  1. 打开终端或命令提示符:导航到Node.js项目的根目录。

  2. 创建一个新的Dockerfile

    如果使用的是Linux或Mac,可以在终端中输入以下命令:

    1
    touch Dockerfile

    如果在Windows上,可以使用命令提示符或PowerShell并输入以下命令:

    1
    echo. > Dockerfile
  3. 编辑Dockerfile

    使用喜欢的文本编辑器打开新创建的Dockerfile。例如,可以使用VS Code、Notepad++、vim等。

  4. 在Dockerfile中输入内容

    复制上文给出的Dockerfile内容,并粘贴到的Dockerfile中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 或者选择的其他Node.js版本
FROM node:14 AS build-stage

# 设置工作目录
WORKDIR /usr/src/app

# 将package.json和package-lock.json文件复制到工作目录
COPY package*.json ./

# 安装应用程序的依赖项
RUN npm install

# 复制应用程序的源代码到工作目录
COPY . .

# 暴露端口
# 或的应用程序运行的其他端口
EXPOSE 3000

# 定义Docker容器的启动命令
CMD ["npm", "start"]

保存并关闭文件

0xA.1.2 构建Docker镜像

在项目的目录中执行以下命令,以构建一个Docker镜像:

1
docker build -t realtimetext .

要确保的镜像已经正确构建,可以使用以下命令列出所有本地的Docker镜像:

1
docker images

0xA.1.3 运行Docker容器

确保Docker已安装并正在运行,然后运行以下命令以启动的Node.js应用:

1
docker run -p 4000:3000 realtime_text

0xA.2 导出容器上传并运行

0xA.2.1 导出Docker容器

首先,需要确定一个有效的路径来保存tar文件

1
mkdir docker-images

查看当前运行的docker ID

1
docker ps

将容器提交为新的Docker镜像

1
docker commit [CONTAINER_ID] realtime_text

这应该会将的realtime_text镜像保存为realtime_text.tar文件,位于docker-images/目录下

1
docker save -o docker-images/realtime_text.tar realtime_text

0xA.2.2 上传Docker容器

  1. 需要确保在QNAP中的FileStation创建一个名为Container的目录

  2. 在App Center中搜索并安装Container Station,等待服务启动完成就可以打开了

  3. 打开映像页面,点击右上角的导入,从本地计算机选择刚导出的Docker镜像文件realtime_text,再点击导入

0xA.2.3 运行Docker容器

在容器页面启动名为realtime_text的容器,查看其详细信息及Web URL:http://192.168.50.145:32769,就是部署后的网页地址

0xA.3 维护容器占用空间

长期发送图片消息会导致upload目录占用较大空间,必要情况下可以在容器页面选择容器后的设置按钮,选择执行/bin/sh控制台

1
2
cd uploads
rm *

0xB 常见问题与解决方案

0xB.1 构建失败

  • 在构建Docker时出现任何问题请先确保你在realtime_text根目录下

  • 构建docker的镜像名必须是小写

  • 构建docker镜像必须指定导出目录

  • 如果你在Linux下创建docker请确保相关文件有执行的权限

    1
    2
    chmod +x script_name.sh
    ./script_name.sh

0xB.2 端口占用

在运行时报告了一个端口占用错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
“node:events:491
throw er; // Unhandled 'error' event
^
Error: listen EADDRINUSE: address already in use :::3000
at Server.setupListenHandle [as _listen2] (node:net:1485:16)
at listenInCluster (node:net:1533:12)
at Server.listen (node:net:1621:7)
at Object.<anonymous> (D:\code\win-code\NodeJS\realtime_text\server.js:60:8)
at Module._compile (node:internal/modules/cjs/loader:1159:14)
at Module._extensions..js (node:internal/modules/cjs/loader:1213:10)
at Module.load (node:internal/modules/cjs/loader:1037:32)
at Module._load (node:internal/modules/cjs/loader:878:12)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)
at node:internal/main/run_main_module:23:47
Emitted 'error' event on Server instance at:
at emitErrorNT (node:net:1512:8)
at process.processTicksAndRejections (node:internal/process/task_queues:82:21) {
code: 'EADDRINUSE',
errno: -4091,
syscall: 'listen',
address: '::',
port: 3000
}

Node.js v18.12.1”

这个错误 Error: listen EADDRINUSE: address already in use :::3000 意味着端口 3000 已经被其他程序或者相同的程序的另一个实例使用了。你有几种方式来解决这个问题:

  1. 杀掉使用该端口的进程

    1. 在 Windows 上:
      1. 打开命令行窗口,然后运行 netstat -ano | findstr :3000
      2. 这会显示使用端口 3000 的所有进程。找到进程 ID(PID)。
      3. 执行 taskkill /PID <PID> /F 来杀掉进程。
    2. 在 macOS 或 Linux 上:
      1. 打开终端,然后运行 lsof -i :3000
      2. 这会显示使用端口 3000 的所有进程。找到进程 ID(PID)。
      3. 执行 kill -9 <PID> 来杀掉进程。
  2. 更改应用程序使用的端口

    在你的 server.js 文件中,找到下面这行代码:

    1
    const port = process.env.PORT || 3000;

    你可以更改 3000 到其他未被使用的端口号,例如:

    1
    const port = process.env.PORT || 4000;

    然后重新运行你的应用程序。

  3. 重启计算机

    如果上面的方法没有解决问题或者你不确定如何进行,重启计算机通常能够解决端口冲突的问题。

    希望这能帮到你!

0xB.3 数据库自增ID列

DataGrip 是一个流行的数据库管理工具,允许你通过其图形界面来进行各种数据库操作。要在 DataGrip 中修改表的 id 列为自增,可以按照以下步骤进行:

  1. 打开 DataGrip 并连接到你的 SQLite 数据库。

  2. 在左侧的 数据库浏览器 中,找到你的表。

  3. 右键点击 你想要修改的表,然后选择 Modify Table

  4. 在打开的窗口中,找到 id 列。

  5. 选中 id 列,然后在右侧的属性面板中找到 IdentityAuto Increment 选项并勾选。

  6. 右键点击 该列名,并从上下文菜单中选择 Set Primary Key

  7. 最后,点击窗口下方的 提交应用 按钮来保存更改。

请注意,不同的数据库系统和不同的版本可能会有些微差异。确保在进行此类操作之前备份你的数据。

如果 DataGrip 不直接支持 SQLite 的自增特性或有任何其他问题,你可能需要使用 SQL 语句来手动修改表结构,或者像之前所描述的那样,通过创建一个新表、复制数据、删除旧表、然后重命名新表的方式来达到目的。

0xB.4 确保前端页面与数据库内容同步

前端消息的ID与数据库ID对应。若前端删除消息失败,可能是ID不匹配或Docker中的sqlite3库问题。若需手动删除消息,以下是相关sql语句。不使用Docker容器可避免该问题,并注意Docker会将24小时制改为12小时制

0xB.4.1 连接数据库

1
sqlite3 messages.db

0xB.4.2 查询表

1
SELECT * FROM messages;

0xB.4.3 查ID列

1
SELECT id FROM messages;

0xB.4.4 删除指定ID行

1
DELETE FROM messages WHERE id = 5;

0xB.4.5 删除指定多行ID

1
DELETE FROM messages WHERE id IN (3, 4, 5);

0xB.4.6 查询指定ID列的值

1
SELECT * FROM messages WHERE id = 5;

0xB.4.7 查询指定多行ID列的值

1
SELECT * FROM messages WHERE id IN (3, 4, 5);

0xB.4.8 修改指定ID

在这个语句中,18 替代了第一个 ?21 替代了第二个 ?,而 21 替代了第三个 ?。这样的话,当你执行这个SQL语句时,它会将 messages 表中 rowid 为 21,且 id 为 21 的行的 id 字段的值更新为 18。

1
UPDATE "messages" SET "id" = 18 WHERE "rowid" = 21 AND "id" = 21

0xB.4.9 SSH连接正在运行的docker

1
docker exec -it <container_id_or_name> /bin/bash

0xB.4.A docker中安装vim

1
2
apt-get update
apt-get install -y vim

0xB.4.B 重置id列自增值

查询当前最大值

1
SELECT MAX(id) FROM messages;

将自增ID的SEQ设置为最大ID值:使用以下SQL语句来设置自增ID列的序列(sequence)为当前最大ID值

1
UPDATE sqlite_sequence SET seq = 31 WHERE name = 'messages';

删除消息后需重启docker容器生效

0xC 运行效果

等一会,加载图片比较慢

0xC.1 大屏幕设备

0xC.2 小屏幕设备