import { request } from "../../../utils/request"; import { shopId } from "../../../env"; import { join, leave, addCustomEventListener, removeCustomEventListener, } from "../../../utils/server-sent-events"; type CommentMessage = { id: string; name: string; msg: string; kind?: "system" | "chat"; }; type CommentFeatureState = { approved: boolean; unreadReminder: boolean; optimizedInput: boolean; topTicker: boolean; }; interface CommentProperties { enhancementApproved: boolean; avatarUrl: string; } interface CommentData { list: CommentMessage[]; displayList: CommentMessage[]; scrollTop: number; isAtBottom: boolean; maxVisible: number; itemHeight: number; MAX_HISTORY: number; joined: boolean; listener: boolean; lastMsgKey: string; inputValue: string; canSubmit: boolean; inputFocused: boolean; inputPlaceholderStyle: string; showGuidePopup: boolean; guideTitle: string; guideSubtitle: string; guideImages: string[]; heartParticles: any[]; heartButtonActive: boolean; unreadMessageCount: number; topNoticeText: string; topNoticeVisible: boolean; featureState: CommentFeatureState; } interface CommentInstance extends WechatMiniprogram.Component.TrivialInstance { data: CommentData; properties: CommentProperties; _sseListener?: (payload: any) => void; _heartButtonTimer?: number | null; _heartTimers?: number[]; _topNoticeTimer?: number | null; _topNoticeQueue?: string[]; safeLeave: () => Promise; bindSseListener: () => void; unbindSseListener: () => void; appendMsgFromSse: ( payload: { name: string; msg: string }, options?: { kind?: "system" | "chat"; skipTopNotice?: boolean }, ) => void; handleSubmit: (e: any) => Promise; appendMsgText: (text: string) => void; createHeartParticle: (index: number) => any; removeHeartParticle: (id: string) => void; clearHeartEffects: () => void; safeVibrate: () => void; } const ITEM_HEIGHT = 60; const MAX_VISIBLE = 5; const MAX_HISTORY = 50; const SSE_CHANNEL = "12"; const SYSTEM_NOTICE = "欢迎来到直播间,请文明发言,禁止发布违规信息。"; const DEFAULT_GUIDE_SUBTITLE = "个人中心"; const DEFAULT_GUIDE_IMAGES = Object.freeze(["/assets/img/600.jpg"]); const DEFAULT_PLACEHOLDER_STYLE = "color:white;font-size:32rpx"; const ENHANCED_PLACEHOLDER_STYLE = "color:rgba(255,255,255,0.62);font-size:28rpx"; const TOP_NOTICE_DURATION = 3400; const REVIEW_READY_FEATURES = Object.freeze({ unreadReminder: true, optimizedInput: true, topTicker: true, }); function getChatChannelName() { return `${shopId}-${SSE_CHANNEL}`; } function createMessage( name: string, msg: string, kind: "system" | "chat" = "chat", ): CommentMessage { return { id: `msg_${Date.now()}_${Math.floor(Math.random() * 1000)}`, name, msg, kind, }; } function buildWelcomeMessage() { return createMessage("系统", SYSTEM_NOTICE, "system"); } function buildFeatureState(approved: boolean): CommentFeatureState { return { approved, unreadReminder: approved && REVIEW_READY_FEATURES.unreadReminder, optimizedInput: approved && REVIEW_READY_FEATURES.optimizedInput, topTicker: approved && REVIEW_READY_FEATURES.topTicker, }; } function sanitizeInput(value: unknown): string { return String(value ?? "") .replace(/\s+/g, " ") .trim(); } function buildGuideImages(avatarUrl: unknown): string[] { const normalizedAvatarUrl = sanitizeInput(avatarUrl); return normalizedAvatarUrl ? [normalizedAvatarUrl] : [...DEFAULT_GUIDE_IMAGES]; } function syncGuideImages(instance: CommentInstance, avatarUrl: unknown) { const nextGuideImages = buildGuideImages(avatarUrl); const currentGuideImages = instance.data.guideImages || []; const unchanged = currentGuideImages.length === nextGuideImages.length && currentGuideImages.every((item, index) => item === nextGuideImages[index]); if (unchanged) return; instance.setData({ guideImages: nextGuideImages, }); } function trimMessages(messages: CommentMessage[], maxHistory: number) { if (messages.length <= maxHistory) return messages; const pinnedMessage = messages[0]?.kind === "system" ? messages[0] : null; if (!pinnedMessage) { return messages.slice(-maxHistory); } const normalMessages = messages.slice(1); return [pinnedMessage, ...normalMessages.slice(-(maxHistory - 1))]; } function formatTopNoticeText(name: string, msg: string) { const safeName = sanitizeInput(name) || "新消息"; const preview = sanitizeInput(msg); const clippedPreview = preview.length > 18 ? `${preview.slice(0, 18)}...` : preview; return clippedPreview ? `${safeName}:${clippedPreview}` : `${safeName} 发来了一条新消息`; } function clearTopNotice(instance: CommentInstance) { if (instance._topNoticeTimer) { clearTimeout(instance._topNoticeTimer); instance._topNoticeTimer = null; } instance._topNoticeQueue = []; if (instance.data.topNoticeVisible || instance.data.topNoticeText) { instance.setData({ topNoticeVisible: false, topNoticeText: "", }); } } function playTopNoticeQueue(instance: CommentInstance) { if (!instance.data.featureState.topTicker || instance.data.topNoticeVisible) { return; } instance._topNoticeQueue = instance._topNoticeQueue || []; const nextNotice = instance._topNoticeQueue.shift(); if (!nextNotice) return; instance.setData( { topNoticeText: nextNotice, topNoticeVisible: false, }, () => { wx.nextTick(() => { instance.setData({ topNoticeVisible: true }); instance._topNoticeTimer = setTimeout(() => { instance.setData({ topNoticeVisible: false }, () => { instance._topNoticeTimer = null; playTopNoticeQueue(instance); }); }, TOP_NOTICE_DURATION); }); }, ); } function enqueueTopNotice(instance: CommentInstance, text: string) { if (!instance.data.featureState.topTicker || !text) return; instance._topNoticeQueue = instance._topNoticeQueue || []; instance._topNoticeQueue.push(text); if (!instance.data.topNoticeVisible) { playTopNoticeQueue(instance); } } function clearUnreadReminder(instance: CommentInstance) { if (!instance.data.unreadMessageCount) return; instance.setData({ unreadMessageCount: 0 }); } function scrollToLatest(instance: CommentInstance, clearUnread = false) { if (clearUnread) { clearUnreadReminder(instance); } instance.setData({ scrollTop: Date.now() }); } function syncFeatureApproval(instance: CommentInstance, approved: boolean) { const justEnabledTopTicker = !instance.data.featureState.topTicker && approved && REVIEW_READY_FEATURES.topTicker; const featureState = buildFeatureState(approved); instance.setData( { featureState, inputPlaceholderStyle: featureState.optimizedInput ? ENHANCED_PLACEHOLDER_STYLE : DEFAULT_PLACEHOLDER_STYLE, inputFocused: featureState.optimizedInput ? instance.data.inputFocused : false, unreadMessageCount: featureState.unreadReminder ? instance.data.unreadMessageCount : 0, topNoticeText: featureState.topTicker ? instance.data.topNoticeText : "", topNoticeVisible: featureState.topTicker ? instance.data.topNoticeVisible : false, }, () => { if (justEnabledTopTicker) { enqueueTopNotice( instance, formatTopNoticeText("直播提醒", SYSTEM_NOTICE), ); } }, ); if (!featureState.topTicker) { clearTopNotice(instance); } } Component({ properties: { enhancementApproved: { type: Boolean, value: false, observer(this: CommentInstance, nextValue: boolean) { syncFeatureApproval(this, !!nextValue); }, }, avatarUrl: { type: String, value: "", observer(this: CommentInstance, nextValue: string) { syncGuideImages(this, nextValue); }, }, }, data: { list: [] as CommentMessage[], displayList: [] as CommentMessage[], scrollTop: 999999, isAtBottom: true, maxVisible: MAX_VISIBLE, itemHeight: ITEM_HEIGHT, MAX_HISTORY, joined: false, listener: false, lastMsgKey: "", inputValue: "", canSubmit: false, inputFocused: false, inputPlaceholderStyle: DEFAULT_PLACEHOLDER_STYLE, showGuidePopup: false, guideTitle: "", guideSubtitle: DEFAULT_GUIDE_SUBTITLE, guideImages: [...DEFAULT_GUIDE_IMAGES], heartParticles: [] as any[], heartButtonActive: false, unreadMessageCount: 0, topNoticeText: "", topNoticeVisible: false, featureState: buildFeatureState(false), } as CommentData, lifetimes: { attached(this: CommentInstance) { const initialMessages = [buildWelcomeMessage()]; this._topNoticeQueue = []; this.setData( { list: initialMessages, displayList: initialMessages, }, () => { syncGuideImages(this, this.properties.avatarUrl); syncFeatureApproval(this, !!this.properties.enhancementApproved); this.bindSseListener(); setTimeout(() => { scrollToLatest(this); }, 50); }, ); }, detached(this: CommentInstance) { this.unbindSseListener(); this.safeLeave(); this.clearHeartEffects(); clearTopNotice(this); }, }, pageLifetimes: { show(this: CommentInstance) { this.bindSseListener(); wx.nextTick(async () => { if (this.data.joined) return; try { await join(getChatChannelName()); this.setData({ joined: true }); } catch (error) { console.warn("SSE join failed", error); } }); }, hide(this: CommentInstance) { this.unbindSseListener(); this.safeLeave(); this.clearHeartEffects(); clearTopNotice(this); }, }, methods: { async safeLeave(this: CommentInstance) { if (!this.data.joined) return; this.setData({ joined: false }); try { await leave(getChatChannelName()); } catch (error) { console.warn("SSE leave failed", error); } }, bindSseListener(this: CommentInstance) { if (this.data.listener) return; this.setData({ listener: true }); this._sseListener = (payload: any) => { if (!payload) return; const { name, msg } = payload; if (!msg && msg !== "") return; const key = `${name ?? ""}:${msg}`; if (key === this.data.lastMsgKey) return; this.setData({ lastMsgKey: key }); this.appendMsgFromSse({ name: name ?? "用户", msg, }); }; addCustomEventListener("pushMsg", this._sseListener); }, unbindSseListener(this: CommentInstance) { if (!this.data.listener) return; this.setData({ listener: false }); if (!this._sseListener) return; removeCustomEventListener("pushMsg", this._sseListener); this._sseListener = undefined; }, onScroll(this: CommentInstance, e: any) { const { scrollTop, scrollHeight } = e.detail; wx.createSelectorQuery() .in(this) .select("#scrollView") .boundingClientRect((rect) => { const clientHeight = rect?.height || 0; const isBottom = scrollTop + clientHeight >= scrollHeight - 20; if (isBottom !== this.data.isAtBottom) { this.setData({ isAtBottom: isBottom }); } if (isBottom) { clearUnreadReminder(this); } }) .exec(); }, appendMsgFromSse( this: CommentInstance, payload: { name: string; msg: string }, options: { kind?: "system" | "chat"; skipTopNotice?: boolean } = {}, ) { const messageName = sanitizeInput(payload.name) || (options.kind === "system" ? "系统" : "用户"); const messageText = String(payload.msg ?? ""); const nextMessage = createMessage( messageName, messageText, options.kind || "chat", ); const nextList = trimMessages( [...this.data.list, nextMessage], this.data.MAX_HISTORY, ); const shouldAutoScroll = this.data.isAtBottom; const shouldShowUnreadReminder = this.data.featureState.unreadReminder && !shouldAutoScroll && (options.kind || "chat") !== "system"; this.setData( { list: nextList, displayList: nextList, unreadMessageCount: shouldShowUnreadReminder ? this.data.unreadMessageCount + 1 : this.data.unreadMessageCount, }, () => { if (shouldAutoScroll) { scrollToLatest(this); } }, ); if (this.data.featureState.topTicker && !options.skipTopNotice) { enqueueTopNotice(this, formatTopNoticeText(messageName, messageText)); } }, handleInput(this: CommentInstance, e: any) { const value = e.detail.value || ""; this.setData({ inputValue: value, canSubmit: !!sanitizeInput(value), }); }, handleInputFocus(this: CommentInstance) { if (!this.data.featureState.optimizedInput) return; this.setData({ inputFocused: true }); }, handleInputBlur(this: CommentInstance) { if (!this.data.inputFocused) return; this.setData({ inputFocused: false }); }, submitCurrentInput(this: CommentInstance) { if (!this.data.canSubmit) return; this.handleSubmit({ detail: { value: this.data.inputValue, }, }); }, handleUnreadReminderTap(this: CommentInstance) { scrollToLatest(this, true); }, openGuidePopup(this: CommentInstance) { this.setData({ showGuidePopup: true, guideTitle: wx.getStorageSync("userName") || "当前用户", guideSubtitle: DEFAULT_GUIDE_SUBTITLE, }); }, closeGuidePopup(this: CommentInstance) { this.setData({ showGuidePopup: false, }); }, async handleSubmit(this: CommentInstance, e: any) { const value = sanitizeInput(e?.detail?.value ?? this.data.inputValue); if (!value) { wx.showToast({ title: "发送内容不能为空", icon: "none", duration: 2000, }); return; } try { await request({ options: { url: "/app/mini-app/push-chat-msg", data: { shopId, id: Number(SSE_CHANNEL), name: wx.getStorageSync("userName") || "微信用户", msg: value, }, }, isLoading: false, }); } catch (error) { console.log(error, "push chat message failed"); this.appendMsgText(value); } finally { this.setData({ inputValue: "", canSubmit: false, }); } }, appendMsgText(this: CommentInstance, text: string) { this.appendMsgFromSse({ name: wx.getStorageSync("userName") || "微信用户", msg: text, }); }, handleHeartTap(this: CommentInstance) { const particles = Array.from({ length: 6 }, (_, index) => this.createHeartParticle(index), ); this.setData({ heartParticles: [...this.data.heartParticles, ...particles], heartButtonActive: true, }); this.safeVibrate(); if (this._heartButtonTimer) { clearTimeout(this._heartButtonTimer); } this._heartButtonTimer = setTimeout(() => { this.setData({ heartButtonActive: false }); }, 220); this._heartTimers = this._heartTimers || []; particles.forEach((particle: any) => { const timer = setTimeout( () => { this.removeHeartParticle(particle.id); }, particle.duration + particle.delay + 260, ); this._heartTimers?.push(timer); }); }, createHeartParticle(index: number) { const palette = ["#ff4d6d", "#ff6b81", "#ff8399", "#ffb3c1"]; const pathClasses = ["path-left", "path-center", "path-right"]; const pulseClasses = ["pulse-small", "pulse-medium", "pulse-large"]; return { id: `heart_${Date.now()}_${index}_${Math.floor(Math.random() * 1000)}`, right: 16 + Math.floor(Math.random() * 28), bottom: 10 + Math.floor(Math.random() * 18), size: 34 + Math.floor(Math.random() * 10), color: palette[ (index + Math.floor(Math.random() * palette.length)) % palette.length ], duration: 920 + Math.floor(Math.random() * 320), delay: index * 40, rotate: -16 + Math.floor(Math.random() * 32), pathClass: pathClasses[ (index + Math.floor(Math.random() * pathClasses.length)) % pathClasses.length ], pulseClass: pulseClasses[index % pulseClasses.length], }; }, removeHeartParticle(this: CommentInstance, id: string) { this.setData({ heartParticles: this.data.heartParticles.filter( (item: any) => item.id !== id, ), }); }, clearHeartEffects(this: CommentInstance) { if (this._heartButtonTimer) { clearTimeout(this._heartButtonTimer); this._heartButtonTimer = null; } if (Array.isArray(this._heartTimers)) { this._heartTimers.forEach((timer) => clearTimeout(timer)); this._heartTimers = []; } this.setData({ heartParticles: [], heartButtonActive: false, }); }, safeVibrate() { if (typeof wx.vibrateShort !== "function") return; try { wx.vibrateShort(); } catch (error) { console.warn("vibrateShort failed", error); } }, }, });