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": {
"popup": "/components/popup/popup"
}
}
+217
View File
@@ -0,0 +1,217 @@
/* components/banner/banner.wxss */
.banner-wrap {
width: 100%;
position: absolute;
top: 7%;
left: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 9999;
padding: 30rpx;
box-sizing: border-box;
}
.banner__avatar {
border-radius: 50%;
margin-right: 20rpx;
}
.banner__card {
border-radius: 25rpx;
padding: 18rpx;
box-sizing: border-box;
background-color: rgba(0, 0, 0, 0.5);
margin-bottom: 20rpx;
display: inline-block;
}
.banner__icon {
width: 40rpx;
height: 40rpx;
margin-right: 10rpx;
}
.banner__avatar {
width: 80rpx;
height: 80rpx;
}
.banner-id {
font-size: 21rpx;
}
.banner-box {
display: flex;
align-items: center;
}
.banner__title {
max-width: 250rpx;
}
.text-overflow {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.banner__count {
color: #fff;
margin-top: 10rpx;
}
.popup-header {
text-align: center;
font-weight: 700;
color: #000;
font-size: 32rpx;
}
.popup-text {
color: #000;
font-size: 28rpx;
}
.report-options {
width: 100%;
margin: 24rpx 0;
}
.options-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
align-items: center;
gap: 24rpx;
}
.option-item {
display: flex;
align-items: center;
margin-right: 30rpx;
margin-bottom: 20rpx;
}
.option-item radio {
margin-right: 10rpx;
}
.other-input {
margin: 24rpx 0;
}
.other-input-field {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
border: 1rpx solid #ddd;
border-radius: 8rpx;
box-sizing: border-box;
}
.upload-section {
margin-bottom: 24rpx;
}
.upload-title {
font-size: 28rpx;
color: #333;
margin-bottom: 16rpx;
}
.image-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.image-wrapper {
position: relative;
width: 160rpx;
height: 160rpx;
border-radius: 8rpx;
overflow: hidden;
}
.uploaded-image {
width: 100%;
height: 100%;
border-radius: 8rpx;
}
.delete-image {
position: absolute;
top: -8rpx;
right: -8rpx;
width: 32rpx;
height: 32rpx;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
}
.upload-btn {
width: 160rpx;
height: 160rpx;
border: 2rpx dashed #ddd;
border-radius: 8rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f8f8f8;
}
.upload-icon {
font-size: 48rpx;
color: #999;
margin-bottom: 8rpx;
}
.upload-text {
font-size: 24rpx;
color: #999;
}
.description-section {
margin-bottom: 32rpx;
}
.description-input {
width: 100%;
height: 160rpx;
padding: 16rpx;
border: 1rpx solid #ddd;
border-radius: 8rpx;
box-sizing: border-box;
margin-bottom: 8rpx;
}
.char-count {
font-size: 24rpx;
color: #999;
text-align: right;
display: block;
}
.submit-section {
padding: 0 24rpx;
}
.submit-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: #ff4d4f;
color: #fff;
font-size: 32rpx;
border-radius: 44rpx;
border: none;
}
.submit-btn:active {
background: #ff7875;
}
.submit-btn[disabled] {
background: #ccc;
color: #999;
opacity: 0.7;
}
+281
View File
@@ -0,0 +1,281 @@
import { request } from "../../utils/request";
// components/banner/banner.ts
interface BannerData {
avatarUrl: string;
nickName: string;
userId: string;
showPopup: boolean;
reportOptions: Array<{
id: number;
text: string;
checked: boolean;
}>;
showOtherInput: boolean;
otherReason: string;
uploadedImages: string[];
description: string;
state: number;
isSubmitEnabled: boolean;
}
interface BannerProperties {
roomInfo: {
iconUrl?: string;
name?: string;
mumId?: string;
state: number;
};
}
interface BannerComponent extends WechatMiniprogram.Component.TrivialInstance {
data: BannerData;
properties: BannerProperties;
}
const state: Record<number, string> = {
1: "直播中",
2: "直播结束",
};
Component({
/**
* 组件的属性列表
*/
properties: {
roomInfo: {
type: Object,
value: {
iconUrl: "",
nickname: "",
mumId: "",
state: null
},
observer: function (
this: BannerComponent,
newVal: BannerProperties["roomInfo"]
) {
this.updateRoomInfo(newVal);
},
},
watchNum:{
type:Number,
data:0
}
},
/**
* 组件的初始数据
*/
data: {
avatarUrl: "",
nickName: "",
userId: "",
state: "",
showPopup: false,
reportOptions: [
{ id: 1, text: "价格虚假", checked: false },
{ id: 2, text: "品牌虚假", checked: false },
{ id: 3, text: "色情低俗", checked: false },
{ id: 4, text: "虚假活动", checked: false },
{ id: 5, text: "不良行为", checked: false },
{ id: 6, text: "商品信息虚假", checked: false },
{ id: 7, text: "出售违禁品", checked: false },
{ id: 8, text: "其他", checked: false },
],
showOtherInput: false,
otherReason: "",
uploadedImages: [],
description: "",
isSubmitEnabled: false,
},
lifetimes: {
attached(this: BannerComponent) {
this.updateRoomInfo(this.properties.roomInfo);
},
},
/**
* 组件的方法列表
*/
methods: {
updateRoomInfo(
this: BannerComponent,
roomInfo?: BannerProperties["roomInfo"]
) {
const info = roomInfo || this.properties.roomInfo;
this.setData({
avatarUrl: info?.iconUrl || "",
nickName: info?.name || "",
userId: info?.mumId || "",
state: state[info?.state ?? 2],
});
},
handOpen(this: WechatMiniprogram.Component.TrivialInstance) {
this.setData({
showPopup: true,
isSubmitEnabled: false,
});
},
closePopup(this: WechatMiniprogram.Component.TrivialInstance) {
this.setData({
showPopup: false,
});
},
onOptionChange(this: WechatMiniprogram.Component.TrivialInstance, e: any) {
const { value } = e.detail;
const reportOptions = this.data.reportOptions.map((item: any) => ({
...item,
checked: value.includes(item.id.toString()),
}));
const showOtherInput =
reportOptions.find((item: any) => item.id === 8)?.checked || false;
this.setData({
reportOptions,
showOtherInput,
}, () => {
this.updateSubmitEnabled();
});
},
onOtherInput(this: WechatMiniprogram.Component.TrivialInstance, e: any) {
this.setData({
otherReason: e.detail.value,
}, () => {
this.updateSubmitEnabled();
});
},
deleteImage(
this: WechatMiniprogram.Component.TrivialInstance,
e: WechatMiniprogram.TouchEvent
) {
const { index } = e.currentTarget.dataset;
wx.showModal({
title: "提示",
content: "确定要删除这张图片吗?",
success: (res) => {
if (res.confirm) {
const uploadedImages = [...this.data.uploadedImages];
uploadedImages.splice(index, 1);
this.setData({ uploadedImages });
}
},
});
},
chooseImage(this: WechatMiniprogram.Component.TrivialInstance) {
wx.chooseImage({
count: 3 - this.data.uploadedImages.length,
success: (res) => {
this.setData({
uploadedImages: [...this.data.uploadedImages, ...res.tempFilePaths],
});
},
});
},
previewImage(this: WechatMiniprogram.Component.TrivialInstance, e: any) {
const { src } = e.currentTarget.dataset;
wx.previewImage({
current: src,
urls: this.data.uploadedImages,
});
},
onDescriptionInput(
this: WechatMiniprogram.Component.TrivialInstance,
e: any
) {
this.setData({
description: e.detail.value,
}, () => {
this.updateSubmitEnabled();
});
},
updateSubmitEnabled(this: BannerComponent) {
const selectedOptions = this.data.reportOptions.filter(
(item: any) => item.checked
);
const hasSelectedOptions = selectedOptions.length > 0;
const isOtherSelected = selectedOptions.some((item: any) => item.id === 8);
const otherReasonValid = !isOtherSelected || (this.data.otherReason && this.data.otherReason.trim().length > 0);
const hasDescription = this.data.description && this.data.description.trim().length > 0;
this.setData({
isSubmitEnabled: hasSelectedOptions && otherReasonValid && hasDescription,
});
},
async submitReport(this: BannerComponent) {
if (!this.data.isSubmitEnabled) {
return;
}
const selectedOptions = this.data.reportOptions.filter(
(item: any) => item.checked
);
if (selectedOptions.length === 0) {
wx.showToast({
title: "请选择举报原因",
icon: "none",
});
return;
}
if (
selectedOptions.some((item: any) => item.id === 8) &&
!this.data.otherReason
) {
wx.showToast({
title: "请填写其他举报原因",
icon: "none",
});
return;
}
// 提交举报信息
const reportData = {
reasons: selectedOptions,
otherReason: this.data.otherReason,
images: this.data.uploadedImages,
description: this.data.description,
userId: this.data.userId,
roomId: wx.getStorageSync("joinedGroupId"),
};
console.log("举报信息:", reportData);
try {
await request({
options: {
url: `/app/video-live/feedback`,
data: reportData,
},
});
wx.showToast({
title: "举报成功",
icon: "success",
});
// 提交成功后关闭弹窗
this.setData({
showPopup: false,
reportOptions: this.data.reportOptions.map((item: any) => ({
...item,
checked: false,
})),
showOtherInput: false,
otherReason: "",
uploadedImages: [],
description: "",
isSubmitEnabled: false,
});
} catch (error) {
this.handleError(error);
}
},
},
});
+83
View File
@@ -0,0 +1,83 @@
<!--components/banner/banner.wxml-->
<view class="banner-wrap">
<view>
<view class="banner__card banner-box">
<image class="banner__avatar" src="{{avatarUrl}}" mode="aspectFill" />
<view>
<view class="banner__title text-overflow">{{nickName}}</view>
<view class="banner-box banner__count">
<image class="banner__icon" src="https://img.alicdn.com/imgextra/i1/O1CN01GC0HVZ1kWz8bvu0KX_!!6000000004692-2-tps-36-36.png" mode="widthFix" />
<text>{{watchNum}}</text>
</view>
</view>
</view>
<view class="banner__card">{{state}}</view>
</view>
<view>
<view class="banner__card banner-box" bind:tap="handOpen">
<image class="banner__icon" src="/assets/img/p.svg" mode="widthFix"/>
<text>投诉</text>
</view>
<view class="banner-id">用户ID:{{userId}}</view>
</view>
</view>
<popup visible="{{showPopup}}" bind:close="closePopup">
<view slot="header" class="popup-header">
<text>举报本场直播</text>
</view>
<view class="popup-content">
<radio-group class="report-options" bindchange="onOptionChange">
<view class="options-row">
<view class="option-item" wx:for="{{reportOptions}}" wx:for-item="item" wx:key="id">
<radio value="{{item.id}}" checked="{{item.checked}}"/>
<text class="popup-text">{{item.text}}</text>
</view>
</view>
</radio-group>
<view class="other-input" wx:if="{{showOtherInput}}">
<input
class="other-input-field popup-text"
type="text"
placeholder="请输入其他举报原因"
value="{{otherReason}}"
bindinput="onOtherInput"
/>
</view>
<view class="upload-section">
<view class="upload-title popup-text">上传图片证据(最多3张)</view>
<view class="image-list">
<block wx:for="{{uploadedImages}}" wx:key="*this">
<view class="image-wrapper">
<image class="uploaded-image" src="{{item}}" mode="aspectFill" bindtap="previewImage" data-src="{{item}}"/>
<view class="delete-image" catchtap="deleteImage" data-index="{{index}}">×</view>
</view>
</block>
<view class="upload-btn" wx:if="{{uploadedImages.length < 3}}" bindtap="chooseImage">
<view class="upload-icon">+</view>
<text class="upload-text">上传图片</text>
</view>
</view>
</view>
<view class="description-section">
<textarea
class="description-input popup-text"
placeholder="请补充说明(选填)"
value="{{description}}"
bindinput="onDescriptionInput"
maxlength="200"
/>
<text class="char-count popup-text">{{description.length}}/200</text>
</view>
<view class="submit-section">
<button class="submit-btn" disabled="{{!isSubmitEnabled}}" bindtap="submitReport">提交举报</button>
</view>
</view>
</popup>
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
@@ -0,0 +1,32 @@
/* components/collapse/collapse.wxss */
.collapse {
border-radius: 16rpx;
overflow: hidden;
}
.collapse-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx;
font-weight: 700;
}
.arrow {
transition: transform 0.3s;
}
.arrow.opened {
transform: rotate(180deg);
}
.collapse-content {
overflow: hidden;
transition: max-height 0.3s ease;
}
.content-inner {
padding: 24rpx;
color: #333;
background: #ffddcb;
}
@@ -0,0 +1,64 @@
// components/collapse/collapse.ts
export { }
Component({
properties: {
title: {
type: String,
value: ''
},
titleStyle: {
type: String,
value: ''
},
defaultOpen: {
type: Boolean,
value: false
},
disableOpen: {
type: Boolean,
value: false
},
offsetHeight: {
type: Number,
value: 0
}
},
data: {
open: false,
contentHeight: 0
},
lifetimes: {
ready(this: WechatMiniprogram.Component.TrivialInstance) {
this.setData({ open: this.data.defaultOpen })
this.calcHeight()
}
},
methods: {
toggle(this: WechatMiniprogram.Component.TrivialInstance) {
if (this.data.disableOpen) return;
const open = !this.data.open;
this.setData({ open }, () => {
if (open) {
// 等展开后再算高度
this.calcHeight();
}
});
},
calcHeight(this: WechatMiniprogram.Component.TrivialInstance) {
const query = this.createSelectorQuery()
query.select('#content').boundingClientRect()
query.exec(res => {
if (res && res[0]) {
this.setData({
contentHeight: res[0].height
})
}
})
}
}
})
@@ -0,0 +1,17 @@
<view class="collapse">
<!-- header -->
<view class="collapse-header" bind:tap="toggle">
<view class="title">
<view class="title-text" style="{{ titleStyle }}">
{{ title }}
</view>
</view>
<view class="arrow {{ open ? 'open' : '' }}">▼</view>
</view>
<view class="collapse-content" style="max-height: {{ open ? (contentHeight+offsetHeight) + 'px' : '0rpx' }}">
<view class="content-inner" id="content">
<slot />
</view>
</view>
</view>
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+83
View File
@@ -0,0 +1,83 @@
/* components/empty/empty.wxss */
@import "../../variables";
.empty {
width: 100%;
position: relative;
box-sizing: border-box;
.content {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
position: relative;
z-index: 1;
}
.icon {
width: 200rpx;
height: 200rpx;
image {
width: 100%;
height: 100%;
}
}
.title {
color: #333333;
font-size: 32rpx;
line-height: 44rpx;
margin-top: 28rpx;
}
.description {
color: #999999;
font-size: 28rpx;
line-height: 40rpx;
margin-top: 8rpx;
text-align: center;
}
.action {
height: 54rpx;
line-height: 54rpx;
border-radius: 46rpx;
min-width: 140rpx;
padding: 0 24rpx;
text-align: center;
color: #ffffff;
font-size: 28rpx;
margin-top: 108rpx;
background: linear-gradient(139deg, #ff9a33 0%, #ff7200 100%);
}
}
.empty-legacy {
height: 60vh;
color: $gray-color;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.empty-scene {
min-height: 1220rpx;
padding-top: 400rpx;
overflow: hidden;
background: #f5f5f5;
.scene-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 982rpx;
border-bottom-left-radius: 28rpx;
border-bottom-right-radius: 28rpx;
// background: linear-gradient(180deg, #ffd5af 0%, rgba(255, 255, 255, 0.2) 100%);
}
}
+135
View File
@@ -0,0 +1,135 @@
// components/empty/empty.ts
type SceneConfig = {
title: string;
description: string;
icon: string;
buttonText?: string;
action?: string;
imageStyle?: string;
};
const SCENE_MAP: Record<string, SceneConfig> = {
loadFail: {
title: "加载失败",
description: "请点击刷新重新加载试试~",
icon: "./assets/load-failed.png",
buttonText: "刷新",
action: "refresh",
imageStyle: "width: 328rpx; height: 392rpx;"
},
noDynamic: {
title: "暂无动态",
description: "这个地方空空如也~",
icon: "./assets/no-dynamic.png",
imageStyle: "width: 260rpx; height: 378rpx;"
},
noMessage: {
title: "暂无消息",
description: "暂无新的消息",
icon: "./assets/no-message.png",
imageStyle: "width: 344rpx; height: 376rpx;"
},
noAddress: {
title: "暂无地址",
description: "快去添加一个吧~",
icon: "./assets/no-address.png",
buttonText: "添加地址",
action: "addAddress",
imageStyle: "width: 264rpx; height: 392rpx;"
},
noArchive: {
title: "暂无档案信息",
description: "去添加宝贝的成长档案吧~",
icon: "./assets/no-archive.png",
imageStyle: "width: 388rpx; height: 408rpx;"
}
};
const DEFAULT_TITLE = "什么都没搜到";
const DEFAULT_ICON = "../../assets/img/empty.svg";
Component({
properties: {
scene: {
type: String,
value: "",
observer: "syncScene"
},
title: {
type: String,
value: DEFAULT_TITLE,
observer: "syncScene"
},
description: {
type: String,
value: "",
observer: "syncScene"
},
icon: {
type: String,
value: DEFAULT_ICON,
observer: "syncScene"
},
buttonText: {
type: String,
value: "",
observer: "syncScene"
},
emptyStyle: {
type: String,
value: ""
},
imageStyle: {
type: String,
value: "",
observer: "syncScene"
}
},
data: {
sceneMode: false,
resolvedTitle: "",
resolvedDescription: "",
resolvedIcon: "",
resolvedButtonText: "",
resolvedAction: "",
resolvedImageStyle: ""
},
lifetimes: {
attached() {
(this as any).syncScene();
}
},
methods: {
syncScene() {
const that = this as any;
const scene = (that.data.scene || "").trim();
const sceneConfig = scene ? SCENE_MAP[scene] : undefined;
const title = that.data.title || "";
const description = that.data.description || "";
const icon = that.data.icon || "";
const buttonText = that.data.buttonText || "";
const imageStyle = that.data.imageStyle || "";
that.setData({
sceneMode: !!sceneConfig,
resolvedTitle: sceneConfig && title === DEFAULT_TITLE ? sceneConfig.title : (title || sceneConfig?.title || DEFAULT_TITLE),
resolvedDescription: sceneConfig && !description ? sceneConfig.description : description,
resolvedIcon: sceneConfig && icon === DEFAULT_ICON ? sceneConfig.icon : (icon || sceneConfig?.icon || DEFAULT_ICON),
resolvedButtonText: sceneConfig && !buttonText ? (sceneConfig.buttonText || "") : buttonText,
resolvedAction: sceneConfig?.action || "",
resolvedImageStyle: sceneConfig && !imageStyle ? (sceneConfig.imageStyle || "") : imageStyle
});
},
handleAction() {
const that = this as any;
that.triggerEvent("action", {
scene: that.data.scene,
action: that.data.resolvedAction
});
}
}
});
+12
View File
@@ -0,0 +1,12 @@
<!--components/empty/empty.wxml-->
<view class="empty {{sceneMode ? 'empty-scene' : 'empty-legacy'}}" style="{{emptyStyle}}">
<view wx:if="{{sceneMode}}" class="scene-bg"></view>
<view class="content">
<view class="icon" style="{{resolvedImageStyle}}">
<image src="{{resolvedIcon}}" mode="aspectFit"></image>
</view>
<view class="title">{{resolvedTitle}}</view>
<view class="description" wx:if="{{resolvedDescription}}">{{resolvedDescription}}</view>
<view class="action" wx:if="{{resolvedButtonText}}" bind:tap="handleAction">{{resolvedButtonText}}</view>
</view>
</view>
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+10
View File
@@ -0,0 +1,10 @@
/* components/end/end.wxss */
.end {
display: flex;
justify-content: center;
align-items: center;
cursor: none;
color: #999;
margin: 0;
font-size: 26rpx;
}
+24
View File
@@ -0,0 +1,24 @@
// components/end/end.ts
Component({
/**
* 组件的属性列表
*/
properties: {
},
/**
* 组件的初始数据
*/
data: {
},
/**
* 组件的方法列表
*/
methods: {
}
})
+2
View File
@@ -0,0 +1,2 @@
<!--components/end/end.wxml-->
<text class="end">—— · ——</text>
@@ -0,0 +1,9 @@
{
"component": true,
"usingComponents": {
"player": "/components/player/player",
"banner": "/components/banner/banner",
"comment": "/components/static/comment/comment",
"product-popup": "/components/static/product-popup/product-popup"
}
}
@@ -0,0 +1,94 @@
/* components/liveroom/liveroom.wxss */
.liveroom-wrap {
position: relative;
width: 100%;
height: 100%;
color: #fcfcfd;
background-image: linear-gradient(90deg, #2f1d17 0%, #1e2326 100%);
}
.text-overflow {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.countdown {
margin: 30rpx 0;
}
.countdown-text {
color: white;
font-size: 28rpx;
}
.countdown-time {
color: white;
font-size: 36rpx;
font-weight: bold;
}
.enter-room-btn {
background: #ff6b6b;
width: 400rpx;
height: 80rpx;
line-height: 80rpx;
color: #fff;
font-size: 32rpx;
border-radius: 40rpx;
border: none;
}
.live-ended {
position: fixed;
top: 35%;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
}
.ended-content {
border-radius: 16rpx;
padding: 48rpx;
text-align: center;
width: 600rpx;
}
.ended-icon {
width: 120rpx;
height: 120rpx;
margin-bottom: 24rpx;
}
.ended-text {
font-size: 36rpx;
color: #fff;
font-weight: 700;
display: block;
margin-bottom: 16rpx;
}
.ended-time {
font-size: 28rpx;
color: #999;
display: block;
margin-bottom: 48rpx;
}
.replay-btn {
width: 400rpx;
height: 80rpx;
line-height: 80rpx;
background: #ff6b35;
color: #fff;
font-size: 32rpx;
border-radius: 40rpx;
border: none;
}
.replay-btn:active {
background: #ff8555;
}
+216
View File
@@ -0,0 +1,216 @@
import { request } from "../../utils/request";
import { appId } from "../../env";
import {
join,
leave,
addCustomEventListener,
removeCustomEventListener,
} from "../../utils/server-sent-events";
interface LiveroomData {
isPlayback: boolean;
pluginVisible: boolean;
roomInfo: any;
pullUrlInfo: any;
vodInfo: any;
watchNum: number;
productNum: number;
chatEnhancementApproved: boolean;
}
interface LiveroomProperties {
joinedGroupId: string;
}
interface LiveroomComponent
extends WechatMiniprogram.Component.TrivialInstance {
data: LiveroomData;
properties: LiveroomProperties;
_listener?: (data: any) => void;
onLiveMessage: (data: any) => void;
init: () => Promise<void>;
cleanup: () => void;
handleError: (error?: unknown) => void;
setChatEnhancementApproval: (approved: boolean) => void;
}
const statusId = "roomId-1";
// components/liveroom/liveroom.ts
Component({
/**
* 组件的属性列表
*/
properties: {
joinedGroupId: {
type: String,
value: "",
observer: function (
this: LiveroomComponent,
newVal: string,
oldVal: string,
) {
if (newVal && newVal !== oldVal) {
this.init();
}
},
},
},
lifetimes: {
attached(this: LiveroomComponent) {},
detached(this: LiveroomComponent) {
this.cleanup();
},
},
pageLifetimes: {
show(this: LiveroomComponent) {
this._listener = this.onLiveMessage.bind(this);
addCustomEventListener("pushMsg", this._listener);
join(statusId);
},
hide() {
// @ts-ignore
removeCustomEventListener("pushMsg", this._listener);
leave(statusId);
},
},
/**
* 组件的初始数据
*/
data: {
isPlayback: false,
pluginVisible: true,
roomInfo: null,
watchNum: 0,
productNum: 0,
durationInterval: null,
},
/**
* 组件的方法列表
*/
methods: {
onLiveMessage(data: any) {
console.log("收到通知:", data);
const that = this as any;
if (data.type == "watchNum") {
that.setData({ watchNum: data.count });
}
if (data.type == "productNum") {
that.setData({ productNum: data.count });
}
},
updateIsPlayback(this: any, bool: boolean) {
this.isPlayback = bool;
},
controlstoggle(this: LiveroomComponent, e: WechatMiniprogram.CustomEvent) {
const { state } = e.detail;
if (state && this.data.roomInfo) {
this.setData({
roomInfo: {
...this.data.roomInfo,
state,
},
});
}
},
async init(this: LiveroomComponent) {
const app = getApp<IAppOption>();
if (!app.globalData.isLogin) return;
const roomId = this.properties.joinedGroupId;
const params = {
roomId: roomId,
appId: appId,
openId: wx.getStorageSync("openId"),
};
try {
const data = await request({
options: {
url: `/app/video-live/details`,
data: params,
method: "GET",
},
});
const bool = this.compareTime(data.data.onlineDatetime);
console.log(bool, "12313");
this.setData({
roomInfo: data.data,
pullUrlInfo: data.data,
vodInfo: data.data,
bool,
});
} catch (error) {
this.handleError(error);
}
},
handleError(this: LiveroomComponent) {
wx.showToast({
title: "获取直播信息失败",
icon: "none",
duration: 2000,
});
},
cleanup(this: LiveroomComponent) {
this.setData({
isPlayback: false,
pluginVisible: true,
roomInfo: null,
watchNum: 0,
productNum: 0,
chatEnhancementApproved: false,
});
},
compareTime(targetTimeStr: string) {
const targetTime = new Date(targetTimeStr);
const currentTime = new Date();
return targetTime > currentTime;
},
startDurationTimer(this: LiveroomComponent) {
this.durationInterval = setInterval(() => {
const now = new Date().getTime();
const startTime = new Date(this.data.roomInfo.onlineDatetime).getTime();
if (now < startTime) {
const remainingTime = Math.floor((startTime - now) / 1000);
const days = Math.floor(remainingTime / (24 * 3600));
const hours = Math.floor((remainingTime % (24 * 3600)) / 3600);
const minutes = Math.floor((remainingTime % 3600) / 60);
const seconds = remainingTime % 60;
this.setData({
isLiveStarted: false,
countdown:
days > 0
? `${days}${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`
: `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`,
});
} else {
const duration = Math.floor((now - startTime) / 1000);
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = duration % 60;
this.setData({
isLiveStarted: true,
duration: `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`,
});
}
}, 1000);
},
stopDurationTimer(this: LiveroomComponent) {
if (this.durationInterval) {
clearInterval(this.durationInterval);
this.durationInterval = null;
}
},
},
});
@@ -0,0 +1,39 @@
<!--components/liveroom/liveroom.wxml-->
<view class="liveroom-wrap">
<banner room-info="{{roomInfo}}" watchNum="{{watchNum}}"/>
<!-- 直播未开始提示
<view class="live-ended" wx:if="{{bool}}" >
<view class="ended-content">
<text class="ended-text">直播未开始</text>
<view class="countdown">
<text class="countdown-text">距离开始还有:</text>
<text class="countdown-time">{{roomInfo.onlineDatetime}}</text>
</view>
<button class="enter-room-btn" bind:tap="enterRoom">进入房间</button>
</view>
</view> -->
<!-- 直播结束提示 -->
<view class="live-ended" wx:if="{{roomInfo.state == 2}}">
<view class="ended-content">
<text class="ended-text">直播已结束</text>
<text class="ended-time">结束时间:{{roomInfo.offlineDatetime}}</text>
<button class="replay-btn">观看回放</button>
</view>
</view>
<banner room-info="{{roomInfo}}"/>
<player
is-playback="{{isPlayback}}"
is-living="{{isLiving}}"
pull-url-info="{{pullUrlInfo}}"
vod-info="{{vodInfo}}"
bind:controlstoggle="controlstoggle"
/>
<comment wx:if="{{roomInfo.chat}}"/>
<product-popup joinedGroupId="{{joinedGroupId}}" productNum="{{productNum}}"/>
</view>
+33
View File
@@ -0,0 +1,33 @@
export type LoginRes = {
data: {
code?: number;
appId: string;
id: number;
mpId: number;
openId: number;
mpUserId: number;
unionId: string;
nickName: string;
avatarUrl: string;
msg?: 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
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+48
View File
@@ -0,0 +1,48 @@
/* components/login/login.wxss */
@import "../../variables";
.loginContainer {
position: absolute;
top: 0;
left: 0;
z-index: 999;
width: 750rpx;
height: 100%;
background-color: rgba($color: #000000, $alpha: .3);
backdrop-filter: blur(12rpx);
display: flex;
justify-content: center;
align-items: center;
.contain {
width: 100%;
height: 25vh;
background-color: #ffffff;
border-radius: 20rpx 20rpx 0 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: fixed;
bottom: 0;
left: 0;
text {
font-size: 35rpx;
width: 80%;
line-height: 50rpx;
margin-bottom: 50rpx;
}
}
.login {
width: 60%;
height: 80rpx;
background: $primary-color-blue;
color: white;
font-size: 40rpx;
font-weight: 800;
line-height: 80rpx;
text-align: center;
border-radius: $primary-border-radius-20;
}
}
+73
View File
@@ -0,0 +1,73 @@
// components/login/login.ts
import { BASE_URL, appId } from "../../env";
import { LoginRes } from "./login.d";
import { wxGetUserInfo, wxLogin, wxRequest } from "../../utils/wx-api";
interface LoginComponent {
data: {
mbuId: number;
userName: string;
};
setData: (data: Partial<{ mbuId: number; userName: string }>) => void;
triggerEvent: (name: string, detail?: any) => void;
handleLogin: () => Promise<void>;
}
const app = getApp<IAppOption>();
Component({
data: {
mbuId: wx.getStorageSync("mbuId"),
userName: wx.getStorageSync("userName"),
},
methods: {
async handleLogin(this: LoginComponent) {
try {
wx.showLoading({ title: "登录中" });
const loginRes = await wxLogin();
const infoRes = await wxGetUserInfo();
if (!loginRes.code) throw new Error(loginRes.errMsg);
const loginResult = await wxRequest<LoginRes>({
url: `${BASE_URL}/auth/app-login?appId=${appId}&code=${loginRes.code}`,
method: "POST",
data: {
...infoRes.userInfo,
},
});
if (
loginResult?.statusCode !== 200 ||
(loginResult.data.code && loginResult.data.code !== 200)
) {
throw new Error(loginResult?.msg || "登录失败");
}
const { mpUserId, id, openId, unionId, nickName } = loginResult.data;
wx.setStorageSync("openId", openId);
wx.setStorageSync("userId", mpUserId);
wx.setStorageSync("mbuId", id);
wx.setStorageSync("unionId", unionId);
wx.setStorageSync("userName", nickName);
wx.setStorageSync("avatarUrl", infoRes.userInfo.avatarUrl);
this.setData({ mbuId: id, userName: nickName });
app.globalData.isLogin = true;
this.triggerEvent("loginSuccess", {
userName: nickName,
avatarUrl: infoRes.userInfo.avatarUrl,
});
console.log("登录成功");
} catch (err: any) {
console.log("登录失败:", err);
app.globalData.isLogin = false;
wx.showToast({
title: `登录失败:${err.message || err.errMsg}`,
icon: "none",
});
} finally {
wx.hideLoading();
}
},
},
});
+7
View File
@@ -0,0 +1,7 @@
<!--components/login/login.wxml-->
<view class="loginContainer" wx:if="{{!userName||!mbuId}}" catch:touchmove="emptyEvent">
<view class="contain">
<text>观看请点击学习课程,如不想观看请点击右上角圆圈“○”,退出小程序</text>
<view class="login" bind:tap="handleLogin">学习课程</view>
</view>
</view>
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+109
View File
@@ -0,0 +1,109 @@
/* 容器样式 */
.quiz-container {
padding: 40rpx 30rpx 230rpx;
box-sizing: border-box;
}
/* 题目卡片 */
.question-card {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
}
/* 题目标题 */
.question-title {
margin-bottom: 30rpx;
word-wrap: break-word;
white-space: normal;
}
.tag {
display: inline;
font-size: 28rpx;
margin-right: 16rpx;
border-radius: 6rpx;
font-size: 36rpx;
color: #04befe
}
.question-text {
font-size: 32rpx;
color: #333;
flex: 1;
width: 100%;
margin-top: 8rpx;
}
/* 选项区域 */
.options {
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* 选项项 */
.option-item {
display: flex;
align-items: center;
padding: 20rpx;
border-radius: 12rpx;
background: #f8f8f8;
transition: all 0.3s ease;
}
.option-item:active {
transform: scale(0.98);
}
/* 选项文本 */
.option-item text {
margin-left: 16rpx;
font-size: 28rpx;
color: #333;
flex: 1;
}
/* 正确答案样式 */
.option-item.correct {
background: #f6ffed;
border: 2rpx solid #b7eb8f;
color: #52c41a;
}
.option-item.correct text {
color: #52c41a;
font-weight: 500;
}
/* 错误答案样式 */
.option-item.wrong {
background: #fff2f0;
border: 2rpx solid #ffccc7;
color: #ff4d4f;
}
.option-item.wrong text {
color: #ff4d4f;
font-weight: 500;
}
/* 选中状态样式 - 使用微信小程序兼容的方式 */
.option-item radio:checked + text,
.option-item checkbox:checked + text {
color: #1890ff;
font-weight: 500;
}
/* 由于微信小程序不支持:has()选择器,我们通过JS动态添加class来实现选中状态样式 */
.option-item.selected {
background: #e6f7ff;
// border: 2rpx solid #91d5ff;
}
.option-item.selected text {
color: #1890ff;
font-weight: 500;
}
+216
View File
@@ -0,0 +1,216 @@
// 定义接口类型
interface Question {
id: number;
type: number;
question: string;
option: Record<string, any>;
}
interface AnswerItem {
queId: string;
ans: string[];
suc?: boolean;
}
interface QuizComponent {
properties: {
questions: Question[];
answerList: AnswerItem[];
};
data: {
selectedAnswers: Record<number, string[]>;
renderQuestions: Array<
Question & { options: Array<{ key: string; value: string }> }
>;
};
setData: (data: Partial<Record<string, any>>, callback?: () => void) => void;
triggerEvent: (name: string, data?: any) => void;
isSelected: (qid: string | number, key: string) => boolean;
getSelectedAnswer: (qid: string | number, index: number) => string;
isCorrect: (qid: string, key: string) => boolean;
isWrong: (qid: string, key: string) => boolean;
handleRadioChange: (e: WechatMiniprogram.RadioGroupChange) => void;
handleCheckboxChange: (e: WechatMiniprogram.CheckboxGroupChange) => void;
getFormattedAnswers: () => Array<{
questionId: number;
answers: string[];
questionType: number;
}>;
getAllAnswers: () => Array<{
questionId: number;
answers: string[];
questionType: number;
}>;
isAllAnswered: () => boolean;
getProgress: () => { answered: number; total: number; percentage: number };
}
// 定义常量
const QUESTION_TYPE = {
SINGLE: 1,
MULTIPLE: 2,
};
Component({
properties: {
questions: {
type: Array,
value: [],
},
answerList: {
type: Array,
value: [],
},
},
observers: {
"questions, answerList": function (this: QuizComponent) {
const renderQuestions = (this.properties.questions || []).map(
(q: Question) => ({
...q,
options: Object.keys(q.option || {}).map((k) => ({
key: k,
value: q.option[k],
})),
})
);
const answerList = Array.isArray(this.properties.answerList)
? this.properties.answerList
: [];
const selectedAnswers = answerList.reduce((acc, item) => {
if (item && item.queId && item.ans) {
acc[Number(item.queId)] = Array.isArray(item.ans)
? item.ans
: [item.ans];
}
return acc;
}, {} as Record<number, string[]>);
this.setData({
renderQuestions,
selectedAnswers,
});
},
},
data: {
selectedAnswers: {} as Record<number, string[]>,
renderQuestions: [] as Array<
Question & { options: Array<{ key: string; value: string }> }
>,
},
methods: {
isSelected(this: QuizComponent, qid: string | number, key: string) {
const selected = this.data.selectedAnswers[Number(qid)];
return Array.isArray(selected) && selected.includes(key);
},
getSelectedAnswer(this: QuizComponent, qid: string | number, index: number) {
const selected = this.data.selectedAnswers[Number(qid)];
return Array.isArray(selected) ? selected[index] : "";
},
isCorrect(this: QuizComponent, qid: string, key: string) {
const answerList = Array.isArray(this.properties.answerList)
? this.properties.answerList
: [];
return answerList.some((item: AnswerItem) => {
const answers = Array.isArray(item.ans) ? item.ans : [];
return String(item.queId) === String(qid) && answers.includes(key) && item.suc;
});
},
isWrong(this: QuizComponent, qid: string, key: string) {
const answerList = Array.isArray(this.properties.answerList)
? this.properties.answerList
: [];
return answerList.some((item: AnswerItem) => {
const answers = Array.isArray(item.ans) ? item.ans : [];
return String(item.queId) === String(qid) && answers.includes(key) && !item.suc;
});
},
// 处理单选变化 { queId: q.id, ans: selectedAnswers }
handleRadioChange(
this: QuizComponent,
e: WechatMiniprogram.RadioGroupChange
) {
const { questionId } = e.currentTarget.dataset;
const value = e.detail.value;
if (!questionId) return;
const numericQuestionId = Number(questionId);
const newAnswers = value ? [value] : [];
this.setData({
[`selectedAnswers.${numericQuestionId}`]: newAnswers,
});
this.triggerEvent("change", {
queId: numericQuestionId,
ans: newAnswers,
});
},
// 处理多选变化
handleCheckboxChange(
this: QuizComponent,
e: WechatMiniprogram.CheckboxGroupChange
) {
const { questionId } = e.currentTarget.dataset;
const values = Array.isArray(e.detail.value) ? e.detail.value : [];
if (!questionId) return;
const numericQuestionId = Number(questionId);
this.setData({
[`selectedAnswers.${numericQuestionId}`]: values,
});
this.triggerEvent("change", {
queId: numericQuestionId,
ans: values,
});
},
getFormattedAnswers(this: QuizComponent) {
const answers = [];
for (const [questionId, selectedOptions] of Object.entries(
this.data.selectedAnswers
)) {
if (selectedOptions && selectedOptions.length > 0) {
answers.push({
queId: Number(questionId),
ans: selectedOptions,
});
}
}
return answers;
},
getAllAnswers(this: QuizComponent) {
return this.getFormattedAnswers();
},
isAllAnswered(this: QuizComponent) {
const totalQuestions = this.properties.questions.length;
const answeredQuestions = Object.values(this.data.selectedAnswers).filter(
(answers) => answers && answers.length > 0
).length;
return totalQuestions === answeredQuestions;
},
getProgress(this: QuizComponent) {
const totalQuestions = this.data.renderQuestions.length;
const answeredQuestions = Object.values(this.data.selectedAnswers).filter(
(answers) => answers && answers.length > 0
).length;
return {
answered: answeredQuestions,
total: totalQuestions,
percentage:
totalQuestions > 0
? Math.round((answeredQuestions / totalQuestions) * 100)
: 0,
};
},
},
});
@@ -0,0 +1,50 @@
<!--components/my-quiz/my-quiz.wxml-->
<view class="quiz-container">
<block wx:for="{{renderQuestions}}" wx:key="id" wx:for-item="q" wx:for-index="qi">
<view class="question-card">
<view class="question-title">
<text class="tag">{{qi + 1}}.</text>
<text class="tag" wx:if="{{q.type == 2}}">[ 多选题 ]</text>
<text class="tag" wx:else>[ 单选题 ]</text>
{{q.question}}
</view>
<view class="options">
<!-- 单选题使用 radio-group -->
<radio-group
wx:if="{{q.type == 1}}"
bindchange="handleRadioChange"
data-question-id="{{q.id}}"
value="{{getSelectedAnswer(q.id, 0)}}"
>
<block wx:for="{{q.options}}" wx:key="key" wx:for-item="op" wx:if="{{op.value}}">
<label class="option-item {{isCorrect(q.id, op.key) ? 'correct' : isWrong(q.id, op.key) ? 'wrong' : ''}} {{getSelectedAnswer(q.id, 0) === op.key ? 'selected' : ''}}">
<radio
value="{{op.key}}"
checked="{{getSelectedAnswer(q.id, 0) === op.key}}"
/>
<text>{{op.key}}. {{op.value}}</text>
</label>
</block>
</radio-group>
<!-- 多选题使用 checkbox-group -->
<checkbox-group
wx:if="{{q.type == 2}}"
bindchange="handleCheckboxChange"
data-question-id="{{q.id}}"
value="{{selectedAnswers[q.id] || []}}"
>
<block wx:for="{{q.options}}" wx:key="key" wx:for-item="op" wx:if="{{op.value}}">
<label class="option-item {{isCorrect(q.id, op.key) ? 'correct' : isWrong(q.id, op.key) ? 'wrong' : ''}} {{isSelected(q.id, op.key) ? 'selected' : ''}}">
<checkbox
value="{{op.key}}"
checked="{{isSelected(q.id, op.key)}}"
/>
<text>{{op.key}}. {{op.value}}</text>
</label>
</block>
</checkbox-group>
</view>
</view>
</block>
</view>
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
@@ -0,0 +1,83 @@
/* components/my-video.wxss */
.live-content {
max-width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
z-index: 1000;
}
.video {
width: 100%;
min-height: 400rpx; /* 200px -> 400rpx */
background-color: #eeeeee;
}
.screen {
.video {
height: 100%;
position: absolute;
z-index: -1;
}
}
.tools {
position: absolute;
display: flex;
width: 100%;
height: 100%;
bottom: 0;
justify-content: space-between;
align-items: flex-end;
color: white;
transition: opacity 0.3s ease;
}
.video-poster {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.3);
z-index: 2;
.play-button {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.7);
.play-icon {
font-size: 50rpx;
color:white;
margin-left: 6rpx;
margin-bottom: 3rpx;
}
}
}
.poster-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.cover {
position: absolute;
bottom: 0;
z-index: 998;
height: 20%;
width: 70%;
left: 10%;
background-color: rgba(255, 255, 255, 0.007);
}
+262
View File
@@ -0,0 +1,262 @@
// components/my-video/index.ts
import { AES_KEY, AES_IV } from "../../env";
import { request } from "../../utils/request";
import { aesEncrypt } from "../../utils/crypto";
interface VideoData {
screen: boolean;
playing: boolean;
currentTime: number;
isReady: boolean;
sendDurationTimer: number | null;
lastUpdateTime: number;
isConnected: boolean; // 新增网络连接状态字段
}
interface MyVideoComponent {
data: VideoData;
videoContext: WechatMiniprogram.VideoContext | null;
pauseVideoHandler?: () => void;
resumeVideoHandler?: () => void;
setData: (d: Partial<VideoData>, cb?: () => void) => void;
initNetworkListener: () => void;
startSendProgress: () => void;
clearSendProgressTimer: () => void;
sendProgress: () => Promise<void>;
}
Component({
properties: {
src: String,
backgroundUrl: String,
},
data: {
screen: false,
playing: false,
currentTime: 0,
isReady: false,
isConnected: false,
sendDurationTimer: 0,
lastUpdateTime: 0,
} as VideoData,
lifetimes: {
attached(this: MyVideoComponent) {
this.initNetworkListener();
// 获取 videoContext 并初始化
this.videoContext = wx.createVideoContext("videoPlayer", this as any);
const pauseVideo = () => {
if (this.data.playing) {
wx.setStorageSync("interruptedTime", this.data.currentTime);
this.videoContext?.pause();
this.setData({ playing: false });
this.clearSendProgressTimer();
}
};
const resumeVideo = () => {
if (!this.videoContext) {
this.videoContext = wx.createVideoContext("videoPlayer", this as any);
}
this.videoContext.pause();
};
this.pauseVideoHandler = pauseVideo;
this.resumeVideoHandler = resumeVideo;
wx.onAudioInterruptionBegin(this.pauseVideoHandler);
wx.onAudioInterruptionEnd(this.resumeVideoHandler);
wx.onAppHide(this.pauseVideoHandler);
wx.onAppShow(this.resumeVideoHandler);
},
detached(this: MyVideoComponent) {
if (this.pauseVideoHandler) {
wx.offAudioInterruptionBegin(this.pauseVideoHandler);
wx.offAppHide(this.pauseVideoHandler);
}
if (this.resumeVideoHandler) {
wx.offAudioInterruptionEnd(this.resumeVideoHandler);
wx.offAppShow(this.resumeVideoHandler);
}
this.clearSendProgressTimer();
this.videoContext = null;
},
},
methods: {
initNetworkListener(this: MyVideoComponent) {
// 首先获取当前网络状态
wx.getNetworkType({
success: (res) => {
this.setData({
isConnected: res.networkType !== "none",
});
},
});
wx.onNetworkStatusChange(
(res: WechatMiniprogram.OnNetworkStatusChangeCallbackResult) => {
this.setData({
isConnected: res.isConnected,
});
if (!res.isConnected && this.data.playing) {
this.videoContext?.pause();
this.setData({ playing: false });
wx.showToast({
title: "网络连接已断开",
icon: "none",
duration: 2000,
});
}
if (res.isConnected) {
wx.showToast({
title: "网络已连接",
icon: "success",
duration: 1500,
});
}
}
);
},
handleLoadedMetadata(this: MyVideoComponent) {
this.setData({ isReady: true });
},
handlePosterTap(this: MyVideoComponent) {
if (!this.videoContext || !this.data.isReady) return;
if (!this.data.isConnected) {
wx.showToast({
title: "网络连接异常,请检查网络设置",
icon: "none",
duration: 2000,
});
return;
}
const newPlaying = !this.data.playing;
const resumeTime =
(wx.getStorageSync("interruptedTime") as number) ||
this.data.currentTime ||
(wx.getStorageSync("currentTime") as number) ||
0;
if (newPlaying) {
if (resumeTime > 0) {
this.videoContext.seek(resumeTime);
}
this.videoContext.play();
if (!this.data.sendDurationTimer) {
this.startSendProgress();
}
} else {
this.videoContext.pause();
this.clearSendProgressTimer();
}
this.setData({ playing: newPlaying });
wx.removeStorageSync("interruptedTime");
},
handlePause(this: MyVideoComponent) {
wx.setStorageSync("interruptedTime", this.data.currentTime);
this.setData({ playing: false });
this.clearSendProgressTimer();
},
handleTimeUpdate(
this: MyVideoComponent,
e: WechatMiniprogram.VideoTimeUpdate
) {
const now = Date.now();
const currentTime = Math.floor(e.detail.currentTime ?? 0);
if (now - this.data.lastUpdateTime >= 1000) {
this.setData({ currentTime });
(this.data as VideoData).lastUpdateTime = now;
wx.setStorageSync("currentTime", currentTime);
}
},
handleEnded(this: MyVideoComponent) {
this.sendProgress();
wx.setStorageSync("ended", true);
this.setData({ playing: false, currentTime: 0 });
wx.removeStorageSync("currentTime");
wx.removeStorageSync("interruptedTime");
this.clearSendProgressTimer();
},
handleFullscreenChange(this: MyVideoComponent) {
const newScreen = !this.data.screen;
if (newScreen) {
this.videoContext?.requestFullScreen({ direction: 90 });
} else {
this.videoContext?.exitFullScreen();
}
this.setData({ screen: newScreen });
},
handleError(this: MyVideoComponent, e: WechatMiniprogram.VideoError) {
console.error("播放错误:", e.detail.errMsg);
this.setData({ playing: false });
},
startSendProgress(this: MyVideoComponent) {
this.clearSendProgressTimer();
const timer = setInterval(() => {
this.sendProgress();
}, 15000) as unknown as number;
this.setData({ sendDurationTimer: timer });
},
clearSendProgressTimer(this: MyVideoComponent) {
const t = this.data.sendDurationTimer;
if (t) {
clearInterval(t);
this.setData({ sendDurationTimer: 0 });
}
},
async sendProgress(this: MyVideoComponent) {
try {
const openId = wx.getStorageSync("openId");
const code = wx.getStorageSync("code");
const state = wx.getStorageSync("state");
const seeId = wx.getStorageSync("seeId");
const duration = this.data.currentTime + 1;
if (code) {
const params = {
openId,
code,
duration
};
await request({
options: {
url: `/app/video-watch/inspect`,
data: aesEncrypt(JSON.stringify(params), AES_KEY, AES_IV),
},
isLoading: false,
});
} else {
const params = {
openId,
state,
seeId,
duration
};
await request({
options: {
url: `/app/watch/inspect`,
data: aesEncrypt(JSON.stringify(params), AES_KEY, AES_IV),
},
isLoading: false,
});
}
} catch (err) {
console.error("上报观看进度失败:", err);
}
},
},
});
@@ -0,0 +1,33 @@
<!--components/my-video.wxml-->
<view class="live-content">
<video id="videoPlayer"
class="video"
danmu-list="{{danmuList}}"
enable-danmu="{{false}}"
show-fullscreen-btn
show-center-play-btn='{{false}}'
enable-progress-gesture="{{false}}"
show-play-btn="{{true}}"
controls="{{true}}"
object-fit="contain"
picture-in-picture-mode="{{[]}}"
show-background-playback-button="{{false}}"
src="{{src}}"
bindended="handleEnded"
binderror="handleError"
bindpause="handlePause"
bindtimeupdate="handleTimeUpdate"
bindfullscreenchange="handleFullscreenChange"
bindloadedmetadata="handleLoadedMetadata"
>
<cover-view class="cover"></cover-view>
<image wx:if="{{!playing}}" src="{{backgroundUrl}}" mode="aspectFill" class="poster-image"></image>
<view class="video-poster" wx:if="{{!playing}}" catchtap="handlePosterTap">
<view class="play-button">
<view class="play-icon">
</view>
</view>
</view>
</video>
</view>
@@ -0,0 +1,5 @@
{
"component": true,
"styleIsolation": "apply-shared",
"usingComponents": {}
}
@@ -0,0 +1,97 @@
.weui-navigation-bar {
--weui-FG-0:rgba(0,0,0,.9);
--height: 44px;
--left: 16px;
}
.weui-navigation-bar .android {
--height: 48px;
}
.weui-navigation-bar {
overflow: hidden;
color: var(--weui-FG-0);
flex: none;
}
.weui-navigation-bar__inner {
position: relative;
top: 0;
left: 0;
height: calc(var(--height) + env(safe-area-inset-top));
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding-top: env(safe-area-inset-top);
width: 100%;
box-sizing: border-box;
}
.weui-navigation-bar__left {
position: relative;
padding-left: var(--left);
padding-top: 16rpx; //默认位置偏高
display: flex;
flex-direction: row;
align-items: flex-start;
height: 100%;
box-sizing: border-box;
}
.weui-navigation-bar__btn_goback_wrapper {
padding: 11px 18px 11px 16px;
margin: -11px -18px -11px -16px;
}
.weui-navigation-bar__btn_goback_wrapper.weui-active {
opacity: 0.5;
}
.weui-navigation-bar__btn_goback {
font-size: 12px;
width: 12px;
height: 24px;
-webkit-mask: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='24' viewBox='0 0 12 24'%3E %3Cpath fill-opacity='.9' fill-rule='evenodd' d='M10 19.438L8.955 20.5l-7.666-7.79a1.02 1.02 0 0 1 0-1.42L8.955 3.5 10 4.563 2.682 12 10 19.438z'/%3E%3C/svg%3E") no-repeat 50% 50%;
mask: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='24' viewBox='0 0 12 24'%3E %3Cpath fill-opacity='.9' fill-rule='evenodd' d='M10 19.438L8.955 20.5l-7.666-7.79a1.02 1.02 0 0 1 0-1.42L8.955 3.5 10 4.563 2.682 12 10 19.438z'/%3E%3C/svg%3E") no-repeat 50% 50%;
-webkit-mask-size: cover;
mask-size: cover;
background-color: var(--weui-FG-0);
}
.weui-navigation-bar__center {
font-size: 17px;
text-align: center;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
font-weight: bold;
flex: 1;
height: 100%;
}
.weui-navigation-bar__loading {
margin-right: 4px;
align-items: center;
}
.weui-loading {
font-size: 16px;
width: 16px;
height: 16px;
display: block;
background: transparent url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='80px' height='80px' viewBox='0 0 80 80' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3Eloading%3C/title%3E%3Cdefs%3E%3ClinearGradient x1='94.0869141%25' y1='0%25' x2='94.0869141%25' y2='90.559082%25' id='linearGradient-1'%3E%3Cstop stop-color='%23606060' stop-opacity='0' offset='0%25'%3E%3C/stop%3E%3Cstop stop-color='%23606060' stop-opacity='0.3' offset='100%25'%3E%3C/stop%3E%3C/linearGradient%3E%3ClinearGradient x1='100%25' y1='8.67370605%25' x2='100%25' y2='90.6286621%25' id='linearGradient-2'%3E%3Cstop stop-color='%23606060' offset='0%25'%3E%3C/stop%3E%3Cstop stop-color='%23606060' stop-opacity='0.3' offset='100%25'%3E%3C/stop%3E%3C/linearGradient%3E%3C/defs%3E%3Cg stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' opacity='0.9'%3E%3Cg%3E%3Cpath d='M40,0 C62.09139,0 80,17.90861 80,40 C80,62.09139 62.09139,80 40,80 L40,73 C58.2253967,73 73,58.2253967 73,40 C73,21.7746033 58.2253967,7 40,7 L40,0 Z' fill='url(%23linearGradient-1)'%3E%3C/path%3E%3Cpath d='M40,0 L40,7 C21.7746033,7 7,21.7746033 7,40 C7,58.2253967 21.7746033,73 40,73 L40,80 C17.90861,80 0,62.09139 0,40 C0,17.90861 17.90861,0 40,0 Z' fill='url(%23linearGradient-2)'%3E%3C/path%3E%3Ccircle id='Oval' fill='%23606060' cx='40.5' cy='3.5' r='3.5'%3E%3C/circle%3E%3C/g%3E%3C/g%3E%3C/svg%3E%0A") no-repeat;
background-size: 100%;
margin-left: 0;
animation: loading linear infinite 1s;
}
@keyframes loading {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,143 @@
interface NavigationBarComponent {
data: {
displayStyle: string;
ios?: boolean;
innerPaddingRight?: string;
leftWidth?: string;
safeAreaTop?: string;
delta?: any;
animated?: any;
};
setData: (
data: Partial<NavigationBarComponent["data"]>,
callback?: () => void
) => void;
triggerEvent: <DetailType = any>(
name: string,
detail?: DetailType,
options?: WechatMiniprogram.Component.TriggerEventOption
) => void;
_showChange: (show: boolean) => void;
back: () => void;
handleBack: () => void;
}
Component({
options: {
multipleSlots: true, // 在组件定义时的选项中启用多slot支持
},
/**
* 组件的属性列表
*/
properties: {
extClass: {
type: String,
value: "",
},
title: {
type: String,
value: "",
},
background: {
type: String,
value: "",
},
color: {
type: String,
value: "",
},
back: {
type: Boolean,
value: true,
},
loading: {
type: Boolean,
value: false,
},
homeButton: {
type: Boolean,
value: false,
},
animated: {
// 显示隐藏的时候opacity动画效果
type: Boolean,
value: true,
},
show: {
// 显示隐藏导航,隐藏的时候navigation-bar的高度占位还在
type: Boolean,
value: true,
observer: "_showChange",
},
// back为true的时候,返回的页面深度
delta: {
type: Number,
value: 1,
},
},
/**
* 组件的初始数据
*/
data: {
displayStyle: "",
},
lifetimes: {
attached(this: NavigationBarComponent) {
const rect = wx.getMenuButtonBoundingClientRect();
wx.getSystemInfo({
success: (res) => {
const isAndroid = res.platform === "android";
const isDevtools = res.platform === "devtools";
this.setData({
ios: !isAndroid,
innerPaddingRight: `padding-right: ${res.windowWidth - rect.left
}px`,
leftWidth: `width: ${res.windowWidth - rect.left}px`,
safeAreaTop:
isDevtools || isAndroid
? `height: calc(var(--height) + ${res.safeArea.top
}px); padding-top: ${res.safeArea.top || 24}px`
: ``,
});
},
});
},
},
/**
* 组件的方法列表
*/
methods: {
_showChange(this: NavigationBarComponent, show: boolean) {
const animated = this.data.animated;
let displayStyle = "";
if (animated) {
displayStyle = `opacity: ${show ? "1" : "0"};transition:opacity 0.5s;`;
} else {
displayStyle = `display: ${show ? "" : "none"}`;
}
this.setData({
displayStyle,
});
},
back(this: NavigationBarComponent) {
this.triggerEvent("handleBack", { delta: 1 });
const data = this.data;
const pages = getCurrentPages();
//通过链接进入,或只有一个栈时,点击返回是没有用的,直接返回首页
if (pages.length <= 1) {
wx.switchTab({ url: "/pages/tab-bar/medicine-box/index" });
return;
}
if (data.delta) {
wx.navigateBack({
delta: data.delta,
});
} else {
wx.switchTab({ url: "/pages/tab-bar/medicine-box/index" });
}
this.triggerEvent("back", { delta: data.delta }, {});
},
},
});
@@ -0,0 +1,64 @@
<view class="weui-navigation-bar {{extClass}}">
<view class="weui-navigation-bar__inner {{ios ? 'ios' : 'android'}}" style="color: {{color}}; background: {{background}}; {{displayStyle}}; {{innerPaddingRight}}; {{safeAreaTop}};">
<!-- 左侧按钮 -->
<view class='weui-navigation-bar__left' style="{{leftWidth}};">
<block wx:if="{{back || homeButton}}">
<!-- 返回上一页 -->
<block wx:if="{{back}}">
<view class="weui-navigation-bar__buttons weui-navigation-bar__buttons_goback">
<view
bindtap="back"
class="weui-navigation-bar__btn_goback_wrapper"
hover-class="weui-active"
hover-stay-time="100"
aria-role="button"
aria-label="返回"
>
<view class="weui-navigation-bar__button weui-navigation-bar__btn_goback"></view>
</view>
</view>
</block>
<!-- 返回首页 -->
<block wx:if="{{homeButton}}">
<view class="weui-navigation-bar__buttons weui-navigation-bar__buttons_home">
<view
bindtap="home"
class="weui-navigation-bar__btn_home_wrapper"
hover-class="weui-active"
aria-role="button"
aria-label="首页"
>
<view class="weui-navigation-bar__button weui-navigation-bar__btn_home"></view>
</view>
</view>
</block>
</block>
<block wx:else>
<slot name="left"></slot>
</block>
</view>
<!-- 标题 -->
<view class='weui-navigation-bar__center'>
<view wx:if="{{loading}}" class="weui-navigation-bar__loading" aria-role="alert">
<view
class="weui-loading"
aria-role="img"
aria-label="加载中"
></view>
</view>
<block wx:if="{{title}}">
<text>{{title}}</text>
</block>
<block wx:else>
<slot name="center"></slot>
</block>
</view>
<!-- 右侧留空 -->
<view class='weui-navigation-bar__right'>
<slot name="right"></slot>
</view>
</view>
</view>
@@ -0,0 +1,6 @@
{
"component": true,
"pageOrientation": "portrait",
"usingComponents": {
}
}
+14
View File
@@ -0,0 +1,14 @@
/* components/player.wxss */
.player-object {
width: 100%;
height: 100%;
}
.player-video {
width: 100%;
height: 100%;
}
.video {
width: 100%;
height: 100vh;
overflow: hidden;
}
+206
View File
@@ -0,0 +1,206 @@
interface PlayerProperties {
isPlayback: boolean;
isLiving: boolean;
pullUrlInfo: {
rtmpUrl: string;
rtmpOriaacUrl: string;
hlsOriaacUrl: string;
hlsUrl: string;
};
vodInfo: {
state: number;
pullUrl: string;
};
}
interface PlayerData {
liveUrl: string;
vodUrl: string;
isPlaying: boolean;
showVideo: boolean;
fullScreen: boolean;
height: number;
retryCount: number;
maxRetryCount: number;
}
interface PlayerComponent extends WechatMiniprogram.Component.TrivialInstance {
data: PlayerData;
properties: PlayerProperties;
videoContext: WechatMiniprogram.VideoContext | null;
}
Component({
properties: {
isPlayback: {
type: Boolean,
value: false,
},
isLiving: {
type: Boolean,
value: false,
},
pullUrlInfo: {
type: Object,
value: {
rtmpUrl: "",
rtmpOriaacUrl: "",
hlsOriaacUrl: "",
hlsUrl: "",
},
},
vodInfo: {
type: Object,
value: {
state: 0,
pullUrl: "",
},
},
},
data: {
liveUrl: "",
vodUrl: "",
isPlaying: false,
fullScreen: false,
height: 0,
showVideo: true,
retryCount: 0,
maxRetryCount: 3,
},
lifetimes: {
attached(this: PlayerComponent) {
this.initPlayer();
},
detached(this: PlayerComponent) {
this.cleanup();
},
},
observers: {
"isLiving, pullUrlInfo": function (
this: PlayerComponent,
isLiving: boolean,
pullUrlInfo: PlayerProperties["pullUrlInfo"]
) {
if (isLiving && pullUrlInfo.rtmpUrl) {
this.setData({ liveUrl: pullUrlInfo.rtmpUrl });
} else {
this.setData({ liveUrl: "" });
}
},
vodInfo: function (
this: PlayerComponent,
data: PlayerProperties["vodInfo"]
) {
if (data && data.pullUrl) {
this.setData({
vodUrl: data.pullUrl,
isPlaying: true,
showVideo: true,
});
this.playVideo();
}
},
},
methods: {
playVideo(this: PlayerComponent) {
if (!this.videoContext || !this.data.showVideo) return;
setTimeout(() => {
try {
this.videoContext?.play();
} catch (error) {
this.handlePlayError();
}
}, 500);
},
handlePlayError(this: PlayerComponent) {
const { retryCount, maxRetryCount } = this.data;
if (retryCount < maxRetryCount) {
this.setData({ retryCount: retryCount + 1 });
setTimeout(() => {
this.playVideo();
}, 2000);
} else {
this.setData({
showVideo: false,
retryCount: 0,
});
wx.showToast({
title: "播放失败,请检查网络连接",
icon: "none",
});
}
},
initPlayer(this: PlayerComponent) {
try {
const sysInfo = wx.getSystemInfoSync();
this.setData({ height: sysInfo.screenHeight });
this.videoContext = wx.createVideoContext("vodVideo", this);
} catch (err) {
console.error("初始化播放器失败:", err);
wx.showToast({ title: "播放器初始化失败", icon: "none" });
}
},
onLiveStateChange(
this: PlayerComponent,
event: WechatMiniprogram.CustomEvent
) {
const code = event.detail.code;
const stateMap: Record<number, string> = {
2001: "已连接",
2002: "已断开",
2003: "缓冲中",
2004: "播放开始",
2005: "播放结束",
2006: "加载中",
};
console.log("直播状态:", stateMap[code] || `未知(${code})`);
},
onLiveError(this: PlayerComponent, event: WechatMiniprogram.CustomEvent) {
const errCode = event.detail.errCode;
const errorMap: Record<number, string> = {
10001: "系统错误",
10002: "网络错误",
10003: "解码错误",
10004: "直播流不存在",
};
const msg = errorMap[errCode] || `直播错误: ${event.detail.errMsg}`;
wx.showToast({ title: msg, icon: "none" });
},
onVideoPlay(this: PlayerComponent) {
this.setData({ isPlaying: true, retryCount: 0, showVideo: true });
},
onVideoPause(this: PlayerComponent) {
this.setData({ isPlaying: false });
},
onVideoEnded(this: PlayerComponent) {
this.setData({ isPlaying: false });
},
onVideoError(this: PlayerComponent, err: WechatMiniprogram.CustomEvent) {
if (err) {
this.setData({ isPlaying: false, showVideo: false });
this.triggerEvent("controlstoggle", { state: 2 });
}
},
cleanup(this: PlayerComponent) {
this.videoContext = null;
this.setData({
liveUrl: "",
vodUrl: "",
isPlaying: false,
fullScreen: false,
showVideo: true,
retryCount: 0,
});
},
},
});
+38
View File
@@ -0,0 +1,38 @@
<!--components/player.wxml-->
<view class="player-object">
<video
wx:if="{{showVideo && vodUrl}}"
id="vodVideo"
src="{{vodUrl}}"
autoplay="{{true}}"
show-fullscreen-btn="{{false}}"
show-play-btn="{{false}}"
show-center-play-btn="{{false}}"
class="video"
object-fit="contain"
direction="0"
bindplay="onVideoPlay"
bindpause="onVideoPause"
bindended="onVideoEnded"
binderror="onVideoError"
></video>
<live-player
wx:if="{{liveUrl}}"
live-url="{{liveUrl}}"
vod-url="{{vodUrl}}"
is-playback="{{isPlayback}}"
room-title="{{roomInfo.title}}"
viewer-count="{{roomInfo.viewerCount}}"
is-living="{{roomInfo.isLiving}}"
object-fit="contain"
mode="live"
class="player-video"
bindstatechange="onLiveStateChange"
binderror="onLiveError"
bindplay="onVideoPlay"
bindpause="onVideoPause"
bindended="onVideoEnded"
/>
</view>
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+44
View File
@@ -0,0 +1,44 @@
@import "../../variables";
.mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: flex-end;
z-index: 999;
opacity: 0;
transition: opacity 0.5s ease;
overscroll-behavior: none;
}
.mask.show {
opacity: 1;
}
.mask.hide {
opacity: 0;
}
.content {
width: 100%;
max-height: 90%;
background: #fff;
// background: linear-gradient(180deg, #edf0f7 0%, #e7ebf4 100%);
// box-shadow: 0 28rpx 70rpx rgba(11, 18, 35, 0.18);
border-top-left-radius: $primary-gap;
border-top-right-radius: $primary-border-radius;
padding: $primary-gap;
transform: translateY(100%);
transition: transform 0.5s ease;
}
.content.show {
transform: translateY(0);
}
.content.hide {
transform: translateY(100%);
}
+47
View File
@@ -0,0 +1,47 @@
// @ts-nocheck
const systemInfo = wx.getWindowInfo();
Component({
properties: {
visible: {
type: Boolean,
value: false,
observer(newVal:any) {
if (newVal) {
//先挂载再执行动画
this.setData({ showSelf: true }, () => {
setTimeout(() => {
this.setData({
popupAnimate: 'show',
maskAnimate: 'show'
})
}, 20)
})
} else {
//先执行动画,不见了再卸载
this.setData({
popupAnimate: 'hide',
maskAnimate: 'hide'
})
// 等动画结束再卸载
setTimeout(() => {
this.setData({ showSelf: false })
}, 500) //和transition一致
}
}
}
},
data: {
showSelf: false,
safeBottom: systemInfo.safeArea.bottom - systemInfo.safeArea.height, //底部的安全距离,避免被返回键挡住了
popupAnimate: '',
maskAnimate: ''
},
methods: {
handleClose() {
this.triggerEvent('close');
},
emptyEvent() {
}
}
})
+7
View File
@@ -0,0 +1,7 @@
<!-- 下半弹窗 -->
<view wx:if="{{showSelf}}" class="mask {{maskAnimate}}" bind:tap="handleClose" catchtouchmove="emptyEvent" >
<view class="content {{popupAnimate}}" style="padding-bottom: {{safeBottom}}rpx;" catchtap="emptyEvent" catchtouchmove="emptyEvent">
<slot name="header"></slot>
<slot></slot>
</view>
</view>
@@ -0,0 +1,7 @@
{
"component": true,
"usingComponents": {
"popup": "/components/popup/popup",
"tag":"/components/tag/tag"
}
}
@@ -0,0 +1,159 @@
/* components/buy-record/buy-record.wxss */
@import "variables";
.order {
margin-top: $primary-gap;
border-radius: $primary-border-radius;
background-color: white;
overflow: hidden;
.order-header {
border-bottom: 2rpx solid rgba(0, 0, 0, 0.1);
line-height: $primary-gap;
height: $primary-gap;
padding: $primary-gap;
text-align: end;
}
.order-footer {
border-top: 2rpx solid rgba(0, 0, 0, 0.1);
// height: $primary-gap;
padding: $primary-gap;
display: flex;
flex-direction: column;
gap: $primary-gap/2;
.order-price {
display: flex;
justify-content: space-between;
}
.order-total-price {
font-size: 32rpx;
font-weight: 700;
}
.order-trade-amount {
color: $primary-color;
font-size: 38rpx;
font-weight: 700;
}
.orderNo {
// font-size: $large-font-size;
}
.payTime {
// font-size: $large-font-size;
}
}
.item+.item {
border-top: 2rpx solid rgba(0, 0, 0, 0.1);
}
.item {
width: 702rpx;
background-color: white;
height: 300rpx;
.info {
display: flex;
justify-content: space-between;
padding: $primary-gap;
padding-bottom: 0;
height: 200rpx;
.left {
border-radius: $primary-border-radius;
overflow: hidden;
min-width: 200rpx;
width: 200rpx;
height: 200rpx;
margin-right: $primary-gap;
image {
width: 100%;
height: 100%;
}
}
.right {
display: flex;
flex-direction: column;
justify-content: space-around;
width: 100%;
.spu {
display: flex;
justify-content: space-between;
width: 100%;
font-size: $large-font-size;
font-weight: 700;
.spuName {
max-width: 300rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.sku {
display: flex;
justify-content: space-between;
width: 100%;
color: $gray-color;
.skuName {
max-width: 300rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.history {
width: 100%;
border-radius: $primary-border-radius;
text-align: end;
color: $primary-color;
}
}
}
}
}
.order:first-child {
margin-top: 0rpx;
}
.footer {
display: flex;
flex-direction: row;
height: 40rpx;
justify-content: space-between;
.totalPrice {
font-size: $large-font-size;
font-weight: 700;
margin-right: $primary-gap;
margin-top: $primary-gap/2;
}
}
// .popup-header {
// display: flex;
// align-items: center;
// font-weight: 700;
// font-size: $large-font-size;
// margin-bottom: $primary-gap * 2;
// }
@@ -0,0 +1,82 @@
import { Orders } from '../../../pages/tab-bar/user/user.d'
export const OrderStateMap: Record<number, any> = {
10: { id: 10, name: "未付款", color: "#ff0056" },
20: { id: 20, name: "已驳回", color: "#ff0056" },
30: { id: 30, name: "待发货", color: "#FA6905" },
40: { id: 40, name: "待审核", color: "#ff7500" },
50: { id: 50, name: "发货中", color: "#1a64d3" },
60: { id: 60, name: "已签收", color: "#00bc12" },
70: { id: 70, name: "售后中", color: "#00b1dd" },
80: { id: 80, name: "全退款", color: "#ff0000" },
90: { id: 90, name: "部分退款", color: "#d90000" },
100: { id: 100, name: "已取消", color: "#ffba00" },
};
export const DeliveryStateMap: Record<number, any> = {
10: { id: 10, name: "待确认", color: "#ff4c3e" },
15: { id: 15, name: "定时发货", color: "#ff4c3e" },
20: { id: 20, name: "待发送ERP", color: "gold" },
30: { id: 30, name: "仓库待发货", color: "#ff7500" },
40: { id: 40, name: "仓库已发货", color: "#FABD14" },
50: { id: 50, name: "物流已揽收", color: "#057748" },
60: { id: 60, name: "客户签收", color: "#00bc12" },
70: { id: 70, name: "客户半签", color: "#52c41a" },
80: { id: 80, name: "客户拒收", color: "#ff0000" },
100: { id: 100, name: "已取消", color: "#ffba00" },
110: { id: 110, name: "售后中", color: "#00B4F4" },
120: { id: 120, name: "售后完成", color: "#156AA8" },
};
//不能查看物流信息
Component({
properties: {
order: {
type: Object,
value: {} as Orders,
},
isDetail: {
type: Boolean,
value: false
}
},
data: {
item: [] as any[],
state: {},
orderTime: "",
payTime: "",
showHistoryPopup: false,
deliveryState: null
},
observers: {
'order': function (order: Orders) {
if (order && order.good) {
// @ts-ignore
this.setData({
item: order.good,
state: OrderStateMap[order.state],
orderTime: order.orderTime.replace("T", " "),
payTime: order.payTime ? order.payTime.replace("T", " ") : ""
})
}
if (order && order.deliveryState) {
// @ts-ignore
this.setData({
deliveryState: DeliveryStateMap[order.deliveryState]
})
}
}
},
methods: {
// openPopup() {
// this.setData({ showHistoryPopup: true })
// },
// closePopup() {
// this.setData({ showHistoryPopup: false })
// }
handleDetail(this: WechatMiniprogram.Component.TrivialInstance) {
if (this.data.isDetail) return;
wx.navigateTo({ url: `/pages/common/order-detail/order-detail?id=${this.data.order.orderNo}` })
}
}
})
export { }
@@ -0,0 +1,68 @@
<!--components/buy-record/buy-record.wxml-->
<view class="order">
<view class="order-header">订单创建时间 {{orderTime}}</view>
<view class="item" wx:for="{{item}}" wx:key="skuName" bind:tap="handleDetail">
<view class="info">
<view class="left">
<image src="{{item.picUrl}}" mode="aspectFill" />
</view>
<view class="right">
<view class="spu">
<view class="spuName">{{item.spuName}}</view>
<view class="price">{{item.price?'¥'+item.price / 100:"暂无报价"}}</view>
</view>
<view class="sku">
<view class="skuName">{{item.skuName}}</view>
<view class="count">x{{item.count}}</view>
</view>
<tag class="history" color="{{state.color}}" propStyle="margin-right: 0rpx;">{{state.name}}</tag>
<view class="history" style="color:{{deliveryState.color}}">{{deliveryState.name}}</view>
<!-- <view class="history">
查看物流
</view> -->
<view>
</view>
</view>
</view>
<view class="footer">
<view></view>
<view class="totalPrice">
应付款:¥{{item.totalPrice / 100}}
</view>
</view>
</view>
<view class="order-footer">
<view class="order-price">
<view class="order-total-price">订单总金额 ¥{{order.totalAmount/100}}</view>
<view class="order-total-price">
优惠金额:¥{{order.discountAmount/100 || 0.00}}
</view>
</view>
<view class="order-price">
<view class="order-trade-amount">
实付金额 ¥{{order.advancePrice/100 || 0.00}}
</view>
<view class="order-trade-amount" >
{{state>=70?'退款金额':'支付金额'}}
¥{{order.tradeAmount/100}}
</view>
</view>
<view class="orderNo" wx:if="{{order.orderNo}}">订单号 {{order.orderNo}}</view>
<view class="orderNo" wx:if="{{order.transactionId}}">微信订单号 {{order.transactionId}}</view>
<view class="payTime" wx:if="{{order.payTime}}">订单支付时间 {{payTime||'未付款'}}</view>
<view class="payTime" wx:if="{{order.sendTime}}">订单发货时间 {{sendTime||'未付款'}}</view>
</view>
</view>
<!-- <popup visible="{{showHistoryPopup}}" bind:close="closePopup">
<view slot="header" class="popup-header">
<text>物流信息</text>
</view>
<view>
这里显示物流的信息
</view>
</popup> -->
@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"van-stepper": "@vant/weapp/stepper/index"
}
}
@@ -0,0 +1,101 @@
/* components/cart-item/cart-item.wxss */
@import "variables";
.item {
background-color: white;
// background-color: red;
height: 300rpx;
margin-bottom: $primary-gap;
border-radius: $primary-border-radius;
display: flex;
justify-content: space-between;
padding: $primary-gap;
.left {
height: 300rpx;
display: flex;
justify-content: space-between;
flex-direction: column;
.pic {
border-radius: $primary-border-radius;
overflow: hidden;
min-width: 200rpx;
width: 200rpx;
height: 200rpx;
margin-right: $primary-gap;
image {
width: 100%;
height: 100%;
}
}
.delete {
height: 100rpx;
line-height: 120rpx;
margin-left: $primary-gap/2;
.delete-button {
color: $danger-color;
font-size: $large-font-size;
}
}
}
.right {
height: 200rpx;
display: flex;
flex-direction: column;
justify-content: space-around;
height: 300rpx;
max-width: 440rpx;
.name {
font-size: 40rpx;
font-weight: 700;
max-width: 500rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.price {
font-size: $large-font-size;
font-weight: 700;
text-align: end;
color: $primary-color;
}
.count {
display: flex;
align-items: center;
width: 100%;
text-align: end;
justify-content: flex-end;
float: right;
.stepper {
margin-right: $primary-gap/2;
}
.unit {
font-size: $large-font-size;
text-align: end;
}
}
.stock {
color: $gray-color;
text-align: end;
}
.total-price {
font-size: $large-font-size;
font-weight: 700;
text-align: end;
color: $primary-color;
}
}
}
@@ -0,0 +1,66 @@
// @ts-nocheck
// components/cart-item/cart-item.ts
export { }
const app = getApp<IAppOption>();
Component({
/**
* 组件的属性列表
*/
properties: {
item: {},
shoppingCart: {},
},
/**
* 组件的初始数据
*/
data: {
totalPrice: 0,
showStock: app.globalData.showStock
},
/**
* 组件的方法列表
*/
methods: {
calcTotalPrice() {
if (!this.data.shoppingCart || !this.data.shoppingCart.data) return 0;
const total = this.data.shoppingCart.data.reduce((sum: number, item: any) => {
return sum + item.price * item.count;
}, 0);
this.setData({ totalPrice: total });
},
handleCountChange(e: any) {
this.setData({ item: { ...this.data.item, count: e.detail } })
const target = this.data.shoppingCart.data.find(
(item: any) => item.skuId == this.data.item.skuId
)
if (target) target.count = e.detail;
let storage = wx.getStorageSync("shoppingCart") || [];
storage = storage.map((cart: any) => {
if (cart.mbuId == this.data.shoppingCart.mbuId) {
return this.data.shoppingCart;
}
return cart;
});
wx.setStorageSync("shoppingCart", storage);
this.calcTotalPrice();
this.triggerEvent('handleCountChange', {
totalPrice: this.data.totalPrice
})
},
handleDelete() {
this.triggerEvent('delete', {
skuId: this.data.item.skuId
})
}
},
lifetimes: {
created: function () {
}
}
})
@@ -0,0 +1,30 @@
<!--components/cart-item/cart-item.wxml-->
<view class="item">
<view class="left">
<view class="pic">
<image src="{{item.picUrl}}"></image>
</view>
<view class="delete">
<view bind:tap="handleDelete" class="delete-button">
删除
</view>
</view>
</view>
<view class="right">
<view class="name">{{item.name}}</view>
<view class="price">¥ {{item.price/100}} </view>
<!-- 这里用计数器 -->
<view class="count">
<!-- max="{{showStock?checkItem ? checkItem.stock : skus[0].stock :99999}}" -->
<van-stepper integer min="1" bind:change="handleCountChange" max="{{showStock?item.stock:99999}}" value="{{item.count}}"
class="stepper"></van-stepper>
<view class="unit">{{item.unit}}</view>
</view>
<view class="total-price">总金额:¥ {{item.price*item.count/100}}</view>
<view class="stock" wx:if="{{showStock}}">库存:{{item.stock}}</view>
</view>
</view>
@@ -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

@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"popup": "/components/popup/popup"
}
}
@@ -0,0 +1,186 @@
/* components/static/coupon-popup/coupon-popup.wxss */
.popup-header {
font-size: 36rpx;
color: #FA6905;
font-weight: 600;
padding-bottom: 12rpx;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.1);
}
.list-container {
margin-top: 24rpx;
position: relative;
box-shadow: rgba(0, 0, 0, 0.1) 0px 2px 8px;
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 24rpx;
background: rgba(0, 0, 0, 0);
.default {
background: linear-gradient(135deg, rgb(255, 107, 107) 0%, rgb(255, 142, 83) 100%);
padding: 32rpx;
border-radius: 24rpx;
color: #fff;
display: flex;
flex-direction: column;
gap: 20rpx;
min-height: 240rpx;
.default-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
.amount {
font-size: 48rpx;
font-weight: 700;
line-height: 1;
}
.min-price {
font-size: 24rpx;
opacity: 0.9;
}
.name {
font-size: 28rpx;
font-weight: 600;
line-height: 1.3;
height: 76rpx;
overflow: hidden;
}
.desc {
font-size: 24rpx;
opacity: 0.85;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.default-bottom {
display: flex;
justify-content: space-between;
}
.id {
opacity: 0;
user-select: none;
}
}
.large {
display: flex;
flex-direction: row;
justify-content: space-around;
gap: 32rpx;
color: #fff;
// height: 160rpx;
background: linear-gradient(135deg, rgb(255, 107, 107) 0%, rgb(255, 142, 83) 100%);
padding: 32rpx;
border-radius: 24rpx;
.large-left {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
.amount {
font-size: 48rpx;
font-weight: 700;
line-height: 1;
}
.min-price {
font-size: 24rpx;
opacity: 0.9;
}
}
.large-center {
width: 1px;
background: white;
margin: 0rpx;
}
.large-right {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
gap: 8rpx;
width: 100%;
.large-title {
display: flex;
align-items: center;
.name {
font-weight: bold;
font-size: 30rpx;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.desc {
font-size: 24rpx;
opacity: 0.85;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.large-bottom {
display: flex;
align-items: center;
justify-content: space-between;
.id {
opacity: 0;
user-select: none;
}
.large-button {
margin-top: 8rpx;
}
}
}
}
.button {
background: rgba(255, 255, 255, 0.9);
border: none;
color: #333;
font-weight: 600;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
min-width: 140rpx;
height: 48rpx;
border-radius: 24rpx;
line-height: 48rpx;
font-size: 28rpx;
margin: 0;
color: #FA6905;
}
}
.no-use{
width: 100%;
background-color:rgb(255, 125, 95);
border-radius:24rpx;
margin-top:24rpx;
height: 70rpx;
line-height: 70rpx;
text-align: center;
font-weight: 800;
font-size: 38rpx;
color: #fff;
}
@@ -0,0 +1,103 @@
// components/static/coupon-popup/coupon-popup.ts
import { request } from "../../../utils/request";
const app = getApp<IAppOption>();
Component({
/**
* 组件的属性列表
*/
properties: {
hasTabBar: {
type: Boolean,
value: false,
},
},
/**
* 组件的初始数据
*/
data: {
visible: false,
couponList: [],
safeBottom: app.globalData.safeBottom,
},
/**
* 组件的方法列表
*/
methods: {
async handleSwitch(amount?: number) {
const that = this as any;
if (amount) {
try {
const res = await request({
options: {
url: "/app/mini-app/user-coupon",
data: {
amount,
mbuId: wx.getStorageSync('mbuId')
}
},
needLogin: true
})
if (!res || !res.data || res.data.length == 0) {
// wx.showToast({
// title: "没有可使用的优惠券",
// icon: "none",
// duration: 2000
// })
that.triggerEvent('select', {
coupon: null
});
return;
} else {
if (res.data.length > 0) res.data.forEach((item: any) => {
item.discountPrice = (item.discountPrice / 100).toFixed(2);
item.usePrice = item.usePrice / 100;
item.validStartTime = item.validStartTime.replace("T", " ");
item.validEndTime = item.validEndTime.replace("T", " ");
}); //金额单位为分
that.setData({ couponList: res.data, visible: true })
}
} catch (e) {
console.log(e, 'error');
wx.showToast({
title: "查询优惠券信息出错了!",
icon: "none",
duration: 2000
})
that.setData({
visible: false,
})
}
} else {
that.setData({
visible: false,
})
}
},
//选择优惠券
handleSelect(e: any) {
const that = this as any;
const index = e.currentTarget.dataset.index;
const coupon = that.data.couponList[index];
that.setData({ visible: false });
that.triggerEvent('select', {
couponId: coupon?.couponIds?.[0] ?? coupon?.id ?? null,
coupon
});
},
handleClose(this: WechatMiniprogram.Component.TrivialInstance) {
this.setData({ visible: false });
this.triggerEvent("select", { couponId: null });
},
closePopup(this: WechatMiniprogram.Component.TrivialInstance) {
this.setData({ visible: false });
},
}
})
@@ -0,0 +1,36 @@
<!--components/static/coupon-popup/coupon-popup.wxml-->
<popup visible="{{visible}}" bind:close="closePopup">
<view slot="header" class="popup-header">
选择优惠券
</view>
<view class="list-container">
<view wx:for="{{couponList}}" wx:key="name" class="large" data-index="{{index}}">
<view class="large-left">
<view class="amount">{{'¥'+item.discountPrice}}</view>
<view class="min-price">满{{item.usePrice}}元可用</view>
<view class="min-price">剩余{{item.count}}张</view>
</view>
<view class="large-center"></view>
<view class="large-right">
<view class="large-title">
<tag propStyle="background: rgba(0, 0, 0, 0.2);border:none;color:white;">
{{item.discountType===1?'代金券':'折扣券'}}
</tag>
<view class="name">{{item.name}}</view>
</view>
<view class="desc">优惠开始时间:{{item.validStartTime}}</view>
<view class="desc">优惠结束时间:{{item.validEndTime}}</view>
<div class="large-bottom">
<div class="id">ID{{item.couponIds}}</div>
<button class="button large-button" data-index="{{index}}" bind:tap="handleSelect">选择</button>
</div>
</view>
</view>
</view>
<view class="no-use" bind:tap="handleSelect">
不使用优惠券
</view>
<view style="height:{{hasTabBar? safeBottom/2 + 90 : safeBottom}}rpx;"></view>
</popup>
@@ -0,0 +1,7 @@
{
"component": true,
"usingComponents": {
"tag":"/components/tag/tag",
"empty": "/components/empty/empty"
}
}
@@ -0,0 +1,162 @@
/* components/static/coupon-record/coupon-record.wxss */
.list-container {
position: relative;
box-shadow: rgba(0, 0, 0, 0.1) 0px 2px 8px;
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 24rpx;
background: rgba(0,0,0,0);
.default {
background: linear-gradient(135deg, rgb(255, 107, 107) 0%, rgb(255, 142, 83) 100%);
padding: 32rpx;
border-radius: 24rpx;
color: #fff;
display: flex;
flex-direction: column;
gap: 20rpx;
min-height: 240rpx;
.default-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
.amount {
font-size: 48rpx;
font-weight: 700;
line-height: 1;
}
.min-price {
font-size: 24rpx;
opacity: 0.9;
}
.name {
font-size: 28rpx;
font-weight: 600;
line-height: 1.3;
height: 76rpx;
overflow: hidden;
}
.desc {
font-size: 24rpx;
opacity: 0.85;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.default-bottom {
display: flex;
justify-content: space-between;
}
.id {
opacity: 0;
user-select: none;
}
}
.large {
display: flex;
flex-direction: row;
justify-content: space-around;
gap: 32rpx;
color: #fff;
// height: 160rpx;
background: linear-gradient(135deg, rgb(255, 107, 107) 0%, rgb(255, 142, 83) 100%);
padding: 32rpx;
border-radius: 24rpx;
.large-left {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap:8rpx;
.amount {
font-size: 48rpx;
font-weight: 700;
line-height: 1;
}
.min-price {
font-size: 24rpx;
opacity: 0.9;
}
}
.large-center {
width: 1px;
background: white;
margin: 0rpx;
}
.large-right {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
gap: 8rpx;
width: 100%;
.large-title {
display: flex;
align-items: center;
.name {
font-weight: bold;
font-size: 30rpx;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.desc {
font-size: 24rpx;
opacity: 0.85;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.large-bottom {
display: flex;
align-items: center;
justify-content: space-between;
.id {
opacity: 0;
user-select: none;
}
.large-button {
margin-top:8rpx;
}
}
}
}
.button {
background: rgba(255, 255, 255, 0.9);
border: none;
color: #333;
font-weight: 600;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
min-width: 140rpx;
height: 48rpx;
border-radius: 24rpx;
line-height: 48rpx;
font-size: 28rpx;
margin: 0;
color:#FA6905;
}
}
@@ -0,0 +1,62 @@
import { request } from "../../../utils/request";
import { shopId } from '../../../env';
// components/static/coupon-record/coupon-record.ts
Component({
/**
* 组件的属性列表
*/
properties: {
},
/**
* 组件的初始数据
*/
data: {
couponList: []
},
pageLifetimes: {
show(this: WechatMiniprogram.Component.TrivialInstance) {
this.search();
}
},
/**
* 组件的方法列表
*/
methods: {
async search(this: WechatMiniprogram.Component.TrivialInstance) {
try {
const res = await request({
options: {
url: "/app/mini-app/user-coupon",
data: {
mbuId: wx.getStorageSync("mbuId"),
shopId: shopId
}
},
needLogin: true
})
if (res.data.length > 0) res.data.forEach((item: any) => {
item.discountPrice = (item.discountPrice / 100).toFixed(2);
item.usePrice = item.usePrice / 100;
item.validStartTime = item.validStartTime.replace("T", " ");
item.validEndTime = item.validEndTime.replace("T", " ");
}); //金额单位为分
this.setData({ couponList: res.data })
} catch (e) {
console.log(e, 'error');
wx.showToast({
title: "获取优惠券列表失败",
icon: "none",
duration: 2000
});
}
},
handleClick(this: WechatMiniprogram.Component.TrivialInstance) {
wx.switchTab({
url: "/pages/tab-bar/medicine-box/index"
})
}
}
})
@@ -0,0 +1,44 @@
<!--components/static/coupon-record/coupon-record.wxml-->
<view class="list-container">
<!-- <view wx:for="{{couponList}}" wx:key="name" class="default">
<tag propStyle="background: rgba(0, 0, 0, 0.2);border:none;color:white;">
{{item.discountType===1?'代金券':'折扣券'}}
</tag>
<view class="default-content">
<view class="amount">{{'¥'+item.discountPrice}}</view>
<view class="min-price">满{{item.usePrice}}元可用</view>
<view class="min-price">拥有{{item.count}}张</view>
<view class="name">{{item.name}}</view>
<view class="min-price">优惠开始时间:{{item.validStartTime}}</view>
<view class="min-price">优惠结束时间:{{item.validEndTime}}</view>
</view>
<view class="default-bottom">
<view class="id">ID{{item.id}}</view>
<button class="button" data-id="{{item.id}}" bind:tap="handleClick">去使用</button>
</view>
</view> -->
<view wx:for="{{couponList}}" wx:key="name" class="large">
<view class="large-left">
<view class="amount">{{'¥'+item.discountPrice}}</view>
<view class="min-price">满{{item.usePrice}}元可用</view>
<view class="min-price">剩余{{item.count}}张</view>
</view>
<view class="large-center"></view>
<view class="large-right">
<view class="large-title">
<tag propStyle="background: rgba(0, 0, 0, 0.2);border:none;color:white;">
{{item.discountType===1?'代金券':'折扣券'}}
</tag>
<view class="name">{{item.name}}</view>
</view>
<view class="desc">优惠开始时间:{{item.validStartTime}}</view>
<view class="desc">优惠结束时间:{{item.validEndTime}}</view>
<div class="large-bottom">
<div class="id">ID{{item.id}}</div>
<button class="button large-button" data-id="{{item.id}}" bind:tap="handleClick">去使用</button>
</div>
</view>
</view>
<empty wx:if="{{!couponList||couponList.length===0}}"></empty>
</view>
@@ -0,0 +1,9 @@
{
"component": true,
"usingComponents": {
"popup": "/components/popup/popup",
"van-stepper": "@vant/weapp/stepper/index",
"sku-item":"/components/static/sku-item/sku-item",
"coupon-popup":"../coupon-popup/coupon-popup"
}
}
@@ -0,0 +1,115 @@
/* components/static/good-detail-popup/good-detail-popup.wxss */
@import "variables";
.popup-header {
.location {
min-height: 60rpx;
width: 100%;
color: $primary-color;
display: flex;
flex-direction: row;
// align-items:end;
border-bottom: 1rpx solid rgba(0, 0, 0, 0.1);
.icon {
min-width: 40rpx;
line-height: 60rpx;
width: 40rpx;
height: 40rpx;
}
.address{
padding-bottom: $primary-gap;
}
}
.info {
display: flex;
margin-top: $primary-gap;
.left {
border-radius: $primary-border-radius;
width: 200rpx;
height: 200rpx;
overflow: hidden;
margin-right: $primary-gap;
image {
width: 100%;
height: 100%;
}
}
.right {
display: flex;
flex-direction: column;
justify-content: space-between;
;
.price {
color: $primary-color;
font-size: $x-large-font-size;
font-weight: 800;
}
.count {
width: 400rpx;
}
.stock {
font-size: $large-font-size;
color: $gray-color;
}
}
}
}
.sku-container {
display: flex;
flex-direction: column;
// max-height: 500rpx;
// margin-bottom: 54rpx;
.skuTitle {
font-size: $large-font-size;
font-weight: 600;
margin: $primary-gap 0rpx;
}
.scroll {
max-height: 500rpx;
}
}
.footer {
display: flex;
// gap: $primary-gap;
justify-content: center;
position: fixed;
bottom: 0;
width: 702rpx;
.submit {
flex: 1;
height: 80rpx;
background: $primary-color;
color: white;
font-size: $large-font-size;
font-weight: 600;
line-height: 80rpx;
text-align: center;
border-radius: 0rpx $primary-border-radius $primary-border-radius 0rpx;
}
.submit1 {
flex: 1;
height: 80rpx;
background: $primary-color;
color: white;
font-size: $large-font-size;
font-weight: 600;
line-height: 80rpx;
text-align: center;
border-radius: 10rpx;
}
.add-cart {
background-color: #FEB30C;
border-radius: $primary-border-radius 0rpx 0rpx $primary-border-radius ;
}
}
@@ -0,0 +1,302 @@
// components/static/good-detail-popup/good-detail-popup.ts
import { request } from "../../../utils/request";
import { getAddress } from "../../../utils/get-address";
import { appId, shopId } from "../../../env";
import { wxRequestSubscribe } from "../../../utils/wx-api";
// @ts-ignore
const systemInfo = wx.getWindowInfo();
const app = getApp<IAppOption>();
Component({
properties: {
mode: {
// one: 单规格, list: 多规格
type: String,
value: "one",
},
skuId: Number,
spuId: Number,
hasTabBar: Boolean,
showShopCart: {
type: Boolean,
value: false,
},
joinedGroupI9d: {
type: String,
value: null,
},
},
data: {
goodList: {},
showPopup: false,
swiperList: [],
count: 1,
checkItem: null,
skus: [],
addressId: null,
defualtAddress: "未知地址",
safeBottom: getApp().globalData.safeBottom,
showStock: app.globalData.showStock,
selectedCoupon: null //选择的优惠券
},
methods: {
async openPopup(this: WechatMiniprogram.Component.TrivialInstance) {
await this.handleGetInfo();
this.setData({ showPopup: true });
},
closePopup(this: WechatMiniprogram.Component.TrivialInstance) {
this.setData({ showPopup: false });
},
async handleGetInfo(this: WechatMiniprogram.Component.TrivialInstance) {
await getAddress(this);
let api = "";
let params = {};
if (this.data.mode === "one") {
api = "/app/shop-store/sku-one";
params = { id: Number(this.data.skuId) };
} else {
api = "/app/shop-store/sku-list";
params = { id: Number(this.data.spuId) };
}
try {
const res: any = await request({
options: { url: api, data: params },
loadingTitle: "获取商品信息中",
needLogin: false,
});
let swiperList: string[] = [];
let checkItem = null;
if (this.data.mode === "one") {
// 单规格
swiperList = res.sliderPicUrls?.filter(Boolean) || [res.picUrl];
checkItem = res;
} else {
// 多规格
swiperList = res.sliderPicUrls?.filter(Boolean);
if ((!swiperList || !swiperList.length) && res.skus) {
swiperList = res.skus.map((s: any) => s.picUrl ?? s.picUrl);
}
// 找到默认可售SKU
checkItem = res.skus.find((s: any) => s.stock > 0) || res.skus[0];
// 给goodList添加最低价
const min = res.skus.reduce((p: any, c: any) =>
c.price < p.price ? c : p
);
res.price = min.price;
}
this.setData({
goodList: res,
skus: this.data.mode === "one" ? [res] : res.skus,
swiperList,
checkItem,
skuId: checkItem.skuId,
});
} catch (err) {
console.log(err);
wx.showToast({ icon: "none", title: "商品信息获取失败" });
} finally {
wx.hideLoading();
}
},
/** SKU 切换 */
onSetSkuId(e: any) {
const that = this as any;
const checkItem = e.detail.checkItem;
if (that.data.checkItem?.skuId === checkItem.skuId) {
that.setData({ checkItem: null, skuId: null });
return;
}
if (checkItem.stock < 1) {
return wx.showToast({ icon: "none", title: "库存不足" });
}
that.setData({ checkItem, skuId: checkItem.skuId });
},
onCountChange(e: any) {
const that = this as any;
that.setData({ count: e.detail });
},
/** 下单 */
async handleSubmit() {
const that = this as any;
if (!app.redirectLogin()) return;
if (!that.data.checkItem) {
return wx.showToast({ title: "未选择商品!", icon: "none", duration: 2000 });
}
if (that.data.checkItem.price == 0) return wx.showToast({ title: "暂无报价", icon: "none", duration: 3000 });
if (that.data.count > that.data.checkItem.stock) {
if (!that.data.showStock) {
return wx.showToast({ title: "暂无报价", icon: "none", duration: 2000 });
}
return wx.showToast({ title: "库存不足!", icon: "none", duration: 2000 });
}
if (!that.data.addressId || !that.data.defualtAddress) {
wx.showToast({ title: "请先填写地址!", icon: "none", duration: 2000 });
wx.navigateTo({ url: "/pages/common/location/location" })
return
}
const value = {
addressId: that.data.addressId,
userId: wx.getStorageSync("userId"),
mbuId: wx.getStorageSync("mbuId"),
appId,
goods: [{ id: that.data.skuId, count: that.data.count }],
shopId,
type: 1
} as any;
if (that.data.joinedGroupId) value.roomId = that.data.joinedGroupId;
if (that.data.selectedCoupon) {
value.couponIds = that.data.selectedCoupon.couponIds;
}
let subscribeTmplIds = [] as any[];
try {
const subscribe = await wxRequestSubscribe(['3gO2ryTGiXvUWhMgjMH8il3CyBrYmPaO0AR_gwsx69M', 'ts3RBa4gtlEEAtPomEyr4l1Bt1C8PAjbD7-jvtpddkg']) as any;
subscribeTmplIds = Object.entries(subscribe)
.filter(([id, status]) => status === 'accept')
.map(([id]) => id);
if (subscribeTmplIds.length > 0) {
value.subscribeTmplIds = subscribeTmplIds;
}
} catch (e: any) {
console.log(e, '下单失败')
}
try {
wx.showLoading({ title: "下单中" });
const res: any = await request({
options: { url: "/app/app-pay/js-api", data: value },
});
wx.requestPayment({
...res.data,
package: res.data.packageVal,
success: () => {
wx.switchTab({ url: "/pages/tab-bar/user/user" });
wx.showToast({ title: "支付成功" });
},
fail() {
wx.showToast({ title: "支付取消", icon: "none" });
},
complete: () => wx.hideLoading(),
});
} catch (err) {
wx.hideLoading();
console.log(err, '下单失败')
wx.showToast({ title: "下单失败", icon: "none" });
}
},
// 选优惠券下单
async handleBeforeSubmit() {
const that = this as any;
if (!app.redirectLogin()) return;
if (!that.data.checkItem) {
return wx.showToast({ title: "未选择商品!", icon: "none", duration: 2000 });
}
if (that.data.checkItem.price == 0) return wx.showToast({ title: "暂无报价", icon: "none", duration: 3000 });
if (that.data.count > that.data.checkItem.stock) {
if (!that.data.showStock) {
return wx.showToast({ title: "暂无报价", icon: "none", duration: 2000 });
}
return wx.showToast({ title: "库存不足!", icon: "none", duration: 2000 });
}
if (!that.data.addressId || !that.data.defualtAddress) {
wx.showToast({ title: "请先填写地址!", icon: "none", duration: 2000 });
wx.navigateTo({ url: "/pages/common/location/location" })
return
}
const amount = that.data.checkItem.price * that.data.count;
// 打开优惠券弹窗
that.selectComponent("#couponPopup").handleSwitch(amount);
},
//选择优惠券后
onCouponSelect(e: any) {
const that = this as any;
const { coupon } = e.detail;
if (coupon) {
that.setData({ selectedCoupon: coupon });
} else {
that.setData({ selectedCoupon: null });
}
if (that.data.selectedCoupon !== null) {
wx.showToast({
title: "已使用优惠券",
duration: 2000
})
}
// 继续下单
this.handleSubmit();
},
/** 加购物车 */
handleAddCart() {
const that = this as any;
if (!app.redirectLogin()) return;
const item = that.data.checkItem;
if (!item) return wx.showToast({ title: "未选择商品", icon: "none", duration: 3000 });
if (item.price == 0) return wx.showToast({ title: "暂无报价", icon: "none", duration: 3000 });
if (that.data.count > item.stock) {
if (!that.data.showStock) {
return wx.showToast({ title: "暂无报价", icon: "none", duration: 2000 });
}
return wx.showToast({ title: "库存不足", icon: "none", duration: 3000 });
}
let all = wx.getStorageSync("shoppingCart") || [];
const mbuId = wx.getStorageSync("mbuId");
let cart = all.find((i: any) => i.mbuId == mbuId);
if (!cart) {
cart = { mbuId, data: [] };
all.push(cart);
}
const exist = cart.data.find((i: any) => i.skuId == item.skuId);
if (exist) {
exist.count += that.data.count;
if (exist.count > item.stock) {
return wx.showToast({ title: "超过库存限制", icon: "none" });
}
} else {
cart.data.push({ ...item, count: that.data.count });
}
wx.setStorageSync("shoppingCart", all);
wx.showToast({ title: "加入购物车成功" });
},
handleSelcetLocation() {
if (!app.redirectLogin()) return;
wx.navigateTo({
url: "/pages/common/location/location",
});
},
},
pageLifetimes: {
show() {
const that = this as any;
if (that.data.showPopup === true) getAddress(this);
},
},
lifetimes: {
attached: function () {
getAddress(this);
},
}
});
@@ -0,0 +1,55 @@
<!--components/static/good-detail-popup/good-detail-popup.wxml-->
<popup visible="{{showPopup}}" bind:close="closePopup">
<view slot="header" class="popup-header">
<view class="location">
<image src="/assets/img/location.svg" class="icon"></image>
<view class="address" bind:tap="handleSelcetLocation">{{defualtAddress}}</view>
</view>
<view class="info">
<view class="left">
<image src="{{checkItem ? checkItem.picUrl : skus[0].picUrl}}" mode="aspectFill" />
</view>
<view class="right">
<view wx:if="skuId" class="price">
{{ checkItem ? checkItem.price?'¥'+checkItem.price/100:'暂无报价' : skus[0].price?'¥'+skus[0].price/100:'暂无报价' }}
</view>
<!-- 如果不显示库存的话最多可以加到99999去,显示的话就最多可以加到库存量去 -->
<van-stepper integer max="{{showStock?checkItem ? checkItem.stock : skus[0].stock :99999}}" value="{{ count }}"
bind:change="onCountChange" />
<view class="stock" wx:if="{{showStock}}">库存 {{ checkItem ? checkItem.stock : skus[0].stock }}
{{checkItem?checkItem.unit:goodList[0].stock}}
</view>
</view>
</view>
</view>
<view class="sku-container" style="padding-bottom: {{hasTabBar?safeBottom+ 80 + 120 + 24 :safeBottom + 80+24}}rpx;">
<view class="skuTitle">商品分类 ({{skus.length}})</view>
<scroll-view scroll-y class="scroll">
<view wx:for="{{skus}}" wx:key="skuId">
<sku-item active="{{(skuId && skuId==item.skuId)?true:false}}" disable="{{item.stock===0?true:false}}"
bind:selectsku="onSetSkuId" item="{{item}}" />
</view>
</scroll-view>
</view>
<view class="footer">
<view bind:tap="handleAddCart" class="submit add-cart" wx:if="{{show}}">
加入购物车
</view>
<view class="footer">
<view bind:tap="handleAddCart" wx:if="{{showShopCart}}" class="submit add-cart">
加入购物车
</view>
<!-- bind:tap="handleSubmit" -->
<view bind:tap="handleBeforeSubmit" class="submit"
style="margin-bottom:{{hasTabBar? safeBottom/2 + 140 : safeBottom}}rpx;border-top-left-radius: {{showShopCart?0:24}}rpx;border-Bottom-left-radius: {{showShopCart?0:24}}rpx;">
{{ skuId ? ('立即支付 ¥' + (checkItem.price * count / 100)) : '选择商品' }}
</view>
</view>
</view>
</popup>
<coupon-popup
id="couponPopup"
bind:select="onCouponSelect"
/>
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
@@ -0,0 +1,67 @@
@import "variables";
.good-container {
// 宽:(750rpx - 左右边距 24*2rpx - 中间边距 24rpx/ 2
width: 339rpx;
//高 为宽的1.5倍 3比2
height: 509rpx;
overflow: hidden;
border-radius: $primary-border-radius;
background: white;
.image {
width: 100%;
height: 339rpx;
border-radius: $primary-border-radius;
}
.content {
padding: 0rpx 12rpx;
display: flex;
flex-direction: column;
.title {
overflow: hidden;
white-space: nowrap;
display: block;
width: 100%;
line-height: 55rpx;
height: 55rpx;
font-size: $large-font-size;
font-weight: 600;
}
.desc {
overflow: hidden;
white-space: nowrap;
display: block;
text-overflow: ellipsis;
width: 100%;
color: $primary-color;
line-height: 45rpx;
height: 45rpx;
}
.count {
display: flex;
justify-content: space-between;
line-height: 45rpx;
height: 45rpx;
color: $primary-color;
.price {
.money {
font-size: $large-font-size;
}
}
.quantity {
color: black;
margin-right: $primary-gap / 2;
// font-size: $small-font-size;
}
}
}
}
@@ -0,0 +1,35 @@
const app = getApp<IAppOption>()
Component({
/**
* 组件的属性列表
*/
properties: {
item: {
type: Object,
value: {}
}
},
/**
* 组件的初始数据
*/
data: {
},
/**
* 组件的方法列表
*/
methods: {
handleClick(e: any) {
wx.navigateTo({
url: `/pages/common/good-detail/good-detail?spuId=${e.currentTarget.dataset.id}`
})
}
},
lifetimes: {
created: () => {
}
}
})
@@ -0,0 +1,12 @@
<!--components/good-item/good-item.wxml-->
<view class="good-container" bindtap="handleClick" data-id="{{item.spuId}}">
<image class="image" src="{{item.picUrl}}" mode="aspectFill"></image>
<view class="content">
<view class="title">{{item.name}}</view>
<view class="desc">{{item.introduction}}</view>
<view class="count">
<view class="price"><span class="money">{{item.price?'¥'+item.price/100:'暂无报价'}}</span></view>
<view class="quantity">已售 {{item.salesCount}} +</view>
</view>
</view>
</view>
@@ -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

@@ -0,0 +1,10 @@
export type locationItemType = {
id: number,
mbuId: number,
address: string,
areaId: number,
defaultStatus: boolean | number,
location: string[],
name: string,
mobile: string
}
@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"van-switch": "@vant/weapp/switch/index"
}
}
@@ -0,0 +1,93 @@
/* components/location-item/location-item.wxss */
@import "variables";
.item {
display: flex;
border-radius: $primary-border-radius;
justify-content: space-between;
align-items: center;
background: white;
margin-bottom: $primary-border-radius;
min-height: 180rpx;
padding: $primary-gap;
.info {
display: flex;
flex-direction: column;
width: 100%;
.location {
height: 60rpx;
line-height: 60rpx;
color: $gray-color;
display: flex;
justify-content: space-between;
// align-items: center;
.delete {
width: $primary-gap * 1.3;
height: $primary-gap * 1.3;
image {
width: 100%;
height: 100%;
}
}
}
.address {
// height: 70rpx;
line-height: 70rpx;
font-size: $large-font-size;
font-weight: 600;
display: flex;
justify-content: space-between;
// align-items: center;
.address-info {
max-width: 600rpx;
}
.edit {
width: $primary-gap * 1.5;
height: $primary-gap * 1.5;
line-height: 85rpx;
}
}
.footer {
display: flex;
justify-content: space-between;
.user {
height: 60rpx;
display: flex;
flex-direction: row;
line-height: 60rpx;
.phone {
margin-left: $primary-gap;
}
}
.default-address {
display: flex;
align-items: center;
gap: $primary-gap;
.default-item {
line-height: 60rpx;
color: $primary-color;
}
.switch {
margin-top: 6rpx;
}
}
}
}
}
@@ -0,0 +1,160 @@
// @ts-nocheck
import { request } from '../../../utils/request'
import { locationItemType } from './location-item'
Component({
properties: {
item: {} as locationItemType,
selectLocation: {
type: Boolean,
value: true
}
},
data: {
locationStr: ''
},
observers: {
'item.location': function (location: string[]) {
if (location && Array.isArray(location)) {
this.setData({
locationStr: location.join(' ')
})
}
},
},
methods: {
handleEdit() {
console.log("this.data.item", this.data.item)
this.triggerEvent('edit', { item: this.data.item })
},
handleDelete() {
wx.showModal({
title: '地址设置',
content: '要将此收货地址删除吗?',
success: async (res) => {
if (res.confirm) {
wx.showLoading({ title: "加载中" })
try {
await request({
options: {
url: "/app/member-address/remove",
data: {
// mbuId: Number(wx.getStorageSync("mbuId")),
id: this.data.item.id
}
}
}).then(() => {
wx.showToast({ title: "删除成功" })
if (this.data.item.defaultStatus) {
wx.removeStorageSync("addressId");
wx.removeStorageSync("defualtAddress");
}
this.triggerEvent("refresh")
})
} catch {
}
}
}
})
},
handleSelect() {
// console.log(this.data.item)
if (this.data.selectLocation) {
wx.showModal({
title: '地址设置',
content: '要选择此收货地址吗?',
success: (res) => {
if (res.confirm) {
this.triggerEvent("select", {
id: this.data.item.id,
item: this.data.item
});
}
},
// success: async (res) => {
// if (res.confirm) {
// const value: any = {
// location: this.data.item.location,
// address: this.data.item.address,
// mobile: this.data.item.mobile,
// name: this.data.item.name,
// id: this.data.item.id,
// defaultStatus: 1,
// mbuId: wx.getStorageSync('mbuId')
// }
// try {
// await request<any[]>({
// options: {
// url: "/app/member-address/editor",
// data: value
// }, loadingTitle: "保存中"
// })
// .then(() => {
// // wx.showToast({ title: "设置地址成功!", duration: 1000 })
// wx.setStorage({ key: "addressId", data: this.data.item.id })
// if (this.data.item.location) wx.setStorage({ key: "defualtAddress", data: `${this.data.item.location.join("")}${this.data.item.address}` })
// this.triggerEvent("refresh")
// }).then(() => {
// wx.navigateBack()
// })
// } catch (e) {
// console.log(e, '出现错误')
// }
// } else if (res.cancel) {
// console.log('用户点击了取消')
// }
// },
fail: (e) => {
console.log(e, '错误')
}
})
}
},
stopTap() { },//阻止点击穿透
handleSwitch(e) {
const checked = e.detail;
if (!checked) {
wx.showToast({ title: "不能取消默认地址,请切换到其他默认地址~", icon: "none" });
return;
}
wx.showModal({
title: '地址设置',
content: '要将此地址设置为默认收货地址吗?',
success: async (res) => {
if (res.confirm) {
const value = {
location: this.data.item.location,
address: this.data.item.address,
mobile: this.data.item.mobile,
name: this.data.item.name,
id: this.data.item.id,
defaultStatus: 1,
mbuId: wx.getStorageSync('mbuId')
};
await request({
options: {
url: "/app/member-address/editor",
data: value
},
loadingTitle: "保存中"
});
wx.setStorageSync("addressId", this.data.item.id);
if (this.data.item.location) {
wx.setStorageSync(
"defualtAddress",
`${this.data.item.location.join("")}${this.data.item.address}`
);
}
this.triggerEvent("refresh");
}
}
});
},
}
})
@@ -0,0 +1,31 @@
<!--components/location-item/location-item.wxml-->
<view class="item">
<view class="info" bind:tap="handleSelect">
<view class="location">
{{locationStr}}
<view class="delete" catch:tap="handleDelete">
<image src="../../../assets/img/delete.svg"></image>
</view>
</view>
<view class="address">
<view class="address-info">{{item.address}}</view>
<view class="edit" catch:tap="handleEdit">
<image style="width: 100%;height: 100%;" src="../../../assets/img/edit.svg" class="icon" />
</view>
</view>
<view class="footer">
<view class="user">
<view class="name">{{item.name}}</view>
<view class="phone">{{item.mobile}}</view>
</view>
<view class="default-address">
<view class="default-item" wx:if="{{item.defaultStatus}}">默认收货地址</view>
<view class="default-item" style="color: #AEADB2;" wx:if="{{!item.defaultStatus}}">选为默认收货地址</view>
<van-switch class="switch" checked="{{item.defaultStatus == true}}" size="30rpx" active-color="#FA6905"
bind:change="handleSwitch" catch:tap="stopTap" />
</view>
</view>
</view>
</view>
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1764038866262" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11776" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><path d="M0 0h1024v1024H0V0z" fill="#202425" opacity=".01" p-id="11777"></path><path d="M540.842667 82.670933l-18.0224-9.0112a17.066667 17.066667 0 0 0-19.524267 3.003734l-280.917333 271.530666c-159.982933 154.624-159.982933 405.265067 0 559.854934a409.6 409.6 0 0 0 134.997333 86.766933c15.530667 6.144 30.208-14.472533 24.951467-30.378667a136.533333 136.533333 0 1 1 259.345066 0c-5.256533 15.906133 9.4208 36.522667 24.951467 30.378667a409.6 409.6 0 0 0 134.997333-86.766933c148.821333-143.837867 159.197867-370.824533 31.061334-526.267734-10.0352-12.1856-29.661867-3.652267-30.1056 12.151467C800.938667 452.369067 787.285333 546.133333 716.8 546.133333c-33.655467 0-37.819733-36.898133-28.228267-86.459733 28.706133-148.036267-12.868267-309.5552-147.729066-377.002667z" fill="#FF7744" p-id="11778"></path><path d="M656.827733 581.973333l-130.3552-164.795733c-7.9872-10.103467-20.957867-10.103467-28.945066 0l-130.3552 164.795733c-79.9744 101.102933-79.9744 265.079467 0 366.1824 3.549867 4.437333 10.8544 0.443733 9.966933-5.154133a136.533333 136.533333 0 1 1 269.7216 0c-0.887467 5.597867 6.417067 9.6256 9.966933 5.154133 79.9744-101.102933 79.9744-265.079467 0-366.1824z" fill="#FFAA44" p-id="11779"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"good-detail-popup":"/components/static/good-detail-popup/good-detail-popup"
}
}
@@ -0,0 +1,116 @@
/* components/static/product-popup/product-popup.wxss */
@import "variables";
.product-popup-wrapper {
position: fixed;
right: -100%;
bottom: $primary-gap * 10;
transition: right .3s linear;
.count {
position: absolute;
background: linear-gradient(100deg, #FEB133 60%, #ffffff00 100%);
border-top-left-radius: $primary-border-radius;
border-bottom-left-radius: $primary-border-radius;
top: -20%;
width: 200rpx;
color: white;
font-weight: 800;
line-height: 50rpx;
height: 50rpx;
.count-icon{
position: absolute;
top: -20%;
width: 50rpx;
height: 50rpx;
z-index: 111;
}
.count-content{
margin-left: $primary-gap*2.2;
}
.count-content-x{
font-size: small;
}
}
.product-popup {
width: 200rpx;
border-radius: $primary-border-radius;
overflow: hidden;
position: relative;
.close {
width: 36rpx;
height: 36rpx;
line-height: 30rpx;
font-size: 30rpx;
color: rgba(255, 255, 255, 0.78);
border-radius: 50%;
text-align: center;
position: absolute;
background: rgba(0, 0, 0, 0.25);
right: $primary-gap/2;
top: $primary-gap/2;
}
.top {
width: 200rpx;
height: 200rpx;
background: white;
display: flex;
justify-content: center;
align-items: center;
image {
border-top-left-radius: $primary-border-radius;
border-top-right-radius: $primary-border-radius;
width: 96%;
height: 98%;
// border: 1px soild #fff;
}
}
.name {
display: -webkit-box;
line-clamp: 2;
box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
justify-content: center;
background: white;
color: #000;
align-items: center;
font-size: 22rpx;
height: 60rpx;
padding: 0 8rpx;
display: flex;
flex-direction: column;
}
.button {
display: flex;
background: #FF0E4C;
color: white;
height: 50rpx;
font-weight: 800;
align-items: center;
.price {
margin-left: 8rpx;
flex: 3;
font-size: 34rpx;
}
.button-right {
height: 100%;
border-top-left-radius: $primary-border-radius;
text-align: center;
line-height: 50rpx;
flex: 1;
background: linear-gradient(100deg, #ff7a3c 60%, #ff004d 100%);
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More