- 电子药箱: 药品库存管理 + 手动录入 + 今日用药打卡 - 药品百科: 搜索 + 分类筛选 + 20种药品静态数据 - 惠教中心: 4条药品使用指南课程 - 我的: 家庭成员信息管理 - 自定义TabBar + navigation-bar组件 - SVG药品分类插图
682 lines
18 KiB
TypeScript
682 lines
18 KiB
TypeScript
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<void>;
|
||
bindSseListener: () => void;
|
||
unbindSseListener: () => void;
|
||
appendMsgFromSse: (
|
||
payload: { name: string; msg: string },
|
||
options?: { kind?: "system" | "chat"; skipTopNotice?: boolean },
|
||
) => void;
|
||
handleSubmit: (e: any) => Promise<void>;
|
||
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);
|
||
}
|
||
},
|
||
},
|
||
});
|