feat: 电子药箱小程序 - 4Tab药品管理

- 电子药箱: 药品库存管理 + 手动录入 + 今日用药打卡
- 药品百科: 搜索 + 分类筛选 + 20种药品静态数据
- 惠教中心: 4条药品使用指南课程
- 我的: 家庭成员信息管理
- 自定义TabBar + navigation-bar组件
- SVG药品分类插图
This commit is contained in:
陈誉午
2026-07-29 11:20:52 +08:00
commit 994b267f5c
683 changed files with 43849 additions and 0 deletions
@@ -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