xx-chat 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ - [x] 项目初始化完成,依赖安装成功。
2
+ - [x] 可以通过 CLI 参数或交互式提示成功获取配置(角色、IP、昵称)。
3
+ - [x] Server 端能够成功启动并监听端口。
4
+ - [x] Client 端能够成功连接到 Server 端。
5
+ - [x] 用户加入/退出聊天室时,所有人能收到系统提示。
6
+ - [x] 任意用户发送消息时,聊天室内的所有人都能实时收到并正确展示。
7
+ - [x] 命令行 UI 输入与接收消息不冲突,颜色区分明显。
@@ -0,0 +1,34 @@
1
+ # LAN CLI Chat Tool Spec
2
+
3
+ ## Why
4
+ 用户需要一个能在局域网内进行简单通信的命令行聊天工具,通过简单的配置即可快速搭建聊天室并进行实时交流。
5
+
6
+ ## What Changes
7
+ - 初始化 Node.js 项目并使用 `yarn` 作为包管理工具。
8
+ - 实现 CLI 参数解析,支持配置角色(server/client)、服务器 IP 和昵称。
9
+ - 实现 Server 端逻辑:管理客户端连接、广播消息、处理用户断开连接。
10
+ - 实现 Client 端逻辑:连接服务器、发送消息、接收并展示广播消息。
11
+ - 实现命令行 UI:使用颜色区分不同用户的消息,确保输入和接收消息时界面不混乱。
12
+
13
+ ## Impact
14
+ - Affected specs: 局域网聊天核心能力、命令行交互 UI。
15
+ - Affected code: 全新项目,涉及核心的 CLI、Server、Client 及 UI 模块。
16
+
17
+ ## ADDED Requirements
18
+ ### Requirement: 启动配置
19
+ 系统应允许用户通过命令行参数或交互式提示输入:角色(server 或 client)、服务器 IP(客户端需要)、昵称。
20
+
21
+ #### Scenario: 作为服务端启动
22
+ - **WHEN** 用户执行命令指定 role 为 server,并输入昵称
23
+ - **THEN** 系统启动 WebSocket 服务端监听默认端口,并作为第一个用户加入聊天室。
24
+
25
+ #### Scenario: 作为客户端启动
26
+ - **WHEN** 用户执行命令指定 role 为 client,输入服务端 IP 和昵称
27
+ - **THEN** 系统连接到指定 IP 的服务端,加入聊天室并收到欢迎消息。
28
+
29
+ ### Requirement: 实时聊天
30
+ 系统应提供唯一的聊天室,所有连接的用户都能实时看到他人发送的消息。
31
+
32
+ #### Scenario: 接收和发送消息
33
+ - **WHEN** 用户在输入框输入消息并回车
34
+ - **THEN** 消息通过服务端广播给所有在线用户,其他用户的界面实时展示该消息。
@@ -0,0 +1,26 @@
1
+ # Tasks
2
+ - [x] Task 1: 初始化项目和依赖
3
+ - [x] SubTask 1.1: 执行 `yarn init -y`,配置 `package.json` 中的 `type: "module"` 以使用 ES Modules。
4
+ - [x] SubTask 1.2: 安装必要的依赖包:`commander`, `inquirer`, `socket.io`, `socket.io-client`, `chalk`。
5
+ - [x] Task 2: 实现 CLI 参数解析与交互
6
+ - [x] SubTask 2.1: 使用 `commander` 解析 `--role`, `--ip`, `--nickname` 参数。
7
+ - [x] SubTask 2.2: 使用 `inquirer` 处理未提供的必填参数。
8
+ - [x] Task 3: 实现 Server 端核心逻辑
9
+ - [x] SubTask 3.1: 使用 `socket.io` 创建服务端,监听指定端口(如 3000)。
10
+ - [x] SubTask 3.2: 实现客户端连接、断开事件监听,以及广播系统消息(如:xxx 加入了聊天室)。
11
+ - [x] SubTask 3.3: 实现接收客户端聊天消息并广播给所有人的逻辑。
12
+ - [x] Task 4: 实现 Client 端核心逻辑
13
+ - [x] SubTask 4.1: 使用 `socket.io-client` 连接到服务端 IP。
14
+ - [x] SubTask 4.2: 监听服务端广播的聊天消息和系统消息。
15
+ - [x] Task 5: 实现命令行 UI
16
+ - [x] SubTask 5.1: 使用 Node.js 的 `readline` 模块处理用户输入,确保接收新消息时使用清除当前行的方法,不打断当前正在输入的内容。
17
+ - [x] SubTask 5.2: 使用 `chalk` 对消息进行颜色格式化(系统消息黄色、自己发送的消息绿色、他人发送的消息蓝色)。
18
+ - [x] Task 6: 联调与入口集成
19
+ - [x] SubTask 6.1: 编写主入口文件 `index.js`,根据配置的角色启动 Server 或 Client。
20
+
21
+ # Task Dependencies
22
+ - Task 2 depends on Task 1
23
+ - Task 3 depends on Task 2
24
+ - Task 4 depends on Task 3
25
+ - Task 5 depends on Task 3 and Task 4
26
+ - Task 6 depends on Task 5
package/index.js ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ import { parseCli } from './src/cli.js';
3
+ import { startServer } from './src/server.js';
4
+ import { startClient } from './src/client.js';
5
+ import { initUI, renderSystemMessage, renderChatMessage } from './src/ui.js';
6
+
7
+ async function main() {
8
+ const { role, ip, nickname } = await parseCli();
9
+
10
+ let socket;
11
+
12
+ if (role === 'server') {
13
+ renderSystemMessage('正在启动服务器...');
14
+ startServer(3000);
15
+ renderSystemMessage('服务器已启动,监听端口 3000');
16
+ // 服务端自己也作为一个客户端连接上去
17
+ socket = startClient('127.0.0.1', nickname);
18
+ } else if (role === 'client') {
19
+ renderSystemMessage(`正在连接到服务器 ${ip}:3000...`);
20
+ socket = startClient(ip, nickname);
21
+ }
22
+
23
+ let currentNickname = nickname;
24
+
25
+ initUI((message) => {
26
+ if (message.startsWith('/')) {
27
+ const parts = message.split(' ');
28
+ const command = parts[0];
29
+
30
+ if (command === '/' || command === '/help') {
31
+ renderSystemMessage('可用命令:\n /nickname <新昵称> - 修改昵称\n /quit - 退出聊天');
32
+ } else if (command === '/nickname') {
33
+ const newNickname = parts.slice(1).join(' ').trim();
34
+ if (newNickname) {
35
+ socket.emit('change_nickname', newNickname);
36
+ currentNickname = newNickname;
37
+ renderSystemMessage(`你的昵称已修改为 ${newNickname}`);
38
+ } else {
39
+ renderSystemMessage('用法: /nickname <新昵称>');
40
+ }
41
+ } else if (command === '/quit') {
42
+ renderSystemMessage('正在退出聊天...');
43
+ process.exit(0);
44
+ } else {
45
+ renderSystemMessage('未知命令,输入 / 查看可用命令');
46
+ }
47
+ return;
48
+ }
49
+
50
+ // UI 层显示自己发的消息
51
+ renderChatMessage(currentNickname, message, true);
52
+ // 通过 socket 发送出去
53
+ socket.emit('chat_message', { nickname: currentNickname, message });
54
+ });
55
+ }
56
+
57
+ main().catch((err) => {
58
+ console.error(err);
59
+ process.exit(1);
60
+ });
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "xx-chat",
3
+ "version": "1.0.0",
4
+ "description": "A simple LAN CLI chat tool",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "xx-chat": "./index.js"
9
+ },
10
+ "author": "xiaozhou <librazhoux@hotmail.com>",
11
+ "license": "MIT",
12
+ "dependencies": {
13
+ "chalk": "5",
14
+ "commander": "11",
15
+ "inquirer": "8.2.4",
16
+ "socket.io": "^4.8.3",
17
+ "socket.io-client": "^4.8.3"
18
+ }
19
+ }
package/src/cli.js ADDED
@@ -0,0 +1,52 @@
1
+ import { program } from 'commander';
2
+ import inquirer from 'inquirer';
3
+
4
+ export async function parseCli() {
5
+ program
6
+ .option('-r, --role <type>', 'Role: server or client')
7
+ .option('-i, --ip <ip>', 'Server IP to connect to (if client)')
8
+ .option('-n, --nickname <name>', 'Your nickname');
9
+
10
+ program.parse(process.argv);
11
+ const options = program.opts();
12
+
13
+ const questions = [];
14
+
15
+ if (!options.role || !['server', 'client'].includes(options.role)) {
16
+ questions.push({
17
+ type: 'list',
18
+ name: 'role',
19
+ message: 'Choose your role:',
20
+ choices: ['server', 'client'],
21
+ });
22
+ }
23
+
24
+ if (!options.nickname) {
25
+ questions.push({
26
+ type: 'input',
27
+ name: 'nickname',
28
+ message: 'Enter your nickname:',
29
+ validate: (input) => input.trim() ? true : 'Nickname cannot be empty',
30
+ });
31
+ }
32
+
33
+ questions.push({
34
+ type: 'input',
35
+ name: 'ip',
36
+ message: 'Enter server IP:',
37
+ default: '127.0.0.1',
38
+ when: (answers) => {
39
+ const role = answers.role || options.role;
40
+ return role === 'client' && !options.ip;
41
+ },
42
+ validate: (input) => input.trim() ? true : 'IP cannot be empty',
43
+ });
44
+
45
+ const answers = await inquirer.prompt(questions);
46
+
47
+ return {
48
+ role: answers.role || options.role,
49
+ ip: answers.ip || options.ip,
50
+ nickname: answers.nickname || options.nickname,
51
+ };
52
+ }
package/src/client.js ADDED
@@ -0,0 +1,29 @@
1
+ import { io } from 'socket.io-client';
2
+ import { renderSystemMessage, renderChatMessage } from './ui.js';
3
+
4
+ export function startClient(ip, nickname) {
5
+ const socket = io(`http://${ip}:3000`);
6
+
7
+ socket.on('connect', () => {
8
+ socket.emit('join', nickname);
9
+ renderSystemMessage('成功连接到聊天服务器!');
10
+ });
11
+
12
+ socket.on('system_message', (msg) => {
13
+ renderSystemMessage(msg);
14
+ });
15
+
16
+ socket.on('chat_message', (data) => {
17
+ renderChatMessage(data.nickname, data.message, false);
18
+ });
19
+
20
+ socket.on('disconnect', () => {
21
+ renderSystemMessage('与服务器断开连接。');
22
+ });
23
+
24
+ socket.on('connect_error', (err) => {
25
+ renderSystemMessage(`连接错误: ${err.message}`);
26
+ });
27
+
28
+ return socket;
29
+ }
package/src/server.js ADDED
@@ -0,0 +1,32 @@
1
+ import { Server } from 'socket.io';
2
+
3
+ export function startServer(port = 3000) {
4
+ const io = new Server(port);
5
+
6
+ io.on('connection', (socket) => {
7
+ let currentNickname = '';
8
+
9
+ socket.on('join', (nickname) => {
10
+ currentNickname = nickname;
11
+ socket.broadcast.emit('system_message', `${nickname} 加入了聊天室`);
12
+ });
13
+
14
+ socket.on('change_nickname', (newNickname) => {
15
+ const oldNickname = currentNickname;
16
+ currentNickname = newNickname;
17
+ socket.broadcast.emit('system_message', `${oldNickname} 将昵称修改为 ${newNickname}`);
18
+ });
19
+
20
+ socket.on('chat_message', (data) => {
21
+ socket.broadcast.emit('chat_message', data);
22
+ });
23
+
24
+ socket.on('disconnect', () => {
25
+ if (currentNickname) {
26
+ socket.broadcast.emit('system_message', `${currentNickname} 离开了聊天室`);
27
+ }
28
+ });
29
+ });
30
+
31
+ return io;
32
+ }
package/src/ui.js ADDED
@@ -0,0 +1,51 @@
1
+ import readline from 'readline';
2
+ import chalk from 'chalk';
3
+
4
+ let rl;
5
+
6
+ function completer(line) {
7
+ const completions = ['/nickname', '/quit', '/help'];
8
+ const hits = completions.filter((c) => c.startsWith(line));
9
+ return [hits.length ? hits : line.startsWith('/') ? completions : [], line];
10
+ }
11
+
12
+ export function initUI(onMessage) {
13
+ rl = readline.createInterface({
14
+ input: process.stdin,
15
+ output: process.stdout,
16
+ prompt: '> ',
17
+ completer
18
+ });
19
+
20
+ rl.prompt();
21
+
22
+ rl.on('line', (line) => {
23
+ const text = line.trim();
24
+ if (text) {
25
+ onMessage(text);
26
+ }
27
+ rl.prompt();
28
+ });
29
+ }
30
+
31
+ function clearLine() {
32
+ if (rl) {
33
+ process.stdout.write('\x1B[2K\x1B[0G');
34
+ }
35
+ }
36
+
37
+ export function renderSystemMessage(msg) {
38
+ clearLine();
39
+ console.log(chalk.yellow(`[系统] ${msg}`));
40
+ if (rl) rl.prompt(true);
41
+ }
42
+
43
+ export function renderChatMessage(nickname, message, isSelf = false) {
44
+ clearLine();
45
+ if (isSelf) {
46
+ console.log(chalk.green(`[我(${nickname})] ${message}`));
47
+ } else {
48
+ console.log(chalk.blue(`[${nickname}] ${message}`));
49
+ }
50
+ if (rl) rl.prompt(true);
51
+ }