feat: 电子药箱小程序 - 4Tab药品管理
- 电子药箱: 药品库存管理 + 手动录入 + 今日用药打卡 - 药品百科: 搜索 + 分类筛选 + 20种药品静态数据 - 惠教中心: 4条药品使用指南课程 - 我的: 家庭成员信息管理 - 自定义TabBar + navigation-bar组件 - SVG药品分类插图
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {
|
||||
"guide-popup": "/components/static/guide-popup/guide-popup"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
@import "variables";
|
||||
|
||||
.comment {
|
||||
padding: 0rpx $primary-gap;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 100rpx;
|
||||
z-index: 9;
|
||||
overflow: visible;
|
||||
|
||||
.top-notice-layer {
|
||||
position: absolute;
|
||||
left: $primary-gap;
|
||||
right: 190rpx;
|
||||
top: -86rpx;
|
||||
height: 64rpx;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.top-notice-pill {
|
||||
height: 64rpx;
|
||||
width: 100%;
|
||||
padding: 0 20rpx;
|
||||
border-radius: 999rpx;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
color: #f6fbff;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(12, 24, 52, 0.92) 0%,
|
||||
rgba(30, 78, 132, 0.78) 100%
|
||||
);
|
||||
border: 1rpx solid rgba(117, 207, 255, 0.24);
|
||||
box-shadow: 0 14rpx 36rpx rgba(0, 0, 0, 0.22);
|
||||
animation: top-notice-slide 3.4s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
.top-notice-tag {
|
||||
flex-shrink: 0;
|
||||
height: 36rpx;
|
||||
padding: 0 16rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #62f0ff 0%, #3cb6ff 100%);
|
||||
color: #07253d;
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.top-notice-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.comment-container {
|
||||
width: 470rpx;
|
||||
max-width: calc(100% - 260rpx);
|
||||
overflow: hidden;
|
||||
|
||||
.list-wrapper {
|
||||
will-change: transform;
|
||||
|
||||
.msg-item {
|
||||
line-height: 50rpx;
|
||||
border-radius: 30rpx;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
min-height: 50rpx;
|
||||
margin-top: $primary-gap / 2;
|
||||
font-weight: 700;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
word-break: break-all;
|
||||
padding: 0rpx $primary-gap;
|
||||
|
||||
&.is-system {
|
||||
background: rgba(30, 65, 117, 0.48);
|
||||
border: 1rpx solid rgba(104, 196, 255, 0.18);
|
||||
}
|
||||
|
||||
.msg-item-name {
|
||||
color: #76dbfd;
|
||||
margin-right: $primary-gap;
|
||||
}
|
||||
|
||||
.msg-item-text {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.unread-reminder {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 116rpx;
|
||||
transform: translateX(-50%);
|
||||
min-width: 180rpx;
|
||||
height: 56rpx;
|
||||
padding: 0 22rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
box-sizing: border-box;
|
||||
color: #ffffff;
|
||||
background: rgba(14, 24, 44, 0.9);
|
||||
box-shadow: 0 10rpx 24rpx rgba(0, 0, 0, 0.18);
|
||||
animation: unread-reminder-pop 0.24s ease-out;
|
||||
}
|
||||
|
||||
.unread-reminder-dot {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-radius: 50%;
|
||||
background: #ff6a88;
|
||||
box-shadow: 0 0 0 8rpx rgba(255, 106, 136, 0.12);
|
||||
}
|
||||
|
||||
.unread-reminder-text {
|
||||
font-size: 24rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.input-shell {
|
||||
margin-top: $primary-gap * 1.5;
|
||||
width: calc(100% - 260rpx);
|
||||
max-width: 470rpx;
|
||||
height: 90rpx;
|
||||
padding: 0rpx $primary-gap;
|
||||
border-radius: 999rpx;
|
||||
box-sizing: border-box;
|
||||
background: rgba(0, 0, 0, 0.56);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&.is-enhanced {
|
||||
padding-left: 18rpx;
|
||||
padding-right: 10rpx;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(12, 19, 30, 0.88) 0%,
|
||||
rgba(20, 36, 59, 0.84) 100%
|
||||
);
|
||||
border: 1rpx solid rgba(135, 195, 255, 0.16);
|
||||
box-shadow: 0 18rpx 40rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
&.is-focused {
|
||||
transform: translateY(-2rpx);
|
||||
box-shadow: 0 18rpx 42rpx rgba(48, 137, 255, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.input-leading {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
margin-right: 14rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-leading-dot {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #5dfff0 0%, #4ab7ff 100%);
|
||||
box-shadow: 0 0 0 10rpx rgba(73, 207, 255, 0.1);
|
||||
animation: input-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.msg-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
line-height: 90rpx;
|
||||
color: white;
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-send {
|
||||
min-width: 108rpx;
|
||||
height: 68rpx;
|
||||
padding: 0 24rpx;
|
||||
border-radius: 999rpx;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2rpx;
|
||||
transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&.is-ready {
|
||||
background: linear-gradient(135deg, #53d6ff 0%, #2f8cff 100%);
|
||||
color: #ffffff;
|
||||
transform: translateY(-1rpx);
|
||||
box-shadow: 0 10rpx 26rpx rgba(47, 140, 255, 0.26);
|
||||
}
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
position: absolute;
|
||||
right: $primary-gap;
|
||||
bottom: 7rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(17, 17, 17, 0.58);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 12rpx 28rpx rgba(0, 0, 0, 0.14);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.action-btn-hover {
|
||||
transform: scale(0.94);
|
||||
background: rgba(17, 17, 17, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.action-share {
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn-heart {
|
||||
background: rgba(255, 61, 104, 0.18);
|
||||
border: 2rpx solid rgba(255, 108, 143, 0.45);
|
||||
|
||||
&.is-active {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 12rpx 30rpx rgba(255, 61, 104, 0.24);
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 38rpx;
|
||||
height: 38rpx;
|
||||
}
|
||||
|
||||
.action-icon-heart {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
.heart-layer {
|
||||
position: absolute;
|
||||
right: $primary-gap + 12rpx;
|
||||
bottom: 84rpx;
|
||||
width: 160rpx;
|
||||
height: 340rpx;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.heart-particle {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.heart-glyph {
|
||||
display: none;
|
||||
line-height: 1;
|
||||
text-shadow: 0 8rpx 20rpx rgba(255, 77, 109, 0.2);
|
||||
}
|
||||
|
||||
.heart-fly-icon {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pulse-small {
|
||||
animation: heart-pop 0.72s ease-out;
|
||||
}
|
||||
|
||||
.pulse-medium {
|
||||
animation: heart-pop 0.84s ease-out;
|
||||
}
|
||||
|
||||
.pulse-large {
|
||||
animation: heart-pop 0.96s ease-out;
|
||||
}
|
||||
|
||||
.path-left {
|
||||
animation-name: float-heart-left;
|
||||
}
|
||||
|
||||
.path-center {
|
||||
animation-name: float-heart-center;
|
||||
}
|
||||
|
||||
.path-right {
|
||||
animation-name: float-heart-right;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes input-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.9);
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes unread-reminder-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(12rpx);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes top-notice-slide {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(120%, 0, 0) scale(0.96);
|
||||
}
|
||||
|
||||
12% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
|
||||
86% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(-16%, 0, 0) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes heart-pop {
|
||||
0% {
|
||||
transform: scale(0.6);
|
||||
}
|
||||
|
||||
45% {
|
||||
transform: scale(1.18);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-heart-left {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 0, 0) scale(0.45);
|
||||
}
|
||||
|
||||
18% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(-48rpx, -240rpx, 0) scale(1.18);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-heart-center {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 0, 0) scale(0.45);
|
||||
}
|
||||
|
||||
18% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(8rpx, -260rpx, 0) scale(1.24);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-heart-right {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 0, 0) scale(0.45);
|
||||
}
|
||||
|
||||
18% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(54rpx, -228rpx, 0) scale(1.12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
<view class="comment">
|
||||
<view wx:if="{{featureState.topTicker && topNoticeVisible}}" class="top-notice-layer">
|
||||
<view class="top-notice-pill">
|
||||
<text class="top-notice-tag">LIVE</text>
|
||||
<text class="top-notice-text">{{topNoticeText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
scroll-y="true"
|
||||
class="comment-container"
|
||||
id="scrollView"
|
||||
style="height: {{maxVisible * itemHeight + 24 * (maxVisible - 1)}}rpx"
|
||||
bindscroll="onScroll"
|
||||
scroll-with-animation="true"
|
||||
scroll-top="{{scrollTop}}"
|
||||
>
|
||||
<view id="list" class="list-wrapper">
|
||||
<view wx:for="{{displayList}}" wx:key="id">
|
||||
<view class="msg-item {{item.kind === 'system' ? 'is-system' : ''}}">
|
||||
<text class="msg-item-name" wx:if="{{item.name}}">{{item.name}}</text>
|
||||
<text class="msg-item-text" wx:if="{{item.msg || item.msg === ''}}">{{item.msg}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view
|
||||
wx:if="{{featureState.unreadReminder && unreadMessageCount > 0}}"
|
||||
class="unread-reminder"
|
||||
bindtap="handleUnreadReminderTap">
|
||||
<view class="unread-reminder-dot"></view>
|
||||
<text class="unread-reminder-text">{{unreadMessageCount}} 条新消息</text>
|
||||
</view>
|
||||
|
||||
<view class="input-shell {{featureState.optimizedInput ? 'is-enhanced' : ''}} {{inputFocused ? 'is-focused' : ''}}">
|
||||
<view wx:if="{{featureState.optimizedInput}}" class="input-leading">
|
||||
<view class="input-leading-dot"></view>
|
||||
</view>
|
||||
|
||||
<input
|
||||
value="{{inputValue}}"
|
||||
confirm-type="send"
|
||||
bindinput="handleInput"
|
||||
bindconfirm="handleSubmit"
|
||||
bindfocus="handleInputFocus"
|
||||
bindblur="handleInputBlur"
|
||||
class="msg-input"
|
||||
cursor-spacing="18"
|
||||
placeholder="说点什么吧~"
|
||||
placeholder-style="{{inputPlaceholderStyle}}"
|
||||
/>
|
||||
|
||||
<view
|
||||
wx:if="{{featureState.optimizedInput}}"
|
||||
class="input-send {{canSubmit ? 'is-ready' : ''}}"
|
||||
bindtap="submitCurrentInput"
|
||||
>
|
||||
发送
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-bar">
|
||||
<view class="action-btn" hover-class="action-btn-hover" bindtap="openGuidePopup">
|
||||
<image class="action-icon" src="./icons/gallery.svg" mode="aspectFit" />
|
||||
</view>
|
||||
|
||||
<button class="action-btn action-share" hover-class="action-btn-hover" open-type="share">
|
||||
<image class="action-icon" src="./icons/share.svg" mode="aspectFit" />
|
||||
</button>
|
||||
|
||||
<view
|
||||
class="action-btn action-btn-heart {{heartButtonActive ? 'is-active' : ''}}"
|
||||
hover-class="action-btn-hover"
|
||||
bindtap="handleHeartTap"
|
||||
>
|
||||
<image class="action-icon action-icon-heart" src="./icons/heart.svg" mode="aspectFit" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="heart-layer">
|
||||
<view
|
||||
wx:for="{{heartParticles}}"
|
||||
wx:key="id"
|
||||
class="heart-particle {{item.pathClass}}"
|
||||
style="right: {{item.right}}rpx; bottom: {{item.bottom}}rpx; animation-duration: {{item.duration}}ms; animation-delay: {{item.delay}}ms;"
|
||||
>
|
||||
<image class="heart-fly-icon {{item.pulseClass}}" src="./icons/heart.svg" mode="aspectFit" />
|
||||
<text
|
||||
class="heart-glyph {{item.pulseClass}}"
|
||||
style="font-size: {{item.size}}rpx; color: {{item.color}}; transform: rotate({{item.rotate}}deg);"
|
||||
>❤</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<guide-popup
|
||||
show="{{showGuidePopup}}"
|
||||
title="{{guideTitle}}"
|
||||
subtitle="{{guideSubtitle}}"
|
||||
images="{{guideImages}}"
|
||||
bind:close="closeGuidePopup"
|
||||
/>
|
||||
</view>
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="17" y="22" width="62" height="52" rx="12" stroke="#FFFFFF" stroke-width="6"/>
|
||||
<circle cx="63" cy="38" r="6" fill="#FFFFFF"/>
|
||||
<path d="M27 62L41 48L52 58L61 49L69 58V66H27V62Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 311 B |
@@ -0,0 +1,9 @@
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M48 80C46.1 80 44.3 79.3 42.8 78L22.3 59.2C10.2 48.1 9.1 29.4 19.8 17.9C30.4 6.5 48 5.8 59.4 16.3L62 18.7L64.6 16.3C76 5.8 93.6 6.5 104.2 17.9C114.9 29.4 113.8 48.1 101.7 59.2L81.2 78C79.7 79.3 77.9 80 76 80C74.1 80 72.3 79.3 70.8 78L62 69.9L53.2 78C51.7 79.3 49.9 80 48 80Z" fill="url(#paint0_linear_420_7)" transform="translate(-14 0)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_420_7" x1="48" y1="10" x2="48" y2="80" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF8AA1"/>
|
||||
<stop offset="1" stop-color="#FF325C"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 685 B |
@@ -0,0 +1,7 @@
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="28" cy="50" r="10" fill="#FFFFFF"/>
|
||||
<circle cx="66" cy="30" r="10" fill="#FFFFFF"/>
|
||||
<circle cx="68" cy="68" r="10" fill="#FFFFFF"/>
|
||||
<path d="M37 45L57 34" stroke="#FFFFFF" stroke-width="6" stroke-linecap="round"/>
|
||||
<path d="M37 55L58 64" stroke="#FFFFFF" stroke-width="6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
Reference in New Issue
Block a user