Files
hms/apps/miniprogram-uniapp/src/pages-sub/pkg-mall/exchange/index.vue
iven 2c567bd772 fix(mp): T40 UI 审查全量修复 + 设计体系一致性优化
Phase 0 基础设施:
- statusTag.ts: getStatusInlineStyle() 移除内联 borderRadius/padding/fontSize,仅返回 {background, color}
- 新增 SEVERITY_COLORS + getSeverityStyle() + getSeverityLabel() 统一告警严重程度样式
- variables.scss: 新增 9 个语义颜色别名 ($success/$danger/$warning/$info 等)
- mixins.scss: 新增 status-inline mixin 统一状态标签样式
- 7 个消费者页面添加 @include status-inline CSS 补偿

Phase 1 HIGH 修复 (4 页面):
- P46 随访管理: 移除 getTypeStyle() 硬编码 fontSize,替换文字 Loading 为组件
- P45 咨询详情医护: 添加 Loading/ErrorState 三态模板 + error ref
- P02 健康数据: 添加 loading ref + Loading 组件 + 错误 toast 提示
- P48 告警中心: 替换本地 SEVERITY_COLORS/SEVERITY_LABELS 为 statusTag.ts 导出

Phase 2 全局一致性:
- 2.1 触控补全: 17 页面为可点击元素添加 min-height: $touch-min
- 2.2 字号替换: 19 文件 31 处硬编码 px → Design Token CSS 变量
- 2.3 颜色替换: 18 文件 ~50 处硬编码十六进制 → SCSS 语义变量
- 2.4 elder-mode.scss: 新增 9 个选择器到触控放大清单

Phase 3 LOW 修复:
- 3.1 统一 Loading: 21 页面旧式文字加载 → <Loading> 组件
- 3.2 useElderClass: 8 页面补全长者模式 class 绑定
- 3.3 零散修复: 按钮 44px→48px,诊断记录添加 scroll-view 无限加载

同时新增 UniApp (Vue 3 + Vite) 小程序完整代码库 (146 文件)
2026-05-15 11:22:51 +08:00

