diff --git a/apps/miniprogram/src/components/ui/AvatarCircle/index.scss b/apps/miniprogram/src/components/ui/AvatarCircle/index.scss new file mode 100644 index 0000000..150018e --- /dev/null +++ b/apps/miniprogram/src/components/ui/AvatarCircle/index.scss @@ -0,0 +1,14 @@ +@import '../../../styles/variables.scss'; + +.avatar-circle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + &__text { + font-family: 'Georgia', 'Times New Roman', serif; + font-weight: 700; + line-height: 1; + } +} diff --git a/apps/miniprogram/src/components/ui/AvatarCircle/index.tsx b/apps/miniprogram/src/components/ui/AvatarCircle/index.tsx new file mode 100644 index 0000000..0908f81 --- /dev/null +++ b/apps/miniprogram/src/components/ui/AvatarCircle/index.tsx @@ -0,0 +1,52 @@ +import React, { useMemo } from 'react'; +import { View, Text } from '@tarojs/components'; +import './index.scss'; + +type AvatarColor = 'pri' | 'acc' | 'wrn' | 'dan'; + +interface AvatarCircleProps { + name: string; + size?: number; + color?: AvatarColor; + className?: string; +} + +const COLOR_MAP: Record = { + pri: { bg: '#D4E5F0', fg: '#3A6B8C' }, + acc: { bg: '#E8F0E8', fg: '#5B7A5E' }, + wrn: { bg: '#FFF3E0', fg: '#C4873A' }, + dan: { bg: '#FDEAEA', fg: '#B54A4A' }, +}; + +const AvatarCircle: React.FC = ({ + name, + size = 44, + color = 'pri', + className = '', +}) => { + const initial = useMemo(() => name?.charAt(0) || '?', [name]); + const colorStyle = COLOR_MAP[color]; + + const cls = ['avatar-circle', className].filter(Boolean).join(' '); + + return ( + + + {initial} + + + ); +}; + +export default React.memo(AvatarCircle); diff --git a/apps/miniprogram/src/components/ui/ShortcutButton/index.scss b/apps/miniprogram/src/components/ui/ShortcutButton/index.scss new file mode 100644 index 0000000..f09b446 --- /dev/null +++ b/apps/miniprogram/src/components/ui/ShortcutButton/index.scss @@ -0,0 +1,79 @@ +@import '../../../styles/variables.scss'; + +.shortcut-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + cursor: pointer; + + &:active { + opacity: var(--tk-touch-feedback-opacity); + } + + &__icon-wrap { + position: relative; + width: 52px; + height: 52px; + border-radius: 26px; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.15s; + } + + &__icon { + font-size: 22px; + line-height: 1; + } + + &__badge { + position: absolute; + top: -4px; + right: -8px; + min-width: 18px; + height: 18px; + line-height: 18px; + text-align: center; + background: $dan; + color: $white; + font-size: 11px; + font-weight: 700; + border-radius: $r-pill; + padding: 0 4px; + } + + &__label { + font-size: 12px; + color: $tx2; + } + + // ── 色彩变体(对齐 SPEC T.priL/T.accL/T.wrnL/T.danL)── + &--pri .shortcut-btn__icon-wrap { + background: #D4E5F0; + } + &--pri .shortcut-btn__icon { + color: #3A6B8C; + } + + &--acc .shortcut-btn__icon-wrap { + background: #E8F0E8; + } + &--acc .shortcut-btn__icon { + color: #5B7A5E; + } + + &--wrn .shortcut-btn__icon-wrap { + background: #FFF3E0; + } + &--wrn .shortcut-btn__icon { + color: #C4873A; + } + + &--dan .shortcut-btn__icon-wrap { + background: #FDEAEA; + } + &--dan .shortcut-btn__icon { + color: #B54A4A; + } +} diff --git a/apps/miniprogram/src/components/ui/ShortcutButton/index.tsx b/apps/miniprogram/src/components/ui/ShortcutButton/index.tsx new file mode 100644 index 0000000..9af3c59 --- /dev/null +++ b/apps/miniprogram/src/components/ui/ShortcutButton/index.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { View, Text } from '@tarojs/components'; +import './index.scss'; + +type ButtonColor = 'pri' | 'acc' | 'wrn' | 'dan'; + +interface ShortcutButtonProps { + icon: string; + label: string; + color?: ButtonColor; + onPress?: () => void; + badge?: number; +} + +const ShortcutButton: React.FC = ({ + icon, + label, + color = 'pri', + onPress, + badge, +}) => { + const cls = ['shortcut-btn', `shortcut-btn--${color}`].join(' '); + + return ( + + + {icon} + {badge != null && badge > 0 && ( + + {badge > 99 ? '99+' : badge} + + )} + + {label} + + ); +}; + +export default React.memo(ShortcutButton); diff --git a/apps/miniprogram/src/components/ui/TodoAlert/index.scss b/apps/miniprogram/src/components/ui/TodoAlert/index.scss new file mode 100644 index 0000000..8b938d5 --- /dev/null +++ b/apps/miniprogram/src/components/ui/TodoAlert/index.scss @@ -0,0 +1,87 @@ +@import '../../../styles/variables.scss'; + +.todo-alert { + display: flex; + align-items: center; + gap: 10px; + border-radius: $r; + padding: 14px 16px; + margin-bottom: 10px; + + &:active { + opacity: var(--tk-touch-feedback-opacity); + } + + &__icon-wrap { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + &__icon { + font-size: 18px; + line-height: 1; + } + + &__body { + flex: 1; + min-width: 0; + } + + &__title { + display: block; + font-size: 15px; + font-weight: 600; + color: $tx; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__subtitle { + display: block; + font-size: 12px; + color: $tx3; + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__arrow { + flex-shrink: 0; + font-size: 20px; + color: $tx3; + margin-left: 4px; + } + + // ── 靛蓝变体 ── + &--pri { + background: #D4E5F0; + border-left: 4px solid #3A6B8C; + + .todo-alert__icon-wrap { + background: rgba(58, 107, 140, 0.15); + } + .todo-alert__icon { + color: #3A6B8C; + } + } + + // ── 警告变体 ── + &--wrn { + background: #FFF3E0; + border-left: 4px solid #C4873A; + + .todo-alert__icon-wrap { + background: rgba(196, 135, 58, 0.15); + } + .todo-alert__icon { + color: #C4873A; + } + } +} diff --git a/apps/miniprogram/src/components/ui/TodoAlert/index.tsx b/apps/miniprogram/src/components/ui/TodoAlert/index.tsx new file mode 100644 index 0000000..6be9f0a --- /dev/null +++ b/apps/miniprogram/src/components/ui/TodoAlert/index.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { View, Text } from '@tarojs/components'; +import './index.scss'; + +type AlertColor = 'pri' | 'wrn'; + +interface TodoAlertProps { + icon?: string; + title: string; + subtitle?: string; + color?: AlertColor; + onPress?: () => void; +} + +const TodoAlert: React.FC = ({ + icon, + title, + subtitle, + color = 'pri', + onPress, +}) => { + const cls = ['todo-alert', `todo-alert--${color}`].join(' '); + + return ( + + {icon && ( + + {icon} + + )} + + {title} + {subtitle && {subtitle}} + + + + ); +}; + +export default React.memo(TodoAlert); diff --git a/apps/miniprogram/src/pages/pkg-mall/product/index.scss b/apps/miniprogram/src/pages/pkg-mall/product/index.scss new file mode 100644 index 0000000..6aedbe6 --- /dev/null +++ b/apps/miniprogram/src/pages/pkg-mall/product/index.scss @@ -0,0 +1,226 @@ +@import '../../../styles/variables.scss'; +@import '../../../styles/mixins.scss'; + +// 商品详情 — 对齐原型 docs/design/mp-10-mall-pkg.html → 屏幕1 + +.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); + 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); +} + +.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; + } + + &:active:not(.disabled) { + opacity: var(--tk-touch-feedback-opacity); + } +} + +.product-detail-exchange-text { + font-family: 'Georgia', 'Times New Roman', serif; + font-size: var(--tk-font-body); + color: $white; + font-weight: 700; +} diff --git a/apps/miniprogram/src/pages/pkg-mall/product/index.tsx b/apps/miniprogram/src/pages/pkg-mall/product/index.tsx new file mode 100644 index 0000000..5b95d0f --- /dev/null +++ b/apps/miniprogram/src/pages/pkg-mall/product/index.tsx @@ -0,0 +1,160 @@ +import React, { useState, useCallback } from 'react'; +import { View, Text } from '@tarojs/components'; +import Taro, { useRouter } from '@tarojs/taro'; +import { usePageData } from '@/hooks/usePageData'; +import { getProduct } from '../../../services/points'; +import type { PointsProduct } from '../../../services/points'; +import { useElderClass } from '../../../hooks/useElderClass'; +import PageShell from '@/components/ui/PageShell'; +import './index.scss'; + +const TYPE_CHAR: Record = { + physical: '物', + service: '券', + privilege: '权', +}; + +const TYPE_LABEL: Record = { + physical: '实物商品', + service: '服务体验', + privilege: '权益卡', +}; + +export default function ProductDetail() { + const modeClass = useElderClass(); + const router = useRouter(); + const productId = router.params.product_id || ''; + const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchProduct = useCallback(async () => { + if (!productId) return; + setLoading(true); + try { + const data = await getProduct(productId); + setProduct(data); + } 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 { + Taro.showToast({ title: '加载失败', icon: 'none' }); + } + } finally { + setLoading(false); + } + }, [productId]); + + usePageData(fetchProduct, { throttleMs: 30000 }); + + if (loading) { + return ( + + + 加载中... + + + ); + } + + if (!product) { + return ( + + + 商品不存在 + + + ); + } + + const productType = product.product_type || 'physical'; + const typeChar = TYPE_CHAR[productType] || '礼'; + const typeLabel = TYPE_LABEL[productType] || '商品'; + const isService = productType === 'service'; + + const handleExchange = () => { + if (product.stock <= 0) { + Taro.showToast({ title: '已兑完', icon: 'none' }); + return; + } + Taro.navigateTo({ + url: `/pages/pkg-mall/exchange/index?product_id=${product.id}`, + }); + }; + + return ( + + + {/* 商品图区域 */} + + {typeChar} + {product.name} + + + {/* 商品信息卡片 */} + + {product.name} + + + {product.points_cost.toLocaleString()} 积分 + + {typeLabel} + + + {product.description && ( + {product.description} + )} + + {/* 规格 */} + + + 类型 + {typeLabel} + + + 库存 + + {product.stock > 0 ? `${product.stock} 件` : '已兑完'} + + + + 兑换方式 + + {isService ? '到院核销' : '后台审核发货'} + + + + + {/* 温馨提示 */} + + 温馨提示 + + {isService + ? '兑换成功后将生成核销码,请凭核销码到前台核销体验服务。' + : '兑换后需工作人员审核确认,审核通过后将在 7 个工作日内寄出。'} + + 积分一经兑换不可退回。 + + + + + {/* 底部操作栏 */} + + + + + + + {product.stock <= 0 ? '已兑完' : '立即兑换'} + + + + + ); +}