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:
iven
2026-05-22 19:15:41 +08:00
parent 1d443ab894
commit 09013ab94a
21 changed files with 2268 additions and 701 deletions

View File

@@ -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'],

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 B

View File

@@ -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;
}
}
}

View 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'>&#10003;</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);

View 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;
}
}
}

View 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);

View 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;
}
}
}

View 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);

View 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;
}
}
}

View 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);

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>