feat(mp): 积分商城 V2 重设计 — design-handoff 全流程
- 新增 4 个 UI 组件: PointsCard/ProductCard/CheckinCalendar/CheckinModal - 商城首页 V2: 积分卡 + 快捷操作 + 分类标签 + 商品网格 - 商品详情 V2: 大图 + 信息卡 + 库存/余额状态 + 底部操作栏 - TabBar 新增商城入口(5 Tab: 首页/健康/商城/助手/我的) - 设计原型 docs/design/mp-05-mall-v2.html + SPEC.md 交付包 - CLAUDE.md 安全规范加固: 新增 §3.7 安全规范 6 条 + Feature DoD 安全清单扩展
This commit is contained in:
@@ -76,6 +76,7 @@ export default defineAppConfig({
|
||||
list: [
|
||||
{ pagePath: 'pages/index/index', text: '首页', iconPath: 'assets/tabbar/home.png', selectedIconPath: 'assets/tabbar/home-active.png' },
|
||||
{ pagePath: 'pages/health/index', text: '健康', iconPath: 'assets/tabbar/health.png', selectedIconPath: 'assets/tabbar/health-active.png' },
|
||||
{ pagePath: 'pages/mall/index', text: '商城', iconPath: 'assets/tabbar/mall.png', selectedIconPath: 'assets/tabbar/mall-active.png' },
|
||||
{ pagePath: 'pages/messages/index', text: '助手', iconPath: 'assets/tabbar/message.png', selectedIconPath: 'assets/tabbar/message-active.png' },
|
||||
{ pagePath: 'pages/profile/index', text: '我的', iconPath: 'assets/tabbar/profile.png', selectedIconPath: 'assets/tabbar/profile-active.png' },
|
||||
],
|
||||
@@ -89,6 +90,10 @@ export default defineAppConfig({
|
||||
network: 'all',
|
||||
packages: ['pages/pkg-health'],
|
||||
},
|
||||
'pages/mall/index': {
|
||||
network: 'all',
|
||||
packages: ['pages/pkg-mall'],
|
||||
},
|
||||
'pages/consultation/index': {
|
||||
network: 'all',
|
||||
packages: ['pages/pkg-consultation'],
|
||||
|
||||
BIN
apps/miniprogram/src/assets/tabbar/mall-active.png
Normal file
BIN
apps/miniprogram/src/assets/tabbar/mall-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 272 B |
BIN
apps/miniprogram/src/assets/tabbar/mall.png
Normal file
BIN
apps/miniprogram/src/assets/tabbar/mall.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 272 B |
@@ -0,0 +1,91 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.checkin-calendar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
padding: $sp-section $sp-lg $sp-md;
|
||||
|
||||
&__day {
|
||||
width: 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $sp-xs;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
@include flex-center;
|
||||
|
||||
&--checked {
|
||||
background: $acc-l;
|
||||
}
|
||||
&--today {
|
||||
background: $pri;
|
||||
border: 2px solid $pri;
|
||||
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.3);
|
||||
}
|
||||
&--empty {
|
||||
background: $surface-alt;
|
||||
}
|
||||
}
|
||||
|
||||
&__check {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: $acc;
|
||||
|
||||
.checkin-calendar__dot--today & {
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
color: $tx3;
|
||||
font-weight: 400;
|
||||
|
||||
&--today {
|
||||
color: $tx;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__tip {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 10px 14px;
|
||||
background: $acc-l;
|
||||
border-radius: $r-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-xs;
|
||||
}
|
||||
|
||||
&__tip-text {
|
||||
font-size: 12px;
|
||||
color: $acc;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// 长者模式
|
||||
.elder-mode & {
|
||||
&__dot {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
&__label {
|
||||
font-size: 13px;
|
||||
}
|
||||
&__tip-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
53
apps/miniprogram/src/components/ui/CheckinCalendar/index.tsx
Normal file
53
apps/miniprogram/src/components/ui/CheckinCalendar/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import './index.scss';
|
||||
|
||||
interface CheckinCalendarProps {
|
||||
consecutiveDays: number;
|
||||
earnedPoints?: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const DAYS = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
|
||||
const CheckinCalendar: React.FC<CheckinCalendarProps> = ({
|
||||
consecutiveDays,
|
||||
}) => {
|
||||
const daysUntilReward = 7 - consecutiveDays;
|
||||
|
||||
return (
|
||||
<View className='checkin-calendar'>
|
||||
{DAYS.map((d, i) => {
|
||||
const isChecked = i < consecutiveDays;
|
||||
const isToday = i === consecutiveDays - 1;
|
||||
return (
|
||||
<View key={i} className='checkin-calendar__day'>
|
||||
<View
|
||||
className={`checkin-calendar__dot ${
|
||||
isChecked
|
||||
? isToday
|
||||
? 'checkin-calendar__dot--today'
|
||||
: 'checkin-calendar__dot--checked'
|
||||
: 'checkin-calendar__dot--empty'
|
||||
}`}
|
||||
>
|
||||
{isChecked && <Text className='checkin-calendar__check'>✓</Text>}
|
||||
</View>
|
||||
<Text className={`checkin-calendar__label ${isToday ? 'checkin-calendar__label--today' : ''}`}>
|
||||
周{d}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{daysUntilReward > 0 && (
|
||||
<View className='checkin-calendar__tip'>
|
||||
<Text className='checkin-calendar__tip-text'>
|
||||
再坚持 {daysUntilReward} 天,连续 7 天签到额外奖励 50 积分
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(CheckinCalendar);
|
||||
142
apps/miniprogram/src/components/ui/CheckinModal/index.scss
Normal file
142
apps/miniprogram/src/components/ui/CheckinModal/index.scss
Normal file
@@ -0,0 +1,142 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.checkin-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
&__card {
|
||||
position: relative;
|
||||
width: 320px;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__header {
|
||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
||||
padding: $sp-lg;
|
||||
padding-bottom: 20px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__header-deco {
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
right: -15px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 30px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--tk-font-body);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: $sp-xs;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__points-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: $sp-2xs;
|
||||
}
|
||||
|
||||
&__points-num {
|
||||
@include serif-number;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: $white;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__points-unit {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
&__streak {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
margin-top: $sp-xs;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__calendar {
|
||||
padding: $sp-section $sp-lg 0;
|
||||
}
|
||||
|
||||
&__calendar-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
margin-bottom: $sp-sm;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__calendar-body {
|
||||
position: relative;
|
||||
padding-bottom: 52px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding: 0 $sp-lg $sp-lg;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
width: 100%;
|
||||
padding: 12px 0;
|
||||
border-radius: $r-pill;
|
||||
background: $pri;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
box-shadow: $shadow-btn;
|
||||
@include touch-target;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
&__btn-text {
|
||||
font-size: 15px;
|
||||
color: $white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// 长者模式
|
||||
.elder-mode & {
|
||||
&__card {
|
||||
width: 340px;
|
||||
}
|
||||
&__points-num {
|
||||
font-size: 42px;
|
||||
}
|
||||
&__title {
|
||||
font-size: 17px;
|
||||
}
|
||||
&__btn {
|
||||
padding: 16px 0;
|
||||
}
|
||||
&__btn-text {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
}
|
||||
63
apps/miniprogram/src/components/ui/CheckinModal/index.tsx
Normal file
63
apps/miniprogram/src/components/ui/CheckinModal/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import CheckinCalendar from '../CheckinCalendar';
|
||||
import './index.scss';
|
||||
|
||||
interface CheckinModalProps {
|
||||
visible: boolean;
|
||||
consecutiveDays: number;
|
||||
earnedPoints: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CheckinModal: React.FC<CheckinModalProps> = ({
|
||||
visible,
|
||||
consecutiveDays,
|
||||
earnedPoints,
|
||||
onClose,
|
||||
}) => {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<View className='checkin-modal'>
|
||||
<View className='checkin-modal__overlay' onClick={onClose} />
|
||||
<View className='checkin-modal__card'>
|
||||
{/* 顶部装饰区 */}
|
||||
<View className='checkin-modal__header'>
|
||||
<View className='checkin-modal__header-deco' />
|
||||
<Text className='checkin-modal__title'>签到成功</Text>
|
||||
<View className='checkin-modal__points-row'>
|
||||
<Text className='checkin-modal__points-num'>+{earnedPoints}</Text>
|
||||
<Text className='checkin-modal__points-unit'>积分</Text>
|
||||
</View>
|
||||
{consecutiveDays > 0 && (
|
||||
<Text className='checkin-modal__streak'>
|
||||
已连续签到 {consecutiveDays} 天
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 7天日历 */}
|
||||
<View className='checkin-modal__calendar'>
|
||||
<Text className='checkin-modal__calendar-title'>本周签到</Text>
|
||||
<View className='checkin-modal__calendar-body'>
|
||||
<CheckinCalendar
|
||||
consecutiveDays={consecutiveDays}
|
||||
earnedPoints={earnedPoints}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<View className='checkin-modal__footer'>
|
||||
<View className='checkin-modal__btn' onClick={onClose}>
|
||||
<Text className='checkin-modal__btn-text'>我知道了</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(CheckinModal);
|
||||
124
apps/miniprogram/src/components/ui/PointsCard/index.scss
Normal file
124
apps/miniprogram/src/components/ui/PointsCard/index.scss
Normal file
@@ -0,0 +1,124 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.points-card {
|
||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
||||
border-radius: $r;
|
||||
padding: $sp-lg;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: $sp-section;
|
||||
box-shadow: 0 8px 24px rgba(196, 98, 58, 0.25);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&__deco {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
|
||||
&--1 {
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
&--2 {
|
||||
bottom: -30px;
|
||||
right: 40px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
&--3 {
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: $sp-xs;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
&__balance {
|
||||
@include serif-number;
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
color: $white;
|
||||
line-height: 1;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: $sp-2xs;
|
||||
}
|
||||
|
||||
&__streak {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
&__checkin {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-2xs;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
padding: 8px 16px;
|
||||
border-radius: $r-pill;
|
||||
cursor: pointer;
|
||||
|
||||
@include touch-target;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
|
||||
&--done {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__checkin-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__checkin--done &__checkin-text {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
// 长者模式
|
||||
.elder-mode & {
|
||||
&__balance {
|
||||
font-size: 52px;
|
||||
}
|
||||
&__label {
|
||||
font-size: 15px;
|
||||
}
|
||||
&__checkin {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
&__checkin-text {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
apps/miniprogram/src/components/ui/PointsCard/index.tsx
Normal file
50
apps/miniprogram/src/components/ui/PointsCard/index.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import './index.scss';
|
||||
|
||||
interface PointsCardProps {
|
||||
balance: number;
|
||||
consecutiveDays: number;
|
||||
checkedIn: boolean;
|
||||
checkinLoading?: boolean;
|
||||
onCheckin?: () => void;
|
||||
}
|
||||
|
||||
const PointsCard: React.FC<PointsCardProps> = ({
|
||||
balance,
|
||||
consecutiveDays,
|
||||
checkedIn,
|
||||
checkinLoading = false,
|
||||
onCheckin,
|
||||
}) => {
|
||||
return (
|
||||
<View className='points-card'>
|
||||
{/* 装饰圆 */}
|
||||
<View className='points-card__deco points-card__deco--1' />
|
||||
<View className='points-card__deco points-card__deco--2' />
|
||||
<View className='points-card__deco points-card__deco--3' />
|
||||
|
||||
<View className='points-card__body'>
|
||||
<View className='points-card__left'>
|
||||
<Text className='points-card__label'>我的积分</Text>
|
||||
<Text className='points-card__balance'>{balance.toLocaleString()}</Text>
|
||||
{consecutiveDays > 0 && (
|
||||
<Text className='points-card__streak'>
|
||||
已连续签到 {consecutiveDays} 天
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
className={`points-card__checkin ${checkedIn ? 'points-card__checkin--done' : ''}`}
|
||||
onClick={() => !checkedIn && !checkinLoading && onCheckin?.()}
|
||||
>
|
||||
<Text className='points-card__checkin-text'>
|
||||
{checkinLoading ? '...' : checkedIn ? '已签到' : '签到'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(PointsCard);
|
||||
116
apps/miniprogram/src/components/ui/ProductCard/index.scss
Normal file
116
apps/miniprogram/src/components/ui/ProductCard/index.scss
Normal file
@@ -0,0 +1,116 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.product-card {
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
overflow: hidden;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
@include touch-feedback;
|
||||
|
||||
&__thumb-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
gap: $sp-2xs;
|
||||
|
||||
&--physical { background: $pri-l; }
|
||||
&--service { background: $acc-l; }
|
||||
&--privilege { background: $wrn-l; }
|
||||
}
|
||||
|
||||
&__thumb-type {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__soldout {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
&__soldout-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__info {
|
||||
padding: 10px $sp-sm 14px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
height: 40px;
|
||||
margin-bottom: $sp-xs;
|
||||
}
|
||||
|
||||
&__bottom {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: $sp-2xs;
|
||||
}
|
||||
|
||||
&__points-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__points-num {
|
||||
@include serif-number;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&__points-unit {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $pri;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__low-stock {
|
||||
display: inline-block;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $wrn;
|
||||
font-weight: 500;
|
||||
background: $wrn-l;
|
||||
padding: 2px $sp-xs;
|
||||
border-radius: $r-sm;
|
||||
margin-top: $sp-2xs;
|
||||
}
|
||||
|
||||
// 长者模式
|
||||
.elder-mode & {
|
||||
&__name {
|
||||
font-size: 16px;
|
||||
height: 46px;
|
||||
}
|
||||
&__points-num {
|
||||
font-size: 22px;
|
||||
}
|
||||
&__points-unit {
|
||||
font-size: 13px;
|
||||
}
|
||||
&__low-stock {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
58
apps/miniprogram/src/components/ui/ProductCard/index.tsx
Normal file
58
apps/miniprogram/src/components/ui/ProductCard/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import type { PointsProduct } from '@/services/points';
|
||||
import './index.scss';
|
||||
|
||||
interface ProductCardProps {
|
||||
product: PointsProduct;
|
||||
onPress?: (product: PointsProduct) => void;
|
||||
}
|
||||
|
||||
const TYPE_BG_CLASS: Record<string, string> = {
|
||||
physical: 'product-card__thumb--physical',
|
||||
service: 'product-card__thumb--service',
|
||||
privilege: 'product-card__thumb--privilege',
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
physical: '实物',
|
||||
service: '服务券',
|
||||
privilege: '权益',
|
||||
};
|
||||
|
||||
const ProductCard: React.FC<ProductCardProps> = ({ product, onPress }) => {
|
||||
const isSoldOut = product.stock <= 0;
|
||||
const isLowStock = product.stock > 0 && product.stock <= 10;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='product-card'
|
||||
onClick={() => onPress?.(product)}
|
||||
>
|
||||
<View className='product-card__thumb-wrap'>
|
||||
<View className={`product-card__thumb ${TYPE_BG_CLASS[product.product_type] || ''}`}>
|
||||
<Text className='product-card__thumb-type'>{TYPE_LABELS[product.product_type] || '商品'}</Text>
|
||||
</View>
|
||||
{isSoldOut && (
|
||||
<View className='product-card__soldout'>
|
||||
<Text className='product-card__soldout-text'>已兑完</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className='product-card__info'>
|
||||
<Text className='product-card__name'>{product.name}</Text>
|
||||
<View className='product-card__bottom'>
|
||||
<View className='product-card__points-row'>
|
||||
<Text className='product-card__points-num'>{product.points_cost}</Text>
|
||||
<Text className='product-card__points-unit'>积分</Text>
|
||||
</View>
|
||||
</View>
|
||||
{isLowStock && (
|
||||
<Text className='product-card__low-stock'>仅剩{product.stock}件</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ProductCard);
|
||||
@@ -1,323 +1,89 @@
|
||||
@import '../../styles/variables.scss';
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
// 积分商城 — 对齐原型 docs/design/mp-05-mall.html
|
||||
// 积分商城 V2 — 对齐原型 docs/design/mp-05-mall-v2.html
|
||||
|
||||
.mall-page {
|
||||
padding-bottom: calc(var(--tk-tabbar-space) + env(safe-area-inset-bottom));
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
/* ─── 积分卡片(渐变背景) ─── */
|
||||
/* ─── 积分卡片区 ─── */
|
||||
.mall-header {
|
||||
background: linear-gradient(135deg, var(--tk-pri) 0%, var(--tk-pri-d) 100%);
|
||||
padding: var(--tk-gap-xl) var(--tk-page-padding) var(--tk-gap-xl);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// 装饰圆
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -30px;
|
||||
right: 40px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 40px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
padding: 0 var(--tk-page-padding);
|
||||
padding-top: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.points-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.points-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.points-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.checkin-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
padding: 6px 14px;
|
||||
border-radius: $r-pill;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
|
||||
&.checked {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.checkin-btn-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.checkin-btn.checked .checkin-btn-text {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.points-balance {
|
||||
@include serif-number;
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
color: $white;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
letter-spacing: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.points-streak {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
display: block;
|
||||
/* ─── 可滚动内容区 ─── */
|
||||
.mall-content {
|
||||
padding: 0 var(--tk-page-padding) var(--tk-gap-lg);
|
||||
}
|
||||
|
||||
/* ─── 快捷操作 ─── */
|
||||
.mall-actions {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: var(--tk-section-gap) var(--tk-page-padding);
|
||||
padding: var(--tk-section-gap) 0;
|
||||
}
|
||||
|
||||
.mall-action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: $sp-xs;
|
||||
@include touch-feedback;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
&-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 26px;
|
||||
@include flex-center;
|
||||
|
||||
.mall-action-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&--checkin {
|
||||
background: $acc;
|
||||
box-shadow: 0 4px 12px rgba(91, 122, 94, 0.3);
|
||||
}
|
||||
&--task {
|
||||
background: $pri;
|
||||
box-shadow: 0 4px 12px rgba(196, 98, 58, 0.3);
|
||||
}
|
||||
&--history {
|
||||
background: $wrn;
|
||||
box-shadow: 0 4px 12px rgba(196, 135, 58, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.mall-action-icon-text {
|
||||
font-size: 22px;
|
||||
color: $white;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mall-action-label {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── 分类标签(Pill) ─── */
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 0 var(--tk-page-padding) var(--tk-section-gap);
|
||||
overflow-x: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.type-tab {
|
||||
padding: 7px 18px;
|
||||
border-radius: $r-pill;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 400;
|
||||
background: $surface-alt;
|
||||
color: $tx2;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
&--checkin {
|
||||
background: $acc;
|
||||
box-shadow: 0 4px 12px rgba(91, 122, 94, 0.3);
|
||||
}
|
||||
&--history {
|
||||
background: $wrn;
|
||||
box-shadow: 0 4px 12px rgba(196, 135, 58, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--tk-pri);
|
||||
&-icon-text {
|
||||
font-size: 22px;
|
||||
color: $white;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--tk-shadow-tab);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&-label {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.type-tab-text {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
font-weight: inherit;
|
||||
/* ─── 分割线 ─── */
|
||||
.mall-divider {
|
||||
height: 1px;
|
||||
background: $bd;
|
||||
margin-bottom: $sp-md;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: inherit;
|
||||
}
|
||||
/* ─── 分类标签 ─── */
|
||||
.mall-tabs {
|
||||
margin-bottom: $sp-section;
|
||||
}
|
||||
|
||||
/* ─── 商品网格 ─── */
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--tk-gap-sm);
|
||||
padding: 0 var(--tk-page-padding) var(--tk-gap-lg);
|
||||
gap: $sp-sm;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
overflow: hidden;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
@include flex-center;
|
||||
position: relative;
|
||||
|
||||
&.type-physical { background: $pri-l; }
|
||||
&.type-service { background: $acc-l; }
|
||||
&.type-privilege { background: $wrn-l; }
|
||||
}
|
||||
|
||||
.product-image-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
line-height: 1;
|
||||
|
||||
.type-service & { color: $acc; }
|
||||
.type-privilege & { color: $wrn; }
|
||||
}
|
||||
|
||||
.product-info {
|
||||
padding: 10px var(--tk-gap-sm) 14px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
height: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.product-bottom {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.product-points {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.product-points-char {
|
||||
@include serif-number;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.product-points-value {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $pri;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.product-stock {
|
||||
font-size: var(--tk-font-micro);
|
||||
padding: 2px 6px;
|
||||
border-radius: $r-xs;
|
||||
|
||||
&.out {
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&.low {
|
||||
background: $dan-l;
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
.product-tag {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
font-size: var(--tk-font-micro);
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
color: $white;
|
||||
|
||||
&--hot {
|
||||
background: $dan;
|
||||
}
|
||||
&--new {
|
||||
background: $acc;
|
||||
// 长者模式
|
||||
.elder-mode .mall-page {
|
||||
.mall-action-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,25 +7,24 @@ import { listProducts } from '../../services/points';
|
||||
import type { PointsProduct } from '../../services/points';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { usePointsStore } from '../../stores/points';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import PointsCard from '@/components/ui/PointsCard';
|
||||
import ProductCard from '@/components/ui/ProductCard';
|
||||
import TabFilter from '@/components/ui/TabFilter';
|
||||
import CheckinModal from '@/components/ui/CheckinModal';
|
||||
import Loading from '../../components/Loading';
|
||||
import ErrorState from '../../components/ErrorState';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import './index.scss';
|
||||
|
||||
const PRODUCT_TYPE_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'physical', label: '实物' },
|
||||
{ key: 'service', label: '服务券' },
|
||||
{ key: 'privilege', label: '权益' },
|
||||
];
|
||||
const PRODUCT_TABS = ['全部', '实物', '服务券', '权益'];
|
||||
const TAB_TYPE_MAP = ['', 'physical', 'service', 'privilege'];
|
||||
|
||||
const TYPE_BG: Record<string, string> = {
|
||||
physical: 'type-physical',
|
||||
service: 'type-service',
|
||||
privilege: 'type-privilege',
|
||||
};
|
||||
const QUICK_ACTIONS = [
|
||||
{ icon: '✓', label: '签到打卡', cls: 'mall-action-icon--checkin' },
|
||||
{ icon: '◷', label: '兑换记录', cls: 'mall-action-icon--history' },
|
||||
] as const;
|
||||
|
||||
export default function Mall() {
|
||||
const currentPatient = useAuthStore((s) => s.currentPatient);
|
||||
@@ -35,35 +34,28 @@ export default function Mall() {
|
||||
const refreshPoints = usePointsStore((s) => s.refresh);
|
||||
const doCheckin = usePointsStore((s) => s.doCheckin);
|
||||
const [products, setProducts] = useState<PointsProduct[]>([]);
|
||||
const [productType, setProductType] = useState('');
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checkinLoading, setCheckinLoading] = useState(false);
|
||||
const [showCheckin, setShowCheckin] = useState(false);
|
||||
const [noProfile, setNoProfile] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const modeClass = useElderClass();
|
||||
|
||||
const fetchProducts = useCallback(
|
||||
async (pageNum: number, type: string, isRefresh = false) => {
|
||||
async (pageNum: number, typeIdx: number, isRefresh = false) => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const res = await listProducts({
|
||||
page: pageNum,
|
||||
page_size: 10,
|
||||
product_type: type || undefined,
|
||||
});
|
||||
const type = TAB_TYPE_MAP[typeIdx] || undefined;
|
||||
const res = await listProducts({ page: pageNum, page_size: 10, product_type: type });
|
||||
const list = res.data || [];
|
||||
if (isRefresh) {
|
||||
setProducts(list);
|
||||
} else {
|
||||
setProducts((prev) => [...prev, ...list]);
|
||||
}
|
||||
setProducts((prev) => (isRefresh ? list : [...prev, ...list]));
|
||||
setTotal(res.total);
|
||||
setPage(pageNum);
|
||||
} catch (err) {
|
||||
console.warn('[mall] 加载商品列表失败:', err);
|
||||
} catch {
|
||||
setError(true);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
@@ -74,12 +66,11 @@ export default function Mall() {
|
||||
);
|
||||
|
||||
const loadAll = useCallback(
|
||||
async (type?: string) => {
|
||||
const t = type !== undefined ? type : productType;
|
||||
async (tabIdx?: number) => {
|
||||
const t = tabIdx !== undefined ? tabIdx : activeTab;
|
||||
if (!currentPatient) {
|
||||
await loadPatients();
|
||||
const updated = useAuthStore.getState().currentPatient;
|
||||
if (!updated) {
|
||||
if (!useAuthStore.getState().currentPatient) {
|
||||
setNoProfile(true);
|
||||
return;
|
||||
}
|
||||
@@ -87,7 +78,7 @@ export default function Mall() {
|
||||
setNoProfile(false);
|
||||
await Promise.all([refreshPoints(), fetchProducts(1, t, true)]);
|
||||
},
|
||||
[currentPatient, loadPatients, refreshPoints, fetchProducts, productType],
|
||||
[currentPatient, loadPatients, refreshPoints, fetchProducts, activeTab],
|
||||
);
|
||||
|
||||
usePageData(
|
||||
@@ -100,7 +91,7 @@ export default function Mall() {
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && products.length < total) {
|
||||
fetchProducts(page + 1, productType);
|
||||
fetchProducts(page + 1, activeTab);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -110,7 +101,7 @@ export default function Mall() {
|
||||
try {
|
||||
const ok = await doCheckin();
|
||||
if (ok) {
|
||||
Taro.showToast({ title: '签到成功', icon: 'success', duration: 2000 });
|
||||
setShowCheckin(true);
|
||||
}
|
||||
} catch (err) {
|
||||
Taro.showToast({
|
||||
@@ -122,9 +113,9 @@ export default function Mall() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setProductType(key);
|
||||
fetchProducts(1, key, true);
|
||||
const handleTabChange = (idx: number) => {
|
||||
setActiveTab(idx);
|
||||
fetchProducts(1, idx, true);
|
||||
};
|
||||
|
||||
const handleProductClick = (item: PointsProduct) => {
|
||||
@@ -135,7 +126,17 @@ export default function Mall() {
|
||||
safeNavigateTo(`/pages/pkg-mall/product/index?product_id=${item.id}`);
|
||||
};
|
||||
|
||||
const handleAction = (label: string) => {
|
||||
if (label === '签到打卡') {
|
||||
handleCheckin();
|
||||
} else if (label === '兑换记录') {
|
||||
safeNavigateTo('/pages/pkg-mall/orders/index');
|
||||
}
|
||||
};
|
||||
|
||||
const balance = account?.balance ?? 0;
|
||||
const consecutiveDays = checkinStatus?.consecutive_days ?? 0;
|
||||
const checkedIn = checkinStatus?.checked_in_today ?? false;
|
||||
|
||||
if (noProfile) {
|
||||
return (
|
||||
@@ -153,101 +154,77 @@ export default function Mall() {
|
||||
|
||||
return (
|
||||
<PageShell padding="none" safeBottom={false} scroll={false} className={`mall-page ${modeClass}`}>
|
||||
{/* 积分余额卡片 */}
|
||||
{/* 积分卡片 */}
|
||||
<View className='mall-header'>
|
||||
<View className='points-card'>
|
||||
<View className='points-top'>
|
||||
<Text className='points-label'>我的积分</Text>
|
||||
<PointsCard
|
||||
balance={balance}
|
||||
consecutiveDays={consecutiveDays}
|
||||
checkedIn={checkedIn}
|
||||
checkinLoading={checkinLoading}
|
||||
onCheckin={handleCheckin}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 可滚动内容区 */}
|
||||
<View className='mall-content'>
|
||||
{/* 快捷操作 */}
|
||||
<View className='mall-actions'>
|
||||
{QUICK_ACTIONS.map((action) => (
|
||||
<View
|
||||
className={`checkin-btn ${checkinStatus?.checked_in_today ? 'checked' : ''}`}
|
||||
onClick={handleCheckin}
|
||||
key={action.label}
|
||||
className='mall-action'
|
||||
onClick={() => handleAction(action.label)}
|
||||
>
|
||||
<Text className='checkin-btn-text'>
|
||||
{checkinLoading ? '...' : checkinStatus?.checked_in_today ? '已签到' : '签到'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='points-balance'>{balance.toLocaleString()}</Text>
|
||||
{checkinStatus && checkinStatus.consecutive_days > 0 && (
|
||||
<Text className='points-streak'>
|
||||
已连续签到 {checkinStatus.consecutive_days} 天
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<View className='mall-actions'>
|
||||
<View className='mall-action' onClick={handleCheckin}>
|
||||
<View className='mall-action-icon mall-action-icon--checkin'>
|
||||
<Text className='mall-action-icon-text'>✓</Text>
|
||||
</View>
|
||||
<Text className='mall-action-label'>签到打卡</Text>
|
||||
</View>
|
||||
{/* TODO: 积分任务功能待实现后恢复 */}
|
||||
<View className='mall-action' onClick={() => safeNavigateTo('/pages/pkg-mall/orders/index')}>
|
||||
<View className='mall-action-icon mall-action-icon--history'>
|
||||
<Text className='mall-action-icon-text'>◷</Text>
|
||||
</View>
|
||||
<Text className='mall-action-label'>兑换记录</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 商品类型切换 */}
|
||||
<View className='type-tabs'>
|
||||
{PRODUCT_TYPE_TABS.map((tab) => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`type-tab ${productType === tab.key ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.key)}
|
||||
>
|
||||
<Text className={`type-tab-text ${productType === tab.key ? 'active' : ''}`}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 商品列表 */}
|
||||
{error ? (
|
||||
<ErrorState onRetry={() => loadAll()} />
|
||||
) : products.length === 0 && !loading ? (
|
||||
<EmptyState icon='礼' text='暂无商品' hint='更多好物即将上架' />
|
||||
) : (
|
||||
<View className='product-grid'>
|
||||
{products.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className='product-card'
|
||||
onClick={() => handleProductClick(item)}
|
||||
>
|
||||
<View className={`product-image ${TYPE_BG[item.product_type] || ''}`}>
|
||||
<Text className='product-image-char'>
|
||||
{item.product_type === 'physical' ? '物' : item.product_type === 'service' ? '券' : '权'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='product-info'>
|
||||
<Text className='product-name'>{item.name}</Text>
|
||||
<View className='product-bottom'>
|
||||
<View className='product-points'>
|
||||
<Text className='product-points-char'>{item.points_cost}</Text>
|
||||
<Text className='product-points-value'>积分</Text>
|
||||
</View>
|
||||
{item.stock <= 0 ? (
|
||||
<Text className='product-stock out'>已兑完</Text>
|
||||
) : item.stock <= 10 ? (
|
||||
<Text className='product-stock low'>仅剩{item.stock}件</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View className={`mall-action-icon ${action.cls}`}>
|
||||
<Text className='mall-action-icon-text'>{action.icon}</Text>
|
||||
</View>
|
||||
<Text className='mall-action-label'>{action.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
{loading && <Loading />}
|
||||
{!loading && products.length >= total && total > 0 && (
|
||||
<Loading text='没有更多了' />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 分割线 */}
|
||||
<View className='mall-divider' />
|
||||
|
||||
{/* 分类标签 */}
|
||||
<View className='mall-tabs'>
|
||||
<TabFilter
|
||||
tabs={PRODUCT_TABS}
|
||||
activeIndex={activeTab}
|
||||
onChange={handleTabChange}
|
||||
variant='pill'
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 商品网格 */}
|
||||
{error ? (
|
||||
<ErrorState onRetry={() => loadAll()} />
|
||||
) : products.length === 0 && !loading ? (
|
||||
<EmptyState icon='礼' text='暂无商品' hint='更多好物即将上架' />
|
||||
) : (
|
||||
<View className='product-grid'>
|
||||
{products.map((item) => (
|
||||
<ProductCard
|
||||
key={item.id}
|
||||
product={item}
|
||||
onPress={handleProductClick}
|
||||
/>
|
||||
))}
|
||||
{loading && <Loading />}
|
||||
{!loading && products.length >= total && total > 0 && (
|
||||
<Loading text='没有更多了' />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 签到弹窗 */}
|
||||
<CheckinModal
|
||||
visible={showCheckin}
|
||||
consecutiveDays={consecutiveDays + (checkedIn ? 0 : 0)}
|
||||
earnedPoints={10}
|
||||
onClose={() => setShowCheckin(false)}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,226 +1,245 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
// 商品详情 — 对齐原型 docs/design/mp-10-mall-pkg.html → 屏幕1
|
||||
// 商品详情 V2 — 对齐原型 docs/design/mp-05-mall-v2.html
|
||||
|
||||
.product-detail-page {
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.product-detail-scroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// 加载/空状态
|
||||
.product-detail-loading,
|
||||
.product-detail-empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: var(--tk-gap-2xl) 0;
|
||||
}
|
||||
|
||||
.product-detail-loading-text,
|
||||
.product-detail-empty-text {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// 商品图区域
|
||||
.product-detail-image {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
&--physical { background: $pri-l; }
|
||||
&--service { background: $acc-l; }
|
||||
&--privilege { background: $wrn-l; }
|
||||
}
|
||||
|
||||
.product-detail-image-char {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
line-height: 1;
|
||||
opacity: 0.3;
|
||||
|
||||
.product-detail-image--service & { color: $acc; }
|
||||
.product-detail-image--privilege & { color: $wrn; }
|
||||
}
|
||||
|
||||
.product-detail-image-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// 商品信息卡片
|
||||
.product-detail-info {
|
||||
background: $card;
|
||||
border-radius: 20px 20px 0 0;
|
||||
margin-top: -16px;
|
||||
position: relative;
|
||||
padding: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
.product-detail-name {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.product-detail-price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--tk-gap-sm);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
.product-detail-points {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.product-detail-type-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: var(--tk-font-micro);
|
||||
font-weight: 600;
|
||||
color: $acc;
|
||||
background: $acc-l;
|
||||
}
|
||||
|
||||
.product-detail-desc {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
line-height: 1.8;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
// 规格信息
|
||||
.product-detail-specs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px var(--tk-gap-md);
|
||||
.product-detail {
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
.product-detail-spec-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--tk-font-cap);
|
||||
}
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.product-detail-spec-label {
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.product-detail-spec-value {
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 温馨提示
|
||||
.product-detail-notice {
|
||||
padding: var(--tk-gap-sm);
|
||||
background: $wrn-l;
|
||||
border-radius: $r-sm;
|
||||
border-left: 3px solid $wrn;
|
||||
}
|
||||
|
||||
.product-detail-notice-title {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $wrn;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.product-detail-notice-text {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 底部操作栏
|
||||
.product-detail-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: $card;
|
||||
border-top: 1px solid $bd-l;
|
||||
padding: var(--tk-gap-sm) var(--tk-page-padding);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-sm);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.product-detail-fav {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.product-detail-fav-icon {
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.product-detail-exchange-btn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
background: var(--tk-pri);
|
||||
border-radius: $r;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--tk-shadow-btn);
|
||||
|
||||
&.disabled {
|
||||
background: $bd;
|
||||
box-shadow: none;
|
||||
&__loading, &__empty {
|
||||
@include flex-center;
|
||||
padding: $sp-2xl 0;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&:active:not(.disabled) {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
&__scroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
// 商品大图
|
||||
&__hero {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
gap: $sp-2xs;
|
||||
|
||||
&--physical { background: $pri-l; }
|
||||
&--service { background: $acc-l; }
|
||||
&--privilege { background: $wrn-l; }
|
||||
}
|
||||
|
||||
&__hero-type {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 商品信息卡
|
||||
&__info-card {
|
||||
background: $card;
|
||||
margin: 0 $sp-section;
|
||||
margin-top: -$sp-md;
|
||||
border-radius: $r;
|
||||
padding: $sp-section;
|
||||
margin-bottom: $sp-md;
|
||||
box-shadow: $shadow-sm;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-xs;
|
||||
margin-bottom: $sp-xs;
|
||||
}
|
||||
|
||||
&__type-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: $acc;
|
||||
background: $acc-l;
|
||||
padding: 2px $sp-xs;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
line-height: 1.4;
|
||||
margin-bottom: $sp-sm;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: $sp-2xs;
|
||||
margin-bottom: $sp-sm;
|
||||
}
|
||||
|
||||
&__points-num {
|
||||
@include serif-number;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__points-unit {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $pri;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 库存/余额卡
|
||||
&__status-card {
|
||||
background: $card;
|
||||
margin: 0 $sp-section;
|
||||
border-radius: $r;
|
||||
padding: $sp-md $sp-section;
|
||||
margin-bottom: $sp-md;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
&__status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
& + & {
|
||||
margin-top: $sp-sm;
|
||||
}
|
||||
}
|
||||
|
||||
&__status-label {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&__status-value {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $acc;
|
||||
|
||||
&--warn { color: $wrn; }
|
||||
&--danger { color: $dan; }
|
||||
&--ok { color: $acc; }
|
||||
}
|
||||
|
||||
// 温馨提示
|
||||
&__notice {
|
||||
margin: 0 $sp-section;
|
||||
padding: $sp-sm $sp-md;
|
||||
background: $wrn-l;
|
||||
border-radius: $r-sm;
|
||||
margin-bottom: $sp-section;
|
||||
}
|
||||
|
||||
&__notice-text {
|
||||
font-size: 12px;
|
||||
color: $wrn;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// 底部操作栏
|
||||
&__footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: $card;
|
||||
border-top: 1px solid $bd;
|
||||
padding: $sp-sm $sp-section;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-sm;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&__footer-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__footer-hint {
|
||||
font-size: 12px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__footer-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__footer-num {
|
||||
@include serif-number;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&__footer-unit {
|
||||
font-size: 12px;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&__exchange-btn {
|
||||
padding: 14px 32px;
|
||||
border-radius: $r-pill;
|
||||
background: $pri;
|
||||
color: $white;
|
||||
box-shadow: $shadow-btn;
|
||||
@include touch-target;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
background: $bd;
|
||||
color: $tx3;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__exchange-text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
// 长者模式
|
||||
.elder-mode & {
|
||||
&__name {
|
||||
font-size: 24px;
|
||||
}
|
||||
&__points-num {
|
||||
font-size: 34px;
|
||||
}
|
||||
&__footer-num {
|
||||
font-size: 28px;
|
||||
}
|
||||
&__exchange-btn {
|
||||
padding: 16px 36px;
|
||||
}
|
||||
&__exchange-text {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.product-detail-exchange-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body);
|
||||
color: $white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -4,26 +4,28 @@ import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getProduct } from '../../../services/points';
|
||||
import type { PointsProduct } from '../../../services/points';
|
||||
import { usePointsStore } from '../../../stores/points';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_CHAR: Record<string, string> = {
|
||||
physical: '物',
|
||||
service: '券',
|
||||
privilege: '权',
|
||||
const TYPE_BG: Record<string, string> = {
|
||||
physical: 'product-detail__hero--physical',
|
||||
service: 'product-detail__hero--service',
|
||||
privilege: 'product-detail__hero--privilege',
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
physical: '实物商品',
|
||||
service: '服务体验',
|
||||
privilege: '权益卡',
|
||||
physical: '实物',
|
||||
service: '服务券',
|
||||
privilege: '权益',
|
||||
};
|
||||
|
||||
export default function ProductDetail() {
|
||||
const modeClass = useElderClass();
|
||||
const router = useRouter();
|
||||
const productId = router.params.product_id || '';
|
||||
const account = usePointsStore((s) => s.account);
|
||||
const [product, setProduct] = useState<PointsProduct | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -33,15 +35,13 @@ export default function ProductDetail() {
|
||||
try {
|
||||
const data = await getProduct(productId);
|
||||
setProduct(data);
|
||||
} catch (err) {
|
||||
console.warn('[product] 单商品接口失败,降级列表查找:', err);
|
||||
} catch {
|
||||
try {
|
||||
const { listProducts } = await import('../../../services/points');
|
||||
const res = await listProducts({ page: 1, page_size: 100 });
|
||||
const found = res.data.find((p) => p.id === productId);
|
||||
if (found) setProduct(found);
|
||||
} catch (fallbackErr) {
|
||||
console.warn('[product] 降级列表查找也失败:', fallbackErr);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
}
|
||||
} finally {
|
||||
@@ -54,8 +54,8 @@ export default function ProductDetail() {
|
||||
if (loading) {
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<View className='product-detail-loading'>
|
||||
<Text className='product-detail-loading-text'>加载中...</Text>
|
||||
<View className='product-detail__loading'>
|
||||
<Text>加载中...</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
@@ -64,95 +64,100 @@ export default function ProductDetail() {
|
||||
if (!product) {
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<View className='product-detail-empty'>
|
||||
<Text className='product-detail-empty-text'>商品不存在</Text>
|
||||
<View className='product-detail__empty'>
|
||||
<Text>商品不存在</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const productType = product.product_type || 'physical';
|
||||
const typeChar = TYPE_CHAR[productType] || '礼';
|
||||
const typeLabel = TYPE_LABEL[productType] || '商品';
|
||||
const isService = productType === 'service';
|
||||
const balance = account?.balance ?? 0;
|
||||
const canAfford = balance >= product.points_cost;
|
||||
const type = product.product_type || 'physical';
|
||||
const isService = type === 'service';
|
||||
|
||||
const handleExchange = () => {
|
||||
if (product.stock <= 0) {
|
||||
Taro.showToast({ title: '已兑完', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!canAfford) {
|
||||
Taro.showToast({ title: '积分不足', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
Taro.navigateTo({
|
||||
url: `/pages/pkg-mall/exchange/index?product_id=${product.id}`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell padding="none" safeBottom={false} scroll={false} className={`product-detail-page ${modeClass}`}>
|
||||
<View className='product-detail-scroll'>
|
||||
{/* 商品图区域 */}
|
||||
<View className={`product-detail-image product-detail-image--${productType}`}>
|
||||
<Text className='product-detail-image-char'>{typeChar}</Text>
|
||||
<Text className='product-detail-image-label'>{product.name}</Text>
|
||||
<PageShell padding="none" safeBottom={false} scroll={false} className={`product-detail ${modeClass}`}>
|
||||
<View className='product-detail__scroll'>
|
||||
{/* 商品大图 */}
|
||||
<View className={`product-detail__hero ${TYPE_BG[type] || ''}`}>
|
||||
<Text className='product-detail__hero-type'>{TYPE_LABEL[type]}</Text>
|
||||
</View>
|
||||
|
||||
{/* 商品信息卡片 */}
|
||||
<View className='product-detail-info'>
|
||||
<Text className='product-detail-name'>{product.name}</Text>
|
||||
<View className='product-detail-price-row'>
|
||||
<Text className='product-detail-points'>
|
||||
{product.points_cost.toLocaleString()} 积分
|
||||
</Text>
|
||||
<Text className='product-detail-type-tag'>{typeLabel}</Text>
|
||||
{/* 商品信息卡 */}
|
||||
<View className='product-detail__info-card'>
|
||||
<View className='product-detail__tags'>
|
||||
<Text className='product-detail__type-badge'>{TYPE_LABEL[type]}</Text>
|
||||
</View>
|
||||
<Text className='product-detail__name'>{product.name}</Text>
|
||||
<View className='product-detail__price-row'>
|
||||
<Text className='product-detail__points-num'>{product.points_cost}</Text>
|
||||
<Text className='product-detail__points-unit'>积分</Text>
|
||||
</View>
|
||||
|
||||
{product.description && (
|
||||
<Text className='product-detail-desc'>{product.description}</Text>
|
||||
<Text className='product-detail__desc'>{product.description}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 规格 */}
|
||||
<View className='product-detail-specs'>
|
||||
<View className='product-detail-spec-row'>
|
||||
<Text className='product-detail-spec-label'>类型</Text>
|
||||
<Text className='product-detail-spec-value'>{typeLabel}</Text>
|
||||
</View>
|
||||
<View className='product-detail-spec-row'>
|
||||
<Text className='product-detail-spec-label'>库存</Text>
|
||||
<Text className='product-detail-spec-value'>
|
||||
{product.stock > 0 ? `${product.stock} 件` : '已兑完'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='product-detail-spec-row'>
|
||||
<Text className='product-detail-spec-label'>兑换方式</Text>
|
||||
<Text className='product-detail-spec-value'>
|
||||
{isService ? '到院核销' : '后台审核发货'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 温馨提示 */}
|
||||
<View className='product-detail-notice'>
|
||||
<Text className='product-detail-notice-title'>温馨提示</Text>
|
||||
<Text className='product-detail-notice-text'>
|
||||
{isService
|
||||
? '兑换成功后将生成核销码,请凭核销码到前台核销体验服务。'
|
||||
: '兑换后需工作人员审核确认,审核通过后将在 7 个工作日内寄出。'}
|
||||
{/* 库存/余额卡 */}
|
||||
<View className='product-detail__status-card'>
|
||||
<View className='product-detail__status-row'>
|
||||
<Text className='product-detail__status-label'>库存状态</Text>
|
||||
<Text className={`product-detail__status-value ${
|
||||
product.stock > 10 ? 'product-detail__status-value--ok' :
|
||||
product.stock > 0 ? 'product-detail__status-value--warn' :
|
||||
'product-detail__status-value--danger'
|
||||
}`}>
|
||||
{product.stock > 10 ? '充足' : product.stock > 0 ? `仅剩 ${product.stock} 件` : '已兑完'}
|
||||
</Text>
|
||||
<Text className='product-detail-notice-text'>积分一经兑换不可退回。</Text>
|
||||
</View>
|
||||
<View className='product-detail__status-row'>
|
||||
<Text className='product-detail__status-label'>您的积分</Text>
|
||||
<Text className={`product-detail__status-value ${canAfford ? 'product-detail__status-value--ok' : 'product-detail__status-value--danger'}`}>
|
||||
{balance.toLocaleString()} ({canAfford ? '充足' : '不足'})
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 温馨提示 */}
|
||||
<View className='product-detail__notice'>
|
||||
<Text className='product-detail__notice-text'>
|
||||
{isService
|
||||
? '兑换成功后将生成核销码,请凭核销码到前台核销。过期未核销订单将自动取消并退还积分。'
|
||||
: '兑换后需工作人员审核确认,审核通过后 7 个工作日内寄出。积分一经兑换不可退回。'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<View className='product-detail-footer'>
|
||||
<View className='product-detail-fav'>
|
||||
<Text className='product-detail-fav-icon'>♡</Text>
|
||||
<View className='product-detail__footer'>
|
||||
<View className='product-detail__footer-left'>
|
||||
<Text className='product-detail__footer-hint'>需要</Text>
|
||||
<View className='product-detail__footer-price'>
|
||||
<Text className='product-detail__footer-num'>{product.points_cost}</Text>
|
||||
<Text className='product-detail__footer-unit'>积分</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
className={`product-detail-exchange-btn ${product.stock <= 0 ? 'disabled' : ''}`}
|
||||
onClick={product.stock <= 0 ? undefined : handleExchange}
|
||||
className={`product-detail__exchange-btn ${!canAfford || product.stock <= 0 ? 'product-detail__exchange-btn--disabled' : ''}`}
|
||||
onClick={handleExchange}
|
||||
>
|
||||
<Text className='product-detail-exchange-text'>
|
||||
{product.stock <= 0 ? '已兑完' : '立即兑换'}
|
||||
<Text className='product-detail__exchange-text'>
|
||||
{product.stock <= 0 ? '已兑完' : !canAfford ? '积分不足' : '立即兑换'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user