167 lines
8.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view :class="['exchange-page', elderClass]">
<template v-if="loading">
<Loading text="加载中..." />
</template>
<template v-else-if="product">
<view class="product-card">
<view :class="['product-icon-wrap', iconCls]">
<text class="product-icon-char">{{ initial }}</text>
</view>
<view class="product-meta">
<text class="product-name">{{ product.name }}</text>
<text class="product-type-tag">{{ typeLabel }}</text>
</view>
</view>
<view class="detail-section">
<text class="detail-section-title">兑换明细</text>
<view class="detail-card">
<view class="detail-row">
<text class="detail-label">所需积分</text>
<text class="detail-value detail-cost">{{ cost.toLocaleString() }}</text>
</view>
<view class="detail-row">
<text class="detail-label">当前余额</text>
<text :class="['detail-value', insufficient ? 'detail-insufficient' : 'detail-sufficient']">{{ balance.toLocaleString() }}</text>
</view>
<view v-if="insufficient" class="detail-row">
<text class="detail-label">差额</text>
<text class="detail-value detail-insufficient">-{{ (cost - balance).toLocaleString() }}</text>
</view>
<view class="detail-row last">
<text class="detail-label">库存</text>
<text class="detail-value">{{ product.stock > 0 ? `剩余 ${product.stock}` : '已兑完' }}</text>
</view>
</view>
</view>
<view class="notice-section">
<text class="notice-title">温馨提示</text>
<text class="notice-text">兑换成功后将生成核销码请凭核销码到前台核销领取</text>
<text class="notice-text">积分一经兑换不可退回</text>
</view>
<view class="exchange-footer">
<view class="footer-cost">
<text class="footer-cost-label">合计</text>
<text class="footer-cost-num">{{ cost.toLocaleString() }}</text>
<text class="footer-cost-unit">积分</text>
</view>
<view :class="['confirm-btn', insufficient || product.stock <= 0 || submitting ? 'disabled' : '']" @tap="handleConfirm">
<text class="confirm-btn-text">
{{ submitting ? '兑换中...' : insufficient ? '积分不足' : product.stock <= 0 ? '已兑完' : '确认兑换' }}
</text>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { listProducts, exchangeProduct, type PointsProduct } from '@/services/points'
import { usePointsStore } from '@/stores/points'
import { useElderClass } from '@/composables/useElderClass'
import Loading from '@/components/Loading.vue'
const TYPE_INITIAL: Record<string, string> = { physical: '物', service: '券', privilege: '权' }
const TYPE_LABEL: Record<string, string> = { physical: '实物商品', service: '服务券', privilege: '权益卡' }
const TYPE_CLASS: Record<string, string> = { physical: 'product-icon-wrap--physical', service: 'product-icon-wrap--service', privilege: 'product-icon-wrap--privilege' }
const { elderClass } = useElderClass()
const pointsStore = usePointsStore()
const product = ref<PointsProduct | null>(null)
const loading = ref(true)
const submitting = ref(false)
let productId = ''
const balance = computed(() => pointsStore.account?.balance ?? 0)
const cost = computed(() => product.value?.points_cost ?? 0)
const insufficient = computed(() => balance.value < cost.value)
const productType = computed(() => product.value?.product_type || 'physical')
const initial = computed(() => TYPE_INITIAL[productType.value] || '礼')
const typeLabel = computed(() => TYPE_LABEL[productType.value] || '商品')
const iconCls = computed(() => TYPE_CLASS[productType.value] || 'product-icon-wrap--service')
const loadData = async () => {
const instance = getCurrentPages()
const page = instance[instance.length - 1] as any
productId = page?.$page?.options?.product_id || page?.options?.product_id || ''
if (!productId) {
uni.showToast({ title: '参数错误', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
return
}
loading.value = true
try {
const [productRes] = await Promise.all([listProducts({ page: 1, page_size: 100 }), pointsStore.refresh()])
const found = productRes.data.find(p => p.id === productId)
if (!found) { uni.showToast({ title: '商品不存在', icon: 'none' }); setTimeout(() => uni.navigateBack(), 1500); return }
product.value = found
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
} finally {
loading.value = false
}
}
const handleConfirm = async () => {
if (!product.value || submitting.value || insufficient.value || product.value.stock <= 0) return
const modalRes = await uni.showModal({ title: '确认兑换', content: `确定花费 ${cost.value} 积分兑换「${product.value.name}」吗?` })
if (!modalRes.confirm) return
submitting.value = true
try {
const order = await exchangeProduct(product.value.id)
uni.showToast({ title: '兑换成功', icon: 'success', duration: 2000 })
setTimeout(() => {
uni.showModal({ title: '兑换成功', content: `核销码: ${order.qr_code}\n请凭此码到前台核销`, showCancel: false, confirmText: '查看订单', success: () => uni.navigateTo({ url: '/pages-sub/pkg-mall/orders/index' }) })
}, 2000)
} catch (err) {
const msg = err instanceof Error ? err.message : '兑换失败'
if (msg.includes('余额不足') || msg.includes('insufficient')) uni.showToast({ title: '积分不足', icon: 'none' })
else uni.showToast({ title: msg, icon: 'none' })
} finally {
submitting.value = false
}
}
onShow(() => { uni.setNavigationBarTitle({ title: '确认兑换' }); loadData() })
</script>
<style lang="scss" scoped>
.exchange-page { min-height: 100vh; background: $bg; padding: 24px; }
.product-card { @include card; display: flex; align-items: center; gap: 16px; margin-bottom: 16px; }
.product-icon-wrap { width: 48px; height: 48px; border-radius: 12px; @include flex-center; }
.product-icon-wrap--physical { background: rgba(250,173,20,0.15); }
.product-icon-wrap--service { background: rgba($pri, 0.1); }
.product-icon-wrap--privilege { background: rgba(114,46,209,0.15); }
.product-icon-char { font-size: var(--tk-font-cap); font-weight: 600; }
.product-meta { flex: 1; }
.product-name { font-size: var(--tk-font-body); font-weight: 500; color: $tx; display: block; }
.product-type-tag { font-size: var(--tk-font-cap); color: $tx3; margin-top: 4px; display: block; }
.detail-section { @include card; margin-bottom: 16px; }
.detail-section-title { font-size: var(--tk-font-body); font-weight: 500; color: $tx; margin-bottom: 12px; display: block; }
.detail-card { }
.detail-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid rgba(0,0,0,0.04); }
.detail-row.last { border-bottom: none; }
.detail-label { font-size: var(--tk-font-cap); color: $tx3; }
.detail-value { font-size: var(--tk-font-body); color: $tx; }
.detail-cost { color: $wrn; font-weight: 500; }
.detail-sufficient { color: $acc; }
.detail-insufficient { color: $wrn; }
.notice-section { @include card; margin-bottom: 16px; }
.notice-title { font-size: var(--tk-font-cap); font-weight: 500; color: $tx2; margin-bottom: 8px; display: block; }
.notice-text { font-size: var(--tk-font-cap); color: $tx3; line-height: 1.6; display: block; }
.exchange-footer { position: fixed; bottom: 0; left: 0; right: 0; display: flex; align-items: center; padding: 12px 24px; background: $card; box-shadow: $shadow-sm; gap: 16px; }
.footer-cost { flex: 1; display: flex; align-items: baseline; gap: 4px; }
.footer-cost-label { font-size: var(--tk-font-cap); color: $tx3; }
.footer-cost-num { font-size: var(--tk-font-body); font-weight: 600; color: $wrn; }
.footer-cost-unit { font-size: var(--tk-font-cap); color: $tx3; }
.confirm-btn { height: $touch-min; padding: 0 32px; background: $pri; border-radius: $r; @include flex-center; }
.confirm-btn.disabled { opacity: 0.5; }
.confirm-btn-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
</style>