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
Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.934 19.9344H19.934V4.73438H8.33398V8.73438" stroke="#FE8D29" stroke-width="0.8" stroke-linejoin="round"/>
<path d="M4.7334 8.73047H11.9334V19.9305H4.7334V8.73047Z" fill="#FE8D29" stroke="#FE8D29" stroke-width="0.8" stroke-linejoin="round"/>
<path d="M7.93359 17.5312H8.73359" stroke="white" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.9336 17.5312H14.7336" stroke="#FE8D29" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 595 B

@@ -0,0 +1,12 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5324 11.0853C17.4708 7.60394 14.6294 4.80078 11.1334 4.80078C7.59876 4.80078 4.7334 7.66614 4.7334 11.2008C4.7334 12.8688 5.37152 14.3878 6.41697 15.5269L5.93757 18.4111L8.7834 17.1887C10.0373 17.6057 11.2206 17.7099 12.3334 17.5014" fill="white"/>
<path d="M17.5324 11.0853C17.4708 7.60394 14.6294 4.80078 11.1334 4.80078C7.59876 4.80078 4.7334 7.66614 4.7334 11.2008C4.7334 12.8688 5.37152 14.3878 6.41697 15.5269L5.93757 18.4111L8.7834 17.1887C10.0373 17.6057 11.2206 17.7099 12.3334 17.5014" stroke="white" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.78281 10.5868C9.27985 10.5868 9.68281 10.1809 9.68281 9.68012C9.68281 9.1794 9.27985 8.77344 8.78281 8.77344C8.28577 8.77344 7.88281 9.1794 7.88281 9.68012C7.88281 10.1809 8.28577 10.5868 8.78281 10.5868Z" fill="#FE8D29"/>
<path d="M12.3834 10.5868C12.8804 10.5868 13.2834 10.1809 13.2834 9.68012C13.2834 9.1794 12.8804 8.77344 12.3834 8.77344C11.8864 8.77344 11.4834 9.1794 11.4834 9.68012C11.4834 10.1809 11.8864 10.5868 12.3834 10.5868Z" fill="#FE8D29"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.2379 18.3725C17.5386 18.8929 16.6719 19.2008 15.7332 19.2008C13.4136 19.2008 11.5332 17.3204 11.5332 15.0008C11.5332 12.6812 13.4136 10.8008 15.7332 10.8008C18.0528 10.8008 19.9332 12.6812 19.9332 15.0008C19.9332 15.6407 19.7901 16.2471 19.5342 16.7899" fill="white"/>
<path d="M18.2379 18.3725C17.5386 18.8929 16.6719 19.2008 15.7332 19.2008C13.4136 19.2008 11.5332 17.3204 11.5332 15.0008C11.5332 12.6812 13.4136 10.8008 15.7332 10.8008C18.0528 10.8008 19.9332 12.6812 19.9332 15.0008C19.9332 15.6407 19.7901 16.2471 19.5342 16.7899" stroke="#FE8D29" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.5345 16.7891L19.9336 19.1999L18.2383 18.3717" fill="white"/>
<path d="M19.5345 16.7891L19.9336 19.1999L18.2383 18.3717" stroke="#FE8D29" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.008 14.7194C16.6352 14.7194 16.333 14.4149 16.333 14.0394C16.333 13.6638 16.6352 13.3594 17.008 13.3594C17.3808 13.3594 17.683 13.6638 17.683 14.0394C17.683 14.4149 17.3808 14.7194 17.008 14.7194Z" fill="#FE8D29"/>
<path d="M14.3078 14.7194C13.935 14.7194 13.6328 14.4149 13.6328 14.0394C13.6328 13.6638 13.935 13.3594 14.3078 13.3594C14.6806 13.3594 14.9828 13.6638 14.9828 14.0394C14.9828 14.4149 14.6806 14.7194 14.3078 14.7194Z" fill="#FE8D29"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,10 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="18" cy="20" r="10" fill="white"/>
<circle cx="31" cy="18" r="8" fill="white" fill-opacity="0.88"/>
<circle cx="15" cy="18" r="1.8" fill="#FE8D29"/>
<circle cx="21" cy="18" r="1.8" fill="#FE8D29"/>
<circle cx="28" cy="17" r="1.5" fill="#FE8D29"/>
<circle cx="33" cy="17" r="1.5" fill="#FE8D29"/>
<path d="M12 28L16.8 24.8L18.7 31.2L12 28Z" fill="white"/>
<path d="M27.4 28L30.8 25.8L32.1 30L27.4 28Z" fill="white" fill-opacity="0.88"/>
</svg>

After

Width:  |  Height:  |  Size: 566 B

+34
View File
@@ -0,0 +1,34 @@
export type LoginRes = {
data: {
code?: number;
avatarUrl:string;
msg?: string,
appId: string,
id: number,
mpId: number,
openId: number,
mpUserId: number,
statusCode: string,
unionId: string,
nickName:string
},
statusCode: number,
msg: string
}
export type userInfoRes = {
openId: string,
mbuId: number,
nickName: string,
gender: number,
city: string,
province: string,
country: string,
avatarUrl: string,
unionId: string,
watermark: {
appid: string,
timestamp: string
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"popup": "/components/popup/popup"
}
}
+134
View File
@@ -0,0 +1,134 @@
@import "../../variables";
.loginContainer {
min-height: 100vh;
width: 100%;
background: #fff;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
box-sizing: border-box;
}
.contentWrap {
flex: 1;
padding-bottom: 120rpx;
box-sizing: border-box;
}
.heroPanel {
position: relative;
height: 1332rpx;
background: #fff;
overflow: hidden;
}
.heroImage {
position: absolute;
// top: 68rpx;
left: 0;
z-index: 1;
display: block;
width: 750rpx;
}
.actionPanel {
position: relative;
z-index: 2;
width: 626rpx;
margin: -300rpx auto 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 48rpx;
// padding-bottom: 72rpx;
}
.actionPanelSingle {
margin-top: -700rpx;
}
.loginButton {
box-sizing: border-box;
width: 626rpx;
height: 92rpx;
padding: 0 32rpx;
border-radius: 76rpx;
display: flex;
align-items: center;
justify-content: center;
}
.wechatLogin {
background: #fe8d29;
box-shadow: 0 20rpx 40rpx rgba(254, 141, 41, 0.24);
}
.phoneLogin {
background: #fff1d6;
}
.loginIcon {
width: 48rpx;
height: 48rpx;
margin-right: 8rpx;
flex-shrink: 0;
}
.phoneIcon {
width: 48rpx;
height: 48rpx;
}
.loginLabel {
font-size: 32rpx;
font-weight: 400;
line-height: 44rpx;
color: #fff;
}
.phoneLabel {
color: #fe8d29;
}
.privacyFooter {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding-top: 24rpx;
font-size: 24rpx;
color: #666;
z-index: 3;
background: #fff;
}
.checkboxLabel {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
.radioTrigger {
display: flex;
align-items: center;
margin-right: 4rpx;
}
.radioTrigger radio {
transform: scale(0.7);
// transform-origin: left center;
margin-top: 4rpx;
}
.privacyText {
line-height: 34rpx;
}
.linkText {
color: #fe8d29;
line-height: 34rpx;
margin-left: 4rpx;
}
+488
View File
@@ -0,0 +1,488 @@
import { appId, BASE_URL } from "../../env";
import { LoginRes } from "./login.d";
import { wxLogin, wxRequest } from "../../utils/wx-api";
const app = getApp<IAppOption>();
const defaultAvatarUrl =
"https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0";
type LoginDisplayConfig = {
showLoginImage: boolean;
};
type PartialLoginDisplayConfig = Partial<LoginDisplayConfig>;
const loginConfigKeys = [
"login",
"loginConfig",
"loginPage",
"auth",
"authConfig",
"authPage",
];
const WXROOM_REDIRECT_PATH_KEY = "wxroomRedirectPath";
function isPlainObject(value: unknown): value is Record<string, any> {
return Object.prototype.toString.call(value) === "[object Object]";
}
function hasOwnValue(source: Record<string, any>, keys: string[]) {
return keys.some((key) => source[key] !== undefined && source[key] !== null);
}
function normalizeString(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function normalizeBoolean(value: unknown) {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value > 0;
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "show", "visible", "enabled"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "hide", "hidden", "disabled"].includes(normalized)) {
return false;
}
return undefined;
}
function normalizeSwitchValue(value: unknown) {
const normalized = normalizeBoolean(value);
if (normalized !== undefined) return normalized;
if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.length > 0;
if (isPlainObject(value)) {
const innerToggle = getBooleanValue(value, ["show", "visible", "enabled", "value"]);
if (innerToggle !== undefined) return innerToggle;
return !!getStringValue(value, [
"url",
"src",
"image",
"imageUrl",
"background",
"backgroundImage",
]);
}
return undefined;
}
function getObjectValue(source: Record<string, any>, keys: string[]) {
for (const key of keys) {
const value = source[key];
if (value !== undefined && value !== null && value !== "") {
return value;
}
}
return undefined;
}
function getStringValue(source: Record<string, any>, keys: string[]):string {
const value = getObjectValue(source, keys);
if (value === undefined) return "";
if (isPlainObject(value)) {
return getStringValue(value, [
"url",
"src",
"image",
"imageUrl",
"background",
"backgroundImage",
]);
}
return normalizeString(value);
}
function getBooleanValue(source: Record<string, any>, keys: string[]) {
const value = getObjectValue(source, keys);
return normalizeBoolean(value);
}
function pushConfigCandidate(list: Record<string, any>[], value: unknown) {
if (isPlainObject(value)) {
list.push(value);
}
}
function collectLoginConfigCandidates(template: unknown) {
const candidates: Record<string, any>[] = [];
if (!isPlainObject(template)) return candidates;
if (
hasOwnValue(template, [
"showLoginImage",
"showLoginBackground",
"showBackgroundImage",
"showImage",
"imageSwitch",
"backgroundSwitch",
"loginImage",
"heroImage",
"loginBg",
"loginBackground",
"backgroundImage",
"backgroundUrl",
"background",
"image",
])
) {
candidates.push(template);
}
loginConfigKeys.forEach((key) => {
pushConfigCandidate(candidates, template[key]);
pushConfigCandidate(candidates, template.pageConfig?.[key]);
pushConfigCandidate(candidates, template.pages?.[key]);
pushConfigCandidate(candidates, template.property?.[key]);
});
if (Array.isArray(template.pages?.login)) {
template.pages.login.forEach((item: any) => {
pushConfigCandidate(candidates, item);
pushConfigCandidate(candidates, item?.props);
});
}
Object.entries(template).forEach(([key, value]) => {
if (!/login|auth/i.test(key)) return;
if (Array.isArray(value)) {
value.forEach((item) => {
if (!isPlainObject(item)) return;
pushConfigCandidate(candidates, item);
pushConfigCandidate(candidates, item.props);
});
return;
}
pushConfigCandidate(candidates, value);
});
return candidates;
}
function readLoginImageSwitch(source: Record<string, any>) {
const imageValue = getObjectValue(source, [
"showLoginImage",
"showLoginBackground",
"showBackgroundImage",
"showImage",
"imageSwitch",
"backgroundSwitch",
"loginImage",
"heroImage",
"loginBackground",
"loginBg",
"backgroundImage",
"backgroundUrl",
"background",
"image",
"imageUrl",
]);
return normalizeSwitchValue(imageValue);
}
function resolveLoginDisplayConfigFromSource(source: unknown): PartialLoginDisplayConfig {
if (!isPlainObject(source)) return {};
const showLoginImage = readLoginImageSwitch(source);
return {
...(showLoginImage !== undefined ? { showLoginImage } : {}),
};
}
function mergeLoginDisplayConfig(
current: PartialLoginDisplayConfig,
next: PartialLoginDisplayConfig
) {
return {
...current,
...(next.showLoginImage !== undefined ? { showLoginImage: next.showLoginImage } : {}),
};
}
Page({
data: {
back: "",
agree: false,
nickFocus: true,
showPopup: false,
safeBottom: app.globalData.safeBottom,
showLoginImage: false,
userInfo: {
avatarUrl: defaultAvatarUrl,
nickName: "微信用户",
},
},
applyLoginDisplayConfig(options: Record<string, any> = {}) {
const template = app.globalData.template as Record<string, any> | null;
const config = collectLoginConfigCandidates(template).reduce(
(result, item) =>
mergeLoginDisplayConfig(result, resolveLoginDisplayConfigFromSource(item)),
{} as PartialLoginDisplayConfig
);
const optionConfig = resolveLoginDisplayConfigFromSource(options);
const merged = mergeLoginDisplayConfig(config, optionConfig);
this.setData({
showLoginImage: merged.showLoginImage ?? false,
});
},
async onChooseAvatar(e: any) {
const { avatarUrl } = e.detail;
wx.uploadFile({
url: `${BASE_URL}/api/operation/sys-media/upload-oss`,
filePath: avatarUrl,
name: "media",
success: (res: any) => {
const { data } = JSON.parse(res.data);
this.setData({
userInfo: {
...this.data.userInfo,
avatarUrl: data,
},
});
},
fail: () => {
wx.showToast({
title: "上传头像失败",
icon: "none",
});
},
});
},
buildQueryString(params: Record<string, any>): string {
return Object.entries(params)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`
)
.join("&");
},
redirectUrl() {
const self = this;
let fullPath = "";
const tabPages = [
"/pages/tab-bar/medicine-box/index",
"/pages/tab-bar/shopping-cart/shopping-cart",
"/pages/tab-bar/course/course",
"/pages/tab-bar/user/user",
"/pages/tab-bar/points-mall/points-mall",
"/pages/tab-bar/category/category",
];
if (self.data.back) {
if (self.data.back === "wxroom") {
fullPath =
wx.getStorageSync(WXROOM_REDIRECT_PATH_KEY) ||
"/package-live/wxroom/index";
wx.removeStorageSync(WXROOM_REDIRECT_PATH_KEY);
} else {
const store = app.globalData.store;
const state = wx.getStorageSync("state") ?? undefined;
const code = wx.getStorageSync("code") ?? undefined;
const groupId = wx.getStorageSync("groupId") ?? undefined;
if (state && !code) {
if (self.data.back === "video") {
const storeParams =
typeof store === "string" ? store : this.buildQueryString(store);
fullPath = `/pages/live/video/video?${storeParams}`;
} else if (self.data.back === "red") {
fullPath = `/pages/common/red/red`;
} else if (self.data.back === "user") {
fullPath = `/pages/tab-bar/user/user`;
} else {
fullPath = `/pages/live/registration/registration?state=${state}`;
}
} else if (!state && code) {
if (self.data.back === "video") {
const storeParams =
typeof store === "string" ? store : this.buildQueryString(store);
fullPath = `/pages/live/video/video?${storeParams}`;
} else if (self.data.back === "red") {
fullPath = `/pages/common/red/red`;
} else if (self.data.back === "user") {
fullPath = `/pages/tab-bar/user/user`;
} else {
fullPath = `/pages/live/registration/registration?code=${code}`;
}
} else {
if (self.data.back === "room") {
fullPath = `/pages/live/room/room?groupId=${groupId}`;
} else if (self.data.back === "red") {
fullPath = `/pages/common/red/red`;
} else if (self.data.back === "user") {
fullPath = `/pages/tab-bar/user/user`;
}
}
}
} else {
const pages = getCurrentPages();
const currentPage = pages[pages.length - 2];
if (!currentPage) {
// 如果没有上一页,跳转到第一个可用 tab,而不是硬编码的 index
const tabbarItems = app.globalData.tabbarItems;
if (tabbarItems && tabbarItems.length > 0) {
wx.switchTab({ url: tabbarItems[0].pagePath });
} else {
wx.switchTab({ url: "/pages/tab-bar/medicine-box/index" });
}
return;
}
const route = `/${currentPage.route}`;
const options = currentPage.options || {};
const query = this.buildQueryString(options);
fullPath = query ? `${route}?${query}` : route;
if (tabPages.includes(route)) {
wx.switchTab({ url: route });
return;
}
wx.redirectTo({ url: route });
return;
}
if (tabPages.includes(fullPath)) {
wx.switchTab({ url: fullPath });
} else {
wx.redirectTo({ url: fullPath });
}
},
async handleLogin() {
if (!this.data.agree) {
wx.showModal({
title: "请先勾选用户协议和隐私政策",
icon: "none",
success: (options) => {
if (options.confirm) {
this.setData({ agree: true });
}
},
});
if (!this.data.agree) return;
}
try {
wx.showLoading({ title: "登录中" });
const loginRes = await wxLogin();
if (!loginRes.code) throw new Error(loginRes.errMsg);
const { avatarUrl, nickName } = this.data.userInfo;
const state = wx.getStorageSync("state");
let loginResult: LoginRes;
if (state) {
const reqData: Record<string, any> = {
state: wx.getStorageSync("state") ?? undefined,
...(nickName && nickName !== "微信用户" ? { nickName } : {}),
...(avatarUrl && avatarUrl !== defaultAvatarUrl ? { avatarUrl } : {}),
};
loginResult = await wxRequest<LoginRes>({
url: `${BASE_URL}/api/auth/app-login?appId=${appId}&code=${loginRes.code}`,
method: "POST",
data: reqData,
});
} else {
const reqData: Record<string, any> = {
state: wx.getStorageSync("code") ?? undefined,
...(nickName && nickName !== "微信用户" ? { nickName } : {}),
...(avatarUrl && avatarUrl !== defaultAvatarUrl ? { avatarUrl } : {}),
};
loginResult = await wxRequest<LoginRes>({
url: `${BASE_URL}/api/auth/app-auth?appId=${appId}&code=${loginRes.code}`,
method: "POST",
data: reqData,
});
}
if (
loginResult?.statusCode !== 200 ||
(loginResult.data.code && loginResult.data.code !== 200)
) {
throw new Error(loginResult?.data?.msg || "登录失败");
}
const {
mpUserId,
id,
openId,
unionId,
nickName: resNickName,
avatarUrl: resAvatarUrl,
} = loginResult.data;
const storageData = {
openId,
userId: mpUserId,
mbuId: id,
unionId,
userName: resNickName,
avatarUrl: resAvatarUrl,
};
Object.entries(storageData).forEach(([key, value]) => {
wx.setStorageSync(key, value);
});
app.globalData.isLogin = true;
this.triggerEvent("loginSuccess", {
userName: resNickName,
avatarUrl: resAvatarUrl,
});
wx.showToast({
title: "登录成功",
duration: 2000,
});
this.redirectUrl();
} catch (err: any) {
console.log("登录失败:", err);
app.globalData.isLogin = false;
wx.showToast({
title: `登录失败:${err.message || err.errMsg}`,
icon: "none",
});
} finally {
setTimeout(() => wx.hideToast(), 3000);
}
},
handleToggleAgree() {
this.setData({ agree: !this.data.agree });
},
openPrivacyContract() {
// @ts-ignore
wx.openPrivacyContract();
},
onLoad(options: any) {
if (options.back) {
this.setData({ back: options.back });
}
this.applyLoginDisplayConfig(options);
},
onShow() {
setTimeout(() => {
this.setData({ nickFocus: true });
}, 200);
},
});
+26
View File
@@ -0,0 +1,26 @@
<navigation-bar title="登录" back="{{!back}}" color="black" background="#FFF" />
<view class="loginContainer">
<view class="contentWrap">
<view class="heroPanel">
</view>
<view class="actionPanel actionPanelSingle">
<view class="loginButton wechatLogin" bind:tap="handleLogin">
<image class="loginIcon" src="./assets/wechat-login-icon-figma.svg" mode="aspectFit"></image>
<text class="loginLabel">微信登录</text>
</view>
</view>
</view>
<view class="privacyFooter" style="padding-bottom: {{safeBottom}}rpx;">
<view class="checkboxLabel">
<view class="radioTrigger" catchtap="handleToggleAgree">
<radio-group>
<radio value="agree" checked="{{agree}}" color="#FE8D29" />
</radio-group>
</view>
<text class="privacyText">我已阅读并同意</text>
<text class="linkText" bindtap="openPrivacyContract">《用户协议》</text>
<text class="privacyText">和</text>
<text class="linkText" bindtap="openPrivacyContract">《隐私政策》</text>
</view>
</view>
</view>