import {
HttpTransportType,
HubConnectionState,
type HubConnection,
HubConnectionBuilder,
type IHttpConnectionOptions,
LogLevel,
} from '@microsoft/signalr';
import { DialogType, ToastType, uiFeedbackService } from '../UiFeedbackService.ts';
import { logger } from '../ConsoleLogger.ts';
import { canvasChat } from './CanvasChat.ts';
import { util } from '../util.ts';
import { pushPageHistory } from '../pageHistory.ts';
interface GroupChatUser {
id: string;
name: string;
}
interface GroupChatCurrentUser {
id: string;
}
interface GroupChatMessage {
userId: string;
userName: string;
avatar: string;
message: string;
sendTime: string;
fontColor?: string | null;
}
declare global {
interface Window {
openGroupChat?: () => void;
}
}
/**
* 群组聊天模块
* 彩蛋功能:通过控制台命令开启。
*/
export class GroupChat {
private readonly _chatHash: string = '#group-chat';
private readonly _storageKey: string = 'group-chat-opened';
private readonly _maxHistoryCount: number = 1000;
private readonly _fontColorStorageKeyPrefix: string = 'group-chat-font-color:';
private _connection: HubConnection | null = null;
private _isOpen: boolean = false;
private _currentUserId: string | null = null;
private _loadedPageIndex: number = 0;
private _hasMoreHistory: boolean = true;
private _isLoadingHistory: boolean = false;
private _loadedHistoryCount: number = 0;
private _scrollPosition: number | undefined;
private _triggerButton: HTMLButtonElement | null = null;
private _chatPanel: HTMLElement | null = null;
private _messagesContainer: HTMLElement | null = null;
private _loadMoreButton: HTMLElement | null = null;
private _input: HTMLTextAreaElement | null = null;
private _sendButton: HTMLButtonElement | null = null;
private _commandsPanel: HTMLElement | null = null;
private _selectedCommandIndex: number = 0;
private _localFontColor: string | null = null;
public constructor() {
this.init();
this.initMobileViewport();
this.initHashListener();
}
/**
* 初始化群聊模块基础能力。
*/
private init(): void {
this.createTriggerButton();
this.checkOpenedHistory();
this.setupConsoleCommand();
}
/**
* 监听 hash 变化,移动端支持返回键关闭。
*/
private initHashListener(): void {
window.addEventListener('hashchange', async () => {
if (!util.isMobile()) {
return;
}
if (window.location.hash === this._chatHash) {
if (!this._isOpen) {
await this.open();
}
return;
}
if (this._isOpen) {
await this.close();
}
});
}
/**
* 判断群聊连接当前是否可发送请求。
*/
private isConnectionReady(): boolean {
return !!(this._connection && this._connection.state === HubConnectionState.Connected);
}
/**
* 当前用户本地颜色缓存 key。
*/
private getLocalFontColorStorageKey(): string | null {
if (!this._currentUserId) {
return null;
}
return `${this._fontColorStorageKeyPrefix}${this._currentUserId}`;
}
/**
* 读取本地字体颜色。
*/
private loadLocalFontColor(): string | null {
const key = this.getLocalFontColorStorageKey();
if (!key) {
return null;
}
const rawValue = localStorage.getItem(key);
if (!rawValue) {
return null;
}
return this.normalizeColor(rawValue);
}
/**
* 保存本地字体颜色。
*/
private saveLocalFontColor(color: string): void {
const key = this.getLocalFontColorStorageKey();
if (!key) {
return;
}
localStorage.setItem(key, color);
this._localFontColor = color;
this.applyInputFontColor();
}
/**
* 将当前字体颜色应用到聊天输入框。
*/
private applyInputFontColor(): void {
if (!this._input) {
return;
}
if (this._localFontColor) {
this._input.style.color = this._localFontColor;
return;
}
this._input.style.color = '';
}
/**
* 将本地字体颜色同步到服务端缓存。
*/
private async syncServerFontColor(localColor: string): Promise<void> {
if (!this.isConnectionReady() || !this._connection) {
return;
}
try {
await this._connection.invoke('SetFontColor', localColor);
} catch (error) {
logger.outPutError('同步字体颜色到服务端失败', error);
}
}
/**
* 连接成功后同步本地与服务端字体颜色。
*/
private async syncFontColorOnConnect(): Promise<void> {
if (!this.isConnectionReady() || !this._connection) {
return;
}
const localColor = this.loadLocalFontColor();
let serverColor: string | null = null;
try {
const serverValue = await this._connection.invoke<string | null>('GetFontColor');
serverColor = serverValue ? this.normalizeColor(serverValue) : null;
} catch (error) {
logger.outPutError('获取服务端字体颜色失败', error);
}
if (localColor && serverColor && localColor !== serverColor) {
await this.syncServerFontColor(localColor);
this._localFontColor = localColor;
this.applyInputFontColor();
return;
}
if (localColor && !serverColor) {
await this.syncServerFontColor(localColor);
this._localFontColor = localColor;
this.applyInputFontColor();
return;
}
if (!localColor && serverColor) {
this.saveLocalFontColor(serverColor);
return;
}
this._localFontColor = localColor;
this.applyInputFontColor();
}
/**
* 移动端打开群聊时,锁定页面滚动。
*/
private disableBodyScroll(): void {
if (!util.isMobile()) {
return;
}
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
document.body.style.top = `-${window.scrollY}px`;
this._scrollPosition = window.scrollY;
}
/**
* 关闭群聊后恢复页面滚动位置。
*/
private enableBodyScroll(): void {
if (!util.isMobile()) {
return;
}
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
document.body.style.top = '';
if (this._scrollPosition !== undefined) {
window.scrollTo(0, this._scrollPosition);
this._scrollPosition = undefined;
}
}
/**
* 初始化移动端视口高度变量,提升键盘和地址栏场景兼容性。
*/
private initMobileViewport(): void {
const setViewportHeight = (): void => {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
};
setViewportHeight();
window.addEventListener('resize', setViewportHeight);
window.addEventListener('orientationchange', () => {
setTimeout(setViewportHeight, 100);
});
if (/iPhone|iPad|iPod/.test(navigator.userAgent)) {
let lastScrollTop = 0;
window.addEventListener(
'scroll',
() => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
if (scrollTop !== lastScrollTop) {
setViewportHeight();
lastScrollTop = scrollTop;
}
},
{ passive: true }
);
}
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', setViewportHeight);
window.visualViewport.addEventListener('scroll', setViewportHeight);
}
}
/**
* 创建群聊触发按钮。
*/
private createTriggerButton(): void {
const trigger = document.createElement('button');
trigger.className = 'group-chat__trigger';
trigger.innerHTML = '<i class="fa fa-comments" aria-hidden="true"></i>';
trigger.setAttribute('aria-label', '打开群组聊天');
trigger.style.display = 'none';
trigger.addEventListener('click', () => {
void this.open();
});
document.body.appendChild(trigger);
this._triggerButton = trigger;
}
/**
* 读取历史状态,决定是否显示触发按钮。
*/
private checkOpenedHistory(): void {
try {
const hasOpened = localStorage.getItem(this._storageKey);
if (hasOpened === 'true' && this._triggerButton) {
this._triggerButton.style.display = 'flex';
}
} catch (error) {
logger.outPutError('检查群聊历史失败', error);
}
}
/**
* 标记用户已开启过群聊。
*/
private markAsOpened(): void {
try {
localStorage.setItem(this._storageKey, 'true');
} catch (error) {
logger.outPutError('保存群聊状态失败', error);
}
}
/**
* 注入控制台命令入口。
*/
private setupConsoleCommand(): void {
window.openGroupChat = () => {
if (!this._isOpen) {
this.open().then(() => {
logger.outPutSuccess('群组聊天已打开');
});
return;
}
logger.outPutInfo('群组聊天已经打开');
};
logger.outPutInfo('💬 群组聊天');
logger.outPutInfo('输入 window.openGroupChat() 来开启群组聊天');
}
/**
* 打开群聊面板并建立连接。
*/
public async open(): Promise<void> {
if (this._isOpen) {
return;
}
this._isOpen = true;
this.markAsOpened();
if (util.isMobile()) {
if (window.location.hash !== this._chatHash) {
pushPageHistory(this._chatHash);
}
this.disableBodyScroll();
}
this.createUI();
await this.connect();
}
/**
* 创建群聊面板 DOM。
*/
private createUI(): void {
const chatPanel = document.createElement('div');
chatPanel.className = 'group-chat';
chatPanel.innerHTML = `
<div class="group-chat__header">
<h3 class="group-chat__title">群组聊天</h3>
<button class="group-chat__close" aria-label="关闭">
<i class="fa fa-times"></i>
</button>
</div>
<div class="group-chat__messages" id="group-chat-messages">
<div
class="group-chat__load-more"
id="group-chat-load-more"
style="display: none;"
>
<span>加载更多...</span>
</div>
</div>
<div class="group-chat__input-area">
<div class="group-chat__commands" id="group-chat-commands">
<div class="group-chat__command-item" data-command="/canvas">
<span class="group-chat__command-name">/canvas</span>
<span class="group-chat__command-desc">
开启/加入 你画我猜
</span>
</div>
<div class="group-chat__command-item" data-command="/font-color">
<span class="group-chat__command-name">/font-color</span>
<span class="group-chat__command-desc">
设置聊天文字颜色
</span>
</div>
</div>
<textarea
class="group-chat__input"
id="group-chat-input"
placeholder="输入消息... (/ 显示命令)"
rows="1"
></textarea>
<button class="group-chat__send-btn" id="group-chat-send">发送</button>
</div>
`;
document.body.appendChild(chatPanel);
this._chatPanel = chatPanel;
this._messagesContainer = chatPanel.querySelector<HTMLElement>('#group-chat-messages');
this._loadMoreButton = chatPanel.querySelector<HTMLElement>('#group-chat-load-more');
this._input = chatPanel.querySelector<HTMLTextAreaElement>('#group-chat-input');
this._sendButton = chatPanel.querySelector<HTMLButtonElement>('#group-chat-send');
this._commandsPanel = chatPanel.querySelector<HTMLElement>('#group-chat-commands');
this.applyInputFontColor();
this.bindEvents();
requestAnimationFrame(() => {
chatPanel.classList.add('group-chat--visible');
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
});
if (this._triggerButton) {
this._triggerButton.style.display = 'none';
}
}
/**
* 绑定输入、发送、滚动加载等交互事件。
*/
private bindEvents(): void {
if (
!this._chatPanel ||
!this._sendButton ||
!this._input ||
!this._commandsPanel ||
!this._loadMoreButton ||
!this._messagesContainer
) {
return;
}
const closeButton = this._chatPanel.querySelector<HTMLButtonElement>('.group-chat__close');
if (closeButton) {
closeButton.addEventListener('click', async () => {
await this.close();
});
}
this._sendButton.addEventListener('click', async () => {
await this.handleSend();
});
this._input.addEventListener('keydown', async (event: KeyboardEvent) => {
const commandsVisible =
this._commandsPanel?.classList.contains('group-chat__commands--visible') ?? false;
if (commandsVisible) {
if (event.key === 'ArrowDown') {
event.preventDefault();
this.moveCommandSelection(1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
this.moveCommandSelection(-1);
return;
}
if (event.key === 'Tab') {
event.preventDefault();
this.moveCommandSelection(event.shiftKey ? -1 : 1);
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
await this.executeSelectedCommand();
return;
}
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
await this.handleSend();
}
});
this._input.addEventListener('input', () => {
if (!this._input || !this._commandsPanel) {
return;
}
this._input.style.height = 'auto';
this._input.style.height = `${Math.min(this._input.scrollHeight, 120)}px`;
const value = this._input.value;
if (value.startsWith('/')) {
this._commandsPanel.classList.add('group-chat__commands--visible');
this._selectedCommandIndex = 0;
this.filterCommandItems(value);
this.updateCommandSelection();
} else {
this._commandsPanel.classList.remove('group-chat__commands--visible');
this.filterCommandItems('');
}
});
const commandItems = this._commandsPanel.querySelectorAll<HTMLElement>(
'.group-chat__command-item'
);
commandItems.forEach((item, index) => {
item.addEventListener('click', () => {
if (!this._input || !this._commandsPanel) {
return;
}
this._input.value = item.dataset.command || '';
this._selectedCommandIndex = index;
this.updateCommandSelection();
this._commandsPanel.classList.remove('group-chat__commands--visible');
this._input.focus();
});
});
this.updateCommandSelection();
this._loadMoreButton.addEventListener('click', async () => {
await this.loadMoreHistory();
});
this._messagesContainer.addEventListener('scroll', async () => {
if (
this._messagesContainer &&
this._messagesContainer.scrollTop === 0 &&
this._hasMoreHistory &&
!this._isLoadingHistory &&
this._loadedHistoryCount < this._maxHistoryCount
) {
await this.loadMoreHistory();
}
});
}
/**
* 发送入口,自动识别命令或普通消息。
*/
private async handleSend(): Promise<void> {
if (!this._input) {
return;
}
const content = this._input.value.trim();
if (!content) {
return;
}
if (content.startsWith('/')) {
await this.handleCommand(content);
return;
}
await this.sendMessage();
}
/**
* 处理斜杠命令。
*/
private async handleCommand(command: string): Promise<void> {
if (!this._input || !this._commandsPanel) {
return;
}
const [commandName, ...args] = command.split(/\s+/);
const commandArg = args.join(' ').trim();
if (commandName === '/canvas') {
this._input.value = '';
this._input.style.height = 'auto';
this._commandsPanel.classList.remove('group-chat__commands--visible');
canvasChat.open();
if (!canvasChat.drawingUser) {
await canvasChat.requestAccess();
}
return;
}
if (commandName === '/font-color') {
await this.handleFontColorCommand(commandArg);
return;
}
await this.sendMessage();
}
/**
* 处理字体颜色命令。
*/
private async handleFontColorCommand(colorArg: string): Promise<void> {
if (!this._input || !this._commandsPanel) {
return;
}
let color = colorArg;
if (!color) {
const pickedColor = await this.pickFontColor();
if (!pickedColor) {
return;
}
color = pickedColor;
}
const normalizedColor = this.normalizeColor(color);
if (!normalizedColor) {
await uiFeedbackService.alert('颜色格式无效,请使用 #RRGGBB、#RGB 或 rgb(r,g,b)');
return;
}
if (!this.isConnectionReady() || !this._connection) {
uiFeedbackService.toast('当前未连接群聊,无法设置颜色', ToastType.Warning);
return;
}
try {
await this._connection.invoke('SetFontColor', normalizedColor);
this.saveLocalFontColor(normalizedColor);
this.applyInputFontColor();
uiFeedbackService.toast(`文字颜色已设置为 ${normalizedColor}`, ToastType.Success);
this._input.value = '';
this._input.style.height = 'auto';
this._commandsPanel.classList.remove('group-chat__commands--visible');
} catch (error) {
logger.outPutError('设置字体颜色失败', error);
uiFeedbackService.toast('设置字体颜色失败', ToastType.Error);
}
}
/**
* 弹出颜色选择器并返回选中值。
*/
private async pickFontColor(): Promise<string | null> {
return await new Promise((resolve) => {
const content = document.createElement('div');
content.className = 'group-chat__font-color-dialog';
const title = document.createElement('p');
title.className = 'group-chat__font-color-title';
title.textContent = '请选择聊天字体颜色';
const picker = document.createElement('input');
picker.type = 'color';
picker.className = 'group-chat__font-color-picker';
picker.value = this._localFontColor ?? '#000000';
const actions = document.createElement('div');
actions.className = 'group-chat__font-color-actions';
const cancelButton = document.createElement('button');
cancelButton.type = 'button';
cancelButton.className = 'dialog__btn dialog__btn--default';
cancelButton.textContent = '取消';
const confirmButton = document.createElement('button');
confirmButton.type = 'button';
confirmButton.className = 'dialog__btn dialog__btn--primary';
confirmButton.textContent = '确定';
actions.appendChild(cancelButton);
actions.appendChild(confirmButton);
content.appendChild(title);
content.appendChild(picker);
content.appendChild(actions);
const { overlay } = uiFeedbackService.createDialog(
'字体颜色',
content,
DialogType.Custom,
{
showHeader: false,
showFooter: false,
}
);
let resolved = false;
const finish = (value: string | null): void => {
if (resolved) {
return;
}
resolved = true;
uiFeedbackService.closeDialog(overlay);
resolve(value);
};
cancelButton.addEventListener('click', () => {
finish(null);
});
confirmButton.addEventListener('click', () => {
finish(picker.value || null);
});
overlay.addEventListener('click', (event: MouseEvent) => {
if (event.target === overlay) {
finish(null);
}
});
setTimeout(() => {
picker.focus();
}, 0);
});
}
/**
* 标准化颜色值,仅允许 Hex 与 rgb。
*/
private normalizeColor(inputColor: string): string | null {
const value = inputColor.trim();
const hexPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
if (hexPattern.test(value)) {
return value;
}
const rgbPattern = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i;
const rgbMatch = value.match(rgbPattern);
if (!rgbMatch) {
return null;
}
const red = Number(rgbMatch[1]);
const green = Number(rgbMatch[2]);
const blue = Number(rgbMatch[3]);
if ([red, green, blue].some((num) => num < 0 || num > 255)) {
return null;
}
return `rgb(${red},${green},${blue})`;
}
/**
* 命令列表选择移动。
*/
private moveCommandSelection(delta: number): void {
const commandItems = this.getCommandItems(true);
if (commandItems.length === 0) {
return;
}
const total = commandItems.length;
this._selectedCommandIndex = (this._selectedCommandIndex + delta + total) % total;
this.updateCommandSelection();
}
/**
* 执行当前选中的命令。
*/
private async executeSelectedCommand(): Promise<void> {
if (!this._input) {
return;
}
const commandItems = this.getCommandItems(true);
if (commandItems.length === 0) {
await this.handleSend();
return;
}
const selected = commandItems[this._selectedCommandIndex];
const selectedCommand = selected?.dataset.command;
if (!selectedCommand) {
await this.handleSend();
return;
}
const currentValue = this._input.value.trim();
const commandValue = this.replaceCommandPrefix(currentValue, selectedCommand);
this._input.value = commandValue;
await this.handleCommand(commandValue);
}
/**
* 用选中命令替换当前输入前缀。
*/
private replaceCommandPrefix(currentValue: string, selectedCommand: string): string {
const trimmed = currentValue.trim();
if (trimmed === '/' || trimmed === '') {
return selectedCommand;
}
const parts = trimmed.split(/\s+/);
const first = parts[0];
const rest = parts.slice(1).join(' ');
if (!first.startsWith('/')) {
return selectedCommand;
}
if (rest) {
return `${selectedCommand} ${rest}`;
}
return selectedCommand;
}
/**
* 根据输入前缀筛选命令列表。
*/
private filterCommandItems(inputValue: string): void {
const commandItems = this.getCommandItems(false);
if (commandItems.length === 0) {
return;
}
const firstPart = inputValue.trim().split(/\s+/)[0] || '';
const prefix = firstPart.startsWith('/') ? firstPart.toLowerCase() : '';
commandItems.forEach((item) => {
const command = (item.dataset.command || '').toLowerCase();
const shouldShow = !prefix || command.startsWith(prefix);
if (shouldShow) {
item.classList.remove('group-chat__command-item--hidden');
} else {
item.classList.add('group-chat__command-item--hidden');
}
});
const visibleItems = this.getCommandItems(true);
if (visibleItems.length === 0) {
this._selectedCommandIndex = 0;
return;
}
if (this._selectedCommandIndex >= visibleItems.length) {
this._selectedCommandIndex = 0;
}
}
/**
* 获取命令项列表。
*/
private getCommandItems(visibleOnly: boolean): HTMLElement[] {
if (!this._commandsPanel) {
return [];
}
const items = Array.from(
this._commandsPanel.querySelectorAll<HTMLElement>('.group-chat__command-item')
);
if (!visibleOnly) {
return items;
}
return items.filter((item) => {
return !item.classList.contains('group-chat__command-item--hidden');
});
}
/**
* 更新命令项选中样式。
*/
private updateCommandSelection(): void {
const allItems = this.getCommandItems(false);
allItems.forEach((item) => {
item.classList.remove('group-chat__command-item--active');
});
const commandItems = this.getCommandItems(true);
if (commandItems.length === 0) {
return;
}
const selectedItem = commandItems[this._selectedCommandIndex];
if (selectedItem) {
selectedItem.classList.add('group-chat__command-item--active');
}
}
/**
* 建立 SignalR 连接并注册客户端回调。
*/
private async connect(): Promise<void> {
try {
const options: IHttpConnectionOptions = {
skipNegotiation: true,
transport: HttpTransportType.WebSockets,
};
this._connection = new HubConnectionBuilder()
.withUrl('/groupchat', options)
.configureLogging(LogLevel.None)
.withAutomaticReconnect()
.build();
this._connection.on('OnMessageReceived', (message: GroupChatMessage) => {
this.addMessage(message, message.userId === this._currentUserId);
});
this._connection.on('OnUserJoined', (user: GroupChatUser) => {
this.addSystemMessage(`${user.name} 加入了群组`);
});
this._connection.on('OnUserLeft', (user: GroupChatUser) => {
this.addSystemMessage(`${user.name} 离开了群组`);
});
this._connection.on(
'OnHistoryLoaded',
(messages: GroupChatMessage[], hasMore: boolean) => {
this.handleHistoryLoaded(messages, hasMore);
}
);
this._connection.on('OnError', (error: string) => {
uiFeedbackService.toast(error, ToastType.Error);
});
await this._connection.start();
const currentUser = await this._connection.invoke<GroupChatCurrentUser>(
'JoinGroup',
null
);
if (currentUser) {
this._currentUserId = currentUser.id;
canvasChat.setUserId(this._currentUserId);
}
await this.syncFontColorOnConnect();
canvasChat.init(this._connection);
await this.loadInitialHistory();
logger.outPutSuccess('已连接到群组聊天');
uiFeedbackService.toast('已连接到群组聊天', ToastType.Success);
} catch (error) {
logger.outPutError('连接群组聊天失败', error);
uiFeedbackService.toast('连接失败,请刷新页面重试', ToastType.Error);
}
}
/**
* 首次加载历史记录。
*/
private async loadInitialHistory(): Promise<void> {
if (!this.isConnectionReady() || !this._connection) {
return;
}
this._loadedPageIndex = 1;
this._loadedHistoryCount = 0;
this._hasMoreHistory = true;
await this._connection.invoke('GetHistory', 1, 50);
}
/**
* 加载更多历史记录并保持滚动位置。
*/
private async loadMoreHistory(): Promise<void> {
if (
!this.isConnectionReady() ||
!this._connection ||
!this._loadMoreButton ||
!this._messagesContainer ||
this._isLoadingHistory ||
!this._hasMoreHistory ||
this._loadedHistoryCount >= this._maxHistoryCount
) {
return;
}
this._isLoadingHistory = true;
this._loadMoreButton.style.display = 'block';
this._loadMoreButton.classList.add('group-chat__load-more--loading');
const textSpan = this._loadMoreButton.querySelector<HTMLElement>('span');
if (textSpan) {
textSpan.textContent = '加载中...';
}
try {
const nextPage = this._loadedPageIndex + 1;
const scrollHeight = this._messagesContainer.scrollHeight;
const scrollTop = this._messagesContainer.scrollTop;
await this._connection.invoke('GetHistory', nextPage, 50);
requestAnimationFrame(() => {
if (!this._messagesContainer) {
return;
}
const newScrollHeight = this._messagesContainer.scrollHeight;
this._messagesContainer.scrollTop = newScrollHeight - scrollHeight + scrollTop;
});
} catch (error) {
logger.outPutError('加载历史记录失败', error);
uiFeedbackService.toast('加载历史记录失败', ToastType.Error);
} finally {
this._isLoadingHistory = false;
this._loadMoreButton.classList.remove('group-chat__load-more--loading');
}
}
/**
* 处理历史记录数据并更新界面。
*/
private handleHistoryLoaded(messages: GroupChatMessage[], hasMore: boolean): void {
if (!this._messagesContainer || !this._loadMoreButton) {
return;
}
this._hasMoreHistory = hasMore;
this._loadedPageIndex += 1;
this._loadedHistoryCount += messages.length;
if (this._loadedHistoryCount >= this._maxHistoryCount) {
this._hasMoreHistory = false;
this._loadMoreButton.style.display = 'none';
} else if (!hasMore) {
this._loadMoreButton.style.display = 'none';
} else {
this._loadMoreButton.style.display = 'block';
const textSpan = this._loadMoreButton.querySelector<HTMLElement>('span');
if (textSpan) {
textSpan.textContent = '加载更多...';
}
}
const fragment = document.createDocumentFragment();
messages.forEach((message) => {
const isOwn = message.userId === this._currentUserId;
const messageElement = this.createMessageElement(message, isOwn);
fragment.insertBefore(messageElement, fragment.firstChild);
});
this._messagesContainer.insertBefore(fragment, this._loadMoreButton.nextSibling);
if (this._loadedPageIndex === 2) {
this.scrollToBottom();
}
}
/**
* 发送普通文本消息。
*/
private async sendMessage(): Promise<void> {
if (!this._input || !this._sendButton || !this._connection || !this.isConnectionReady()) {
return;
}
const message = this._input.value.trim();
if (!message) {
return;
}
this._sendButton.disabled = true;
this._input.disabled = true;
try {
await this._connection.invoke('SendMessage', message);
this._input.value = '';
this._input.style.height = 'auto';
} catch (error) {
logger.outPutError('发送消息失败', error);
uiFeedbackService.toast('发送失败,请重试', ToastType.Error);
} finally {
this._sendButton.disabled = false;
this._input.disabled = false;
this._input.focus();
}
}
/**
* 追加一条聊天消息。
*/
private addMessage(message: GroupChatMessage, isOwn: boolean = false): void {
if (!this._messagesContainer) {
return;
}
if (isOwn && !this._currentUserId) {
this._currentUserId = message.userId;
}
const messageElement = this.createMessageElement(message, isOwn);
this._messagesContainer.appendChild(messageElement);
this.scrollToBottom();
}
/**
* 创建消息 DOM。
*/
private createMessageElement(message: GroupChatMessage, isOwn: boolean = false): HTMLElement {
const messageDiv = document.createElement('div');
const ownClass = isOwn ? ' group-chat__message--own' : '';
messageDiv.className = `group-chat__message${ownClass}`;
const avatar = document.createElement('img');
avatar.className = 'group-chat__avatar';
avatar.src = message.avatar;
avatar.alt = message.userName;
const contentDiv = document.createElement('div');
contentDiv.className = 'group-chat__message-content';
const username = document.createElement('div');
username.className = 'group-chat__username';
username.textContent = message.userName;
const bubble = document.createElement('div');
bubble.className = 'group-chat__bubble';
bubble.textContent = message.message;
const serverColor = message.fontColor ? this.normalizeColor(message.fontColor) : null;
if (isOwn) {
if (this._localFontColor) {
bubble.style.color = this._localFontColor;
} else if (serverColor) {
bubble.style.color = serverColor;
this.saveLocalFontColor(serverColor);
}
if (this._localFontColor && serverColor && this._localFontColor !== serverColor) {
void this.syncServerFontColor(this._localFontColor);
}
} else if (serverColor) {
bubble.style.color = serverColor;
}
const timestamp = document.createElement('div');
timestamp.className = 'group-chat__timestamp';
timestamp.textContent = this.formatTime(message.sendTime);
contentDiv.appendChild(username);
contentDiv.appendChild(bubble);
contentDiv.appendChild(timestamp);
messageDiv.appendChild(avatar);
messageDiv.appendChild(contentDiv);
return messageDiv;
}
/**
* 追加一条系统消息。
*/
private addSystemMessage(text: string): void {
if (!this._messagesContainer) {
return;
}
const systemDiv = document.createElement('div');
systemDiv.className = 'group-chat__system-message';
systemDiv.textContent = text;
this._messagesContainer.appendChild(systemDiv);
this.scrollToBottom();
}
/**
* 统一时间展示格式。
*/
private formatTime(dateTime: string): string {
const date = new Date(dateTime);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* 滚动消息区到底部。
*/
private scrollToBottom(): void {
requestAnimationFrame(() => {
if (!this._messagesContainer) {
return;
}
this._messagesContainer.scrollTop = this._messagesContainer.scrollHeight;
});
}
/**
* 关闭群聊并释放资源。
*/
public async close(): Promise<void> {
if (!this._isOpen) {
return;
}
if (this._connection) {
try {
await this._connection.stop();
} catch (error) {
logger.outPutError('断开连接失败', error);
}
}
if (this._chatPanel) {
this._chatPanel.classList.remove('group-chat--visible');
setTimeout(() => {
if (this._chatPanel && this._chatPanel.parentNode) {
this._chatPanel.parentNode.removeChild(this._chatPanel);
}
}, 300);
}
this._isOpen = false;
this._connection = null;
this._currentUserId = null;
this._loadedPageIndex = 0;
this._hasMoreHistory = true;
this._isLoadingHistory = false;
this._loadedHistoryCount = 0;
this._chatPanel = null;
this._messagesContainer = null;
this._loadMoreButton = null;
this._input = null;
this._sendButton = null;
this._commandsPanel = null;
if (util.isMobile()) {
this.enableBodyScroll();
if (window.location.hash === this._chatHash) {
pushPageHistory(window.location.pathname + window.location.search);
}
}
if (this._triggerButton) {
this._triggerButton.style.display = 'flex';
}
}
}
export const groupChat = new GroupChat();
⚠⚠ 以下内容为AI分析的结果,请根据实际情况进行判断。
这是一个功能完整的群组聊天模块,基于SignalR实现实时通信。让我详细解释一下代码的主要功能和结构:
主要功能
1. 群组聊天核心功能
- 实时消息收发:使用SignalR WebSocket连接实现实时通信
- 历史记录加载:支持分页加载历史聊天记录,最多1000条
- 用户管理:显示用户进入/离开群组的系统消息
2. 特殊命令系统
- /canvas:开启"你画我猜"游戏功能
- /font-color:设置聊天文字颜色,支持颜色选择器
3. 移动端优化
- 响应式设计:适配移动端界面
- 键盘适配:处理移动端键盘弹出时的视口变化
- 返回键支持:移动端可通过返回键关闭聊天面板
- 滚动锁定:打开聊天时锁定页面滚动
核心类结构
私有属性
private _connection: HubConnection | null = null; // SignalR连接
private _isOpen: boolean = false; // 聊天面板状态
private _currentUserId: string | null = null; // 当前用户ID
private _loadedPageIndex: number = 0; // 已加载页面索引
private _hasMoreHistory: boolean = true; // 是否还有更多历史记录
主要方法
初始化相关
init():初始化基础功能createTriggerButton():创建聊天触发按钮setupConsoleCommand():注册控制台命令window.openGroupChat()
连接管理
connect():建立SignalR连接并注册事件回调close():关闭连接并清理资源
界面交互
createUI():动态创建聊天面板DOMbindEvents():绑定各种交互事件handleSend():处理消息发送(自动识别命令/普通消息)
消息处理
addMessage():添加聊天消息addSystemMessage():添加系统消息loadMoreHistory():加载更多历史记录
命令系统
handleCommand():处理斜杠命令handleFontColorCommand():处理字体颜色设置moveCommandSelection():命令列表导航
特色功能
1. 字体颜色同步
- 本地存储用户颜色偏好
- 与服务端同步颜色设置
- 支持可视化颜色选择器
2. 命令自动补全
- 输入
/显示可用命令列表 - 支持键盘导航(上下箭头、Tab键)
- 智能过滤匹配命令
3. 历史记录管理
- 滚动到顶部自动加载更多历史
- 保持滚动位置不跳跃
- 限制最大加载数量防止内存溢出
4. 移动端体验优化
- 动态视口高度计算(处理iOS Safari地址栏)
- 键盘弹出时的界面适配
- Hash路由支持返回键关闭
使用方式
开启群聊
// 控制台命令
window.openGroupChat()
// 或通过触发按钮(首次使用后显示)
可用命令
/canvas # 开启你画我猜游戏
/font-color # 设置字体颜色
/font-color #ff0000 # 直接设置红色
技术亮点
- 状态管理:完善的连接状态检查和错误处理
- 性能优化:使用
requestAnimationFrame优化DOM操作 - 用户体验:丰富的交互反馈和加载状态
- 可扩展性:模块化设计,易于添加新命令
- 兼容性:全面的移动端适配和浏览器兼容
这个模块实现了一个功能丰富、用户体验良好的现代化群聊系统,特别注重移动端体验和实时交互性能。
AI 正在分析代码…
评论加载中...