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