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,4 @@
{
"component": true,
"usingComponents": {}
}
@@ -0,0 +1,219 @@
.guide-popup {
position: fixed;
inset: 0;
z-index: 40;
.guide-mask {
position: absolute;
inset: 0;
background: rgba(10, 13, 24, 0.42);
backdrop-filter: blur(10rpx);
opacity: 0;
transition: opacity 0.28s ease;
}
.guide-shell {
position: absolute;
left: 0;
right: 0;
bottom: 0;
transform: translateY(calc(100% + 48rpx));
transition: transform 0.28s ease;
will-change: transform;
pointer-events: auto;
width: 100%;
}
.guide-drag-bar {
width: 88rpx;
height: 8rpx;
margin: 0 auto 18rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.72);
}
.guide-avatar-wrap {
position: absolute;
left: 50%;
top: -40rpx;
transform: translateX(-50%);
z-index: 2;
width: 132rpx;
height: 132rpx;
padding: 8rpx;
border-radius: 50%;
background: #edf0f7;
box-sizing: border-box;
box-shadow: 0 18rpx 42rpx rgba(11, 18, 35, 0.14);
display: flex;
align-items: center;
justify-content: center;
}
.guide-avatar {
width: 90%;
height: 90%;
border-radius: 50%;
border: 6rpx solid rgba(255, 255, 255, 0.95);
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.18);
background: #f6f7fb;
}
.guide-avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #ffd7dc 0%, #ff8ba2 100%);
color: #ffffff;
font-size: 40rpx;
font-weight: 700;
}
.guide-panel {
border-radius: 28rpx;
padding: 82rpx 24rpx 28rpx;
background: linear-gradient(180deg, #edf0f7 0%, #e7ebf4 100%);
box-shadow: 0 28rpx 70rpx rgba(11, 18, 35, 0.18);
box-sizing: border-box;
}
.guide-topbar {
position: relative;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.guide-name {
flex: 1;
text-align: center;
color: #2a2f3a;
font-size: 28rpx;
font-weight: 600;
line-height: 1.4;
box-sizing: border-box;
pointer-events: none;
}
.guide-entry {
position: absolute;
right: 5%;
top: -30%;
margin-left: auto;
display: flex;
align-items: center;
gap: 8rpx;
color: #2f3442;
z-index: 1;
}
.guide-entry-icon {
width: 28rpx;
height: 28rpx;
}
.guide-entry-text {
font-size: 28rpx;
line-height: 1.4;
}
.guide-balance-card {
margin-top: 26rpx;
padding: 22rpx 20rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.44);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.guide-balance-main {
flex: 1;
min-width: 0;
}
.guide-balance-label {
color: #5f6675;
font-size: 24rpx;
line-height: 1.4;
}
.guide-balance-value {
margin-top: 10rpx;
color: #ff285d;
font-size: 54rpx;
font-weight: 700;
line-height: 1;
}
.guide-balance-actions {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.guide-pill {
min-width: 120rpx;
padding: 0 22rpx;
height: 58rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.74);
color: #ff4774;
font-size: 24rpx;
line-height: 1;
box-sizing: border-box;
}
.guide-pill-primary {
background: linear-gradient(135deg, #ff5d87 0%, #ff3365 100%);
color: #ffffff;
}
.guide-grid {
margin-top: 22rpx;
padding: 18rpx 12rpx 4rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.32);
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
row-gap: 28rpx;
}
.guide-grid-item {
min-height: 126rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
}
.guide-grid-icon {
width: 44rpx;
height: 44rpx;
}
.guide-grid-label {
margin-top: 16rpx;
color: #2f3442;
font-size: 26rpx;
line-height: 1.4;
text-align: center;
}
&.is-visible {
.guide-mask {
opacity: 1;
}
.guide-shell {
transform: translateY(0);
}
}
}
@@ -0,0 +1,257 @@
type GuideMenuItem = {
key: string;
label: string;
icon: string;
};
interface GuidePopupProperties {
show: boolean;
title: string;
subtitle: string;
images: string[];
amount: string;
}
interface GuidePopupData {
mounted: boolean;
visible: boolean;
displayName: string;
displaySubtitle: string;
avatarUrl: string;
avatarLetter: string;
balanceLabel: string;
withdrawText: string;
incomeText: string;
menuItems: GuideMenuItem[];
}
interface GuidePopupInstance
extends WechatMiniprogram.Component.TrivialInstance {
data: GuidePopupData;
properties: GuidePopupProperties;
_hideTimer?: number | null;
}
const DEFAULT_NAME = "当前用户";
const DEFAULT_ENTRY = "个人中心";
const DEFAULT_BALANCE_LABEL = "可提现金额";
const DEFAULT_WITHDRAW_TEXT = "立即提现";
const DEFAULT_INCOME_TEXT = "收益明细";
const HIDE_DELAY = 280;
const DEFAULT_MENU_ITEMS: GuideMenuItem[] = [
{
key: "orders",
label: "我的订单",
icon: "./icons/orders.svg",
},
{
key: "prize-records",
label: "中奖记录",
icon: "./icons/gift.svg",
},
{
key: "coupons",
label: "我的优惠券",
icon: "./icons/coupon.svg",
},
{
key: "comment-board",
label: "评论记录板",
icon: "./icons/comment.svg",
},
{
key: "nickname",
label: "修改昵称",
icon: "./icons/edit.svg",
},
{
key: "complaint",
label: "投诉",
icon: "./icons/warning.svg",
},
];
function normalizeImages(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter(
(item): item is string => typeof item === "string" && item.length > 0,
);
}
function syncViewModel(
instance: GuidePopupInstance,
titleText: string,
subtitleText: string,
imageList: string[],
) {
const name = titleText || wx.getStorageSync("userName") || DEFAULT_NAME;
const subtitle = subtitleText || DEFAULT_ENTRY;
const avatarUrl = imageList.length ? imageList[0] : "";
const avatarLetter = String(name).trim().slice(0, 1).toUpperCase() || "U";
instance.setData({
displayName: name,
displaySubtitle: subtitle,
avatarUrl,
avatarLetter,
});
}
function clearHideTimer(instance: GuidePopupInstance) {
if (!instance._hideTimer) return;
clearTimeout(instance._hideTimer);
instance._hideTimer = null;
}
function togglePopup(instance: GuidePopupInstance, show: boolean) {
clearHideTimer(instance);
if (show) {
if (instance.data.mounted) {
wx.nextTick(() => {
instance.setData({ visible: true });
});
return;
}
instance.setData(
{
mounted: true,
visible: false,
},
() => {
wx.nextTick(() => {
instance.setData({ visible: true });
});
},
);
return;
}
if (!instance.data.mounted) return;
instance.setData({ visible: false });
instance._hideTimer = setTimeout(() => {
instance.setData({ mounted: false });
instance._hideTimer = null;
}, HIDE_DELAY);
}
Component({
properties: {
show: {
type: Boolean,
value: false,
observer(this: GuidePopupInstance, nextValue: boolean) {
togglePopup(this, !!nextValue);
},
},
title: {
type: String,
value: "",
observer(this: GuidePopupInstance, nextValue: string) {
syncViewModel(
this,
nextValue || "",
this.properties.subtitle || "",
normalizeImages(this.properties.images),
);
},
},
subtitle: {
type: String,
value: "",
observer(this: GuidePopupInstance, nextValue: string) {
syncViewModel(
this,
this.properties.title || "",
nextValue || "",
normalizeImages(this.properties.images),
);
},
},
images: {
type: Array,
value: [],
observer(this: GuidePopupInstance, nextValue: unknown) {
syncViewModel(
this,
this.properties.title || "",
this.properties.subtitle || "",
normalizeImages(nextValue),
);
},
},
amount: {
type: String,
value: "0.00",
},
},
data: {
mounted: false,
visible: false,
displayName: DEFAULT_NAME,
displaySubtitle: DEFAULT_ENTRY,
avatarUrl: "",
avatarLetter: "U",
balanceLabel: DEFAULT_BALANCE_LABEL,
withdrawText: DEFAULT_WITHDRAW_TEXT,
incomeText: DEFAULT_INCOME_TEXT,
menuItems: DEFAULT_MENU_ITEMS,
} as GuidePopupData,
lifetimes: {
attached(this: GuidePopupInstance) {
syncViewModel(
this,
this.properties.title || "",
this.properties.subtitle || "",
normalizeImages(this.properties.images),
);
togglePopup(this, !!this.properties.show);
},
detached(this: GuidePopupInstance) {
clearHideTimer(this);
},
},
methods: {
noop() {},
handleClose(this: GuidePopupInstance) {
this.triggerEvent("close");
},
handlePreviewAvatar(this: GuidePopupInstance) {
const images = normalizeImages(this.properties.images);
if (!images.length) return;
wx.previewImage({
current: images[0],
urls: images,
});
},
handleQuickAction(
this: GuidePopupInstance,
e: WechatMiniprogram.CustomEvent,
) {
const key = (e.currentTarget.dataset as { key?: string }).key || "";
if (!key) return;
this.triggerEvent("action", { key });
},
handleMenuTap(this: GuidePopupInstance, e: WechatMiniprogram.CustomEvent) {
const key = (e.currentTarget.dataset as { key?: string }).key || "";
if (!key) return;
this.triggerEvent("action", { key });
},
},
});
@@ -0,0 +1,57 @@
<view wx:if="{{mounted}}" class="guide-popup {{visible ? 'is-visible' : ''}}">
<view class="guide-mask" bindtap="handleClose"></view>
<view class="guide-shell" catchtap="noop" catchtouchmove="noop">
<view class="guide-drag-bar"></view>
<view class="guide-avatar-wrap">
<image
wx:if="{{avatarUrl}}"
class="guide-avatar"
src="{{avatarUrl}}"
mode="aspectFill"
bindtap="handlePreviewAvatar"
/>
<view wx:else class="guide-avatar guide-avatar-fallback">{{avatarLetter}}</view>
</view>
<view class="guide-panel">
<view class="guide-topbar">
<view class="guide-name">{{displayName}}</view>
<view class="guide-entry">
<image class="guide-entry-icon" src="./icons/user.svg" mode="aspectFit" />
<text class="guide-entry-text">{{displaySubtitle}}</text>
</view>
</view>
<view class="guide-balance-card">
<view class="guide-balance-main">
<view class="guide-balance-label">{{balanceLabel}}</view>
<view class="guide-balance-value">¥{{amount}}</view>
</view>
<view class="guide-balance-actions">
<view class="guide-pill guide-pill-primary" data-key="withdraw" bindtap="handleQuickAction">
{{withdrawText}}
</view>
<view class="guide-pill" data-key="income-detail" bindtap="handleQuickAction">
{{incomeText}}
</view>
</view>
</view>
<view class="guide-grid">
<view
wx:for="{{menuItems}}"
wx:key="key"
class="guide-grid-item"
data-key="{{item.key}}"
bindtap="handleMenuTap"
>
<image class="guide-grid-icon" src="{{item.icon}}" mode="aspectFit" />
<text class="guide-grid-label">{{item.label}}</text>
</view>
</view>
</view>
</view>
</view>
@@ -0,0 +1,5 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 24H76V60H44L28 74V60H20V24Z" stroke="#303644" stroke-width="6" stroke-linejoin="round"/>
<path d="M34 38H62" stroke="#303644" stroke-width="6" stroke-linecap="round"/>
<path d="M34 50H54" stroke="#303644" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 369 B

@@ -0,0 +1,4 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 30C29 30 34 25 34 18H74V34C67 34 62 39 62 46C62 53 67 58 74 58V74H34C34 67 29 62 22 62V30Z" stroke="#303644" stroke-width="6" stroke-linejoin="round"/>
<path d="M46 28V64" stroke="#303644" stroke-width="6" stroke-dasharray="8 8"/>
</svg>

After

Width:  |  Height:  |  Size: 351 B

@@ -0,0 +1,5 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24 68L28 52L58 22C61.3 18.7 66.7 18.7 70 22C73.3 25.3 73.3 30.7 70 34L40 64L24 68Z" stroke="#303644" stroke-width="6" stroke-linejoin="round"/>
<path d="M54 26L66 38" stroke="#303644" stroke-width="6" stroke-linecap="round"/>
<path d="M24 76H72" stroke="#303644" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 424 B

@@ -0,0 +1,7 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="20" y="36" width="56" height="40" rx="8" stroke="#303644" stroke-width="6"/>
<path d="M48 36V76" stroke="#303644" stroke-width="6"/>
<path d="M20 50H76" stroke="#303644" stroke-width="6"/>
<path d="M34 34C28 34 24 30 24 24C24 19 28 16 32 16C40 16 46 27 48 34" stroke="#303644" stroke-width="6" stroke-linecap="round"/>
<path d="M62 34C68 34 72 30 72 24C72 19 68 16 64 16C56 16 50 27 48 34" stroke="#303644" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 571 B

@@ -0,0 +1,7 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="22" y="18" width="44" height="58" rx="10" stroke="#303644" stroke-width="6"/>
<path d="M34 34H54" stroke="#303644" stroke-width="6" stroke-linecap="round"/>
<path d="M34 48H54" stroke="#303644" stroke-width="6" stroke-linecap="round"/>
<path d="M34 62H48" stroke="#303644" stroke-width="6" stroke-linecap="round"/>
<path d="M66 58L74 66L86 50" stroke="#303644" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 549 B

@@ -0,0 +1,6 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="36" cy="30" r="12" stroke="#303644" stroke-width="6"/>
<path d="M16 70C18.5 57.5 27.5 50 36 50C44.5 50 53.5 57.5 56 70" stroke="#303644" stroke-width="6" stroke-linecap="round"/>
<path d="M62 32H82" stroke="#303644" stroke-width="6" stroke-linecap="round"/>
<path d="M62 50H76" stroke="#303644" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 460 B

@@ -0,0 +1,5 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M48 18L76 70H20L48 18Z" stroke="#FF5277" stroke-width="6" stroke-linejoin="round"/>
<path d="M48 36V52" stroke="#FF5277" stroke-width="6" stroke-linecap="round"/>
<circle cx="48" cy="62" r="3.5" fill="#FF5277"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B