Explorar o código

feat: 新增完善会员信息流程与优惠券详情页

1.  新增完善信息页面与路由配置,添加realName用户信息字段
2.  新增优惠券详情页面,完善优惠券列表展示与交互
3.  登录后自动校验并引导完善信息,新增全局信息完善校验工具
4.  修复店员页路由参数解码问题,优化个人中心展示逻辑
yanxh hai 2 semanas
pai
achega
967796b1fb

+ 90 - 0
src/api/modules/marketing.ts

@@ -31,17 +31,97 @@ export type MarketingCouponItem = {
   id?: string
   memberCouponId?: string
   couponId?: string
+  templateId?: string
   couponName?: string
   couponType?: string
+  couponTypeName?: string
+  discountType?: number
+  discountTypeName?: string
   discountAmount?: number
   discountValue?: number
   minOrderAmount?: number
   minConsumeAmount?: number
+  maxDiscountAmount?: number
   status?: number | string
   statusName?: string
   expireDate?: string
   validEndDate?: string
+  validStartDate?: string
+  validFrom?: string
+  validTo?: string
   useDate?: string
+  getDate?: string
+  getSource?: string
+  getType?: number
+  getTypeName?: string
+  exchangePoints?: number
+  couponSn?: string
+  sceneCode?: string
+  sceneName?: string
+  useScopeName?: string
+  useOrderNo?: string
+  useAmount?: number
+  orderAmount?: number
+  // CouponDemoVO 额外字段(可领券列表)
+  name?: string
+  subtitle?: string
+  face?: string
+  threshold?: string
+  scope?: string[]
+  issuePeriod?: string
+  remainQuota?: number
+  rules?: string[]
+  stackRule?: string
+  verifyTip?: string
+  issuer?: string
+  walletCode?: string
+  [key: string]: unknown
+}
+
+/** 会员券详情(对应后端 MemberCouponInfoVO) */
+export type MemberCouponDetailVO = {
+  id?: string
+  couponSn?: string
+  memberId?: string
+  memberNo?: string
+  memberNickname?: string
+  memberMobile?: string
+  templateId?: string
+  couponCode?: string
+  couponName?: string
+  couponType?: number
+  couponTypeName?: string
+  discountType?: number
+  discountTypeName?: string
+  discountValue?: number
+  minConsumeAmount?: number
+  maxDiscountAmount?: number
+  getType?: number
+  getTypeName?: string
+  getDate?: string
+  getSource?: string
+  activityId?: string
+  exchangePoints?: number
+  validStartDate?: string
+  validEndDate?: string
+  status?: number
+  statusName?: string
+  useDate?: string
+  useOrderId?: string
+  useOrderNo?: string
+  useAmount?: number
+  orderAmount?: number
+  returnDate?: string
+  returnReason?: string
+  isReturned?: number
+  freezeDate?: string
+  freezeReason?: string
+  freezeOrderId?: string
+  createDate?: string
+  sceneCode?: string
+  sceneName?: string
+  useScope?: number
+  useScopeName?: string
   [key: string]: unknown
 }
 
@@ -134,6 +214,16 @@ export function fetchMarketingVerifyCode(memberCouponId: string, tenantCode: str
   return get<MarketingVerifyCode>('/marketing/coupon/verifyCode', { memberCouponId, tenantCode })
 }
 
+/** 会员券详情 */
+export function fetchCouponDetail(memberCouponId: string, tenantCode: string) {
+  return get<MemberCouponDetailVO>('/marketing/coupon/detail', { memberCouponId, tenantCode })
+}
+
+/** 会员券卡面详情 */
+export function fetchCouponWalletDetail(memberCouponId: string, tenantCode: string) {
+  return get<MarketingCouponItem>('/marketing/coupon/walletDetail', { memberCouponId, tenantCode })
+}
+
 /** 店员扫码核销 */
 export function verifyMarketingCoupon(payload: { tenantCode: string; verifyToken: string }) {
   return post<MarketingVerifyResult>('/marketing/clerk/coupon/verify', payload)

+ 10 - 1
src/api/modules/member.ts

@@ -1,9 +1,10 @@
-import { get } from '@/utils/request'
+import { get, post } from '@/utils/request'
 
 export type MemberInfo = {
   id?: string
   memberNo?: string
   nickname?: string
+  realName?: string
   avatar?: string
   mobile?: string
   phone?: string
@@ -22,3 +23,11 @@ export function fetchMemberInfo() {
 export function fetchMemberCodeCard() {
   return get<MemberCodeCard>('/member/member/codeCard', undefined, { showLoading: false })
 }
+
+export function completeProfile(payload: { realName: string; mobile: string }) {
+  return post<void>('/member/member/completeProfile', payload)
+}
+
+export function getPhoneByCode(payload: { code: string }) {
+  return post<string>('/member/auth/getPhoneByCode', payload, { showLoading: false })
+}

+ 1 - 0
src/api/modules/memberAuth.ts

@@ -21,6 +21,7 @@ export interface MemberLoginResultVO {
   memberId: string
   memberNo?: string
   nickname?: string
+  realName?: string
   avatar?: string
   mobile?: string
 }

+ 98 - 12
src/components/MkCouponCard.vue

@@ -1,23 +1,37 @@
 <template>
-  <view class="coupon-card" :class="{ 'coupon-card--dimmed': dimmed }">
+  <view
+    class="coupon-card"
+    :class="{ 'coupon-card--dimmed': dimmed, 'coupon-card--clickable': clickable }"
+    @click="handleClick"
+  >
     <view class="coupon-card__left">
       <text class="coupon-card__amount">{{ amountText }}</text>
       <text class="coupon-card__unit">{{ unitText }}</text>
     </view>
     <view class="coupon-card__divider" />
     <view class="coupon-card__body">
-      <text class="coupon-card__name">{{ name }}</text>
-      <text class="coupon-card__meta">{{ meta }}</text>
-      <view v-if="tag" class="coupon-card__tag">{{ tag }}</view>
+      <view class="coupon-card__name-row">
+        <text class="coupon-card__name">{{ name }}</text>
+        <text v-if="couponType" class="coupon-card__type">{{ couponType }}</text>
+      </view>
+      <text v-if="meta" class="coupon-card__meta">{{ meta }}</text>
+      <text v-if="subMeta" class="coupon-card__sub-meta">{{ subMeta }}</text>
+      <view class="coupon-card__bottom">
+        <view v-if="tag" class="coupon-card__tag">{{ tag }}</view>
+        <text v-if="validPeriod" class="coupon-card__period">{{ validPeriod }}</text>
+      </view>
     </view>
-    <view v-if="$slots.action" class="coupon-card__action">
+    <view v-if="$slots.action" class="coupon-card__action" @click.stop>
       <slot name="action" />
     </view>
+    <view v-else-if="clickable" class="coupon-card__arrow">
+      <text class="coupon-card__arrow-icon">›</text>
+    </view>
   </view>
 </template>
 
 <script setup lang="ts">
-withDefaults(
+const props = withDefaults(
   defineProps<{
     name?: string
     meta?: string
@@ -25,16 +39,30 @@ withDefaults(
     unitText?: string
     tag?: string
     dimmed?: boolean
+    couponType?: string
+    validPeriod?: string
+    subMeta?: string
+    clickable?: boolean
   }>(),
   {
     name: '优惠券',
-    meta: '店铺线下券',
+    meta: '',
     amountText: '惠',
     unitText: '券',
     tag: '',
-    dimmed: false
+    dimmed: false,
+    couponType: '',
+    validPeriod: '',
+    subMeta: '',
+    clickable: false
   }
 )
+
+const emit = defineEmits<{ click: [] }>()
+
+function handleClick() {
+  if (props.clickable) emit('click')
+}
 </script>
 
 <style scoped lang="scss">
@@ -63,6 +91,14 @@ withDefaults(
       color: #a89888;
     }
   }
+
+  &--clickable {
+    cursor: pointer;
+
+    &:active {
+      opacity: 0.85;
+    }
+  }
 }
 
 .coupon-card__left {
@@ -96,28 +132,61 @@ withDefaults(
 
 .coupon-card__body {
   flex: 1;
-  padding: 24rpx 20rpx;
+  padding: 20rpx 20rpx;
   min-width: 0;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+}
+
+.coupon-card__name-row {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
 }
 
 .coupon-card__name {
-  display: block;
   font-size: 30rpx;
   font-weight: 700;
   color: #2c1810;
 }
 
+.coupon-card__type {
+  flex-shrink: 0;
+  padding: 2rpx 10rpx;
+  border-radius: 6rpx;
+  background: #fff3e6;
+  color: #ff7a1a;
+  font-size: 20rpx;
+  font-weight: 600;
+}
+
 .coupon-card__meta {
   display: block;
-  margin-top: 10rpx;
+  margin-top: 8rpx;
   font-size: 24rpx;
   color: #8c7b6a;
   line-height: 1.5;
 }
 
+.coupon-card__sub-meta {
+  display: block;
+  margin-top: 4rpx;
+  font-size: 22rpx;
+  color: #b5a89d;
+  line-height: 1.4;
+}
+
+.coupon-card__bottom {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  margin-top: 10rpx;
+  flex-wrap: wrap;
+}
+
 .coupon-card__tag {
   display: inline-block;
-  margin-top: 12rpx;
   padding: 4rpx 14rpx;
   border-radius: 999rpx;
   background: #fff3e6;
@@ -125,9 +194,26 @@ withDefaults(
   font-size: 22rpx;
 }
 
+.coupon-card__period {
+  font-size: 22rpx;
+  color: #b5a89d;
+}
+
 .coupon-card__action {
   display: flex;
   align-items: center;
   padding-right: 20rpx;
 }
+
+.coupon-card__arrow {
+  display: flex;
+  align-items: center;
+  padding-right: 16rpx;
+}
+
+.coupon-card__arrow-icon {
+  font-size: 36rpx;
+  color: #c4b5a5;
+  font-weight: 300;
+}
 </style>

+ 1 - 0
src/constants/routes.ts

@@ -2,3 +2,4 @@
  * 小程序内固定路由。
  */
 export const ROUTE_LOGIN_INDEX = '/package-views/pages/login/index'
+export const ROUTE_COMPLETE_INFO = '/package-views/pages/profile/complete-info'

+ 14 - 0
src/package-views/pages/login/index.vue

@@ -39,6 +39,7 @@ import { ref } from 'vue'
 import { loginByWechatMiniPhone } from '@/api/modules/memberAuth'
 import { useAuthStore } from '@/store/modules/auth'
 import { isTabBarPage } from '@/utils/auth'
+import { ROUTE_COMPLETE_INFO } from '@/constants/routes'
 import { removeSsoToken } from '@/utils/tokenStorage'
 
 const auth = useAuthStore()
@@ -91,6 +92,7 @@ async function onGetPhoneNumber(e: { detail?: { errMsg?: string; code?: string }
     auth.setUser({
       id: data.memberId,
       nickname: (data.nickname || '会员').trim(),
+      realName: (data.realName || '').trim(),
       loginName: data.memberNo?.trim(),
       memberNo: data.memberNo?.trim(),
       avatar: data.avatar || '',
@@ -109,7 +111,19 @@ async function onGetPhoneNumber(e: { detail?: { errMsg?: string; code?: string }
 }
 
 function goAfterLogin() {
+  // 登录成功后校验信息是否完善(姓名 + 手机号),未完善则跳完善页
+  const user = auth.userInfo
+  const realName = (user?.realName || '').trim()
+  const phone = (user?.phone || '').trim()
   const fallbackUrl = '/pages/home/index'
+  if (!realName || !phone) {
+    const target = `${ROUTE_COMPLETE_INFO}?redirect=${encodeURIComponent(redirect.value || fallbackUrl)}`
+    uni.redirectTo({
+      url: target,
+      fail: () => uni.reLaunch({ url: target })
+    })
+    return
+  }
   const url = redirect.value || fallbackUrl
   if (isTabBarPage(url)) {
     uni.switchTab({

+ 1 - 1
src/package-views/pages/marketing/clerk-verify.vue

@@ -71,7 +71,7 @@ function goRecords() {
 
 onLoad(query => {
   tenantCode.value = String(query?.tenantCode || '')
-  tenantName.value = String(query?.tenantName || '')
+  tenantName.value = decodeURIComponent(String(query?.tenantName || ''))
 })
 </script>
 

+ 667 - 0
src/package-views/pages/marketing/coupon-detail.vue

@@ -0,0 +1,667 @@
+<template>
+  <view class="page-gradient coupon-detail">
+    <view v-if="loading" class="card empty-card">
+      <text class="muted">加载中…</text>
+    </view>
+
+    <template v-else-if="coupon">
+      <!-- 顶部券卡 -->
+      <view class="detail-hero" :class="{ 'detail-hero--dimmed': isDimmed }">
+        <view class="detail-hero__left">
+          <text class="detail-hero__amount">{{ heroAmount }}</text>
+          <text class="detail-hero__unit">{{ heroUnit }}</text>
+        </view>
+        <view class="detail-hero__body">
+          <view class="detail-hero__name-row">
+            <text class="detail-hero__name">{{ displayName }}</text>
+            <text v-if="displayType" class="detail-hero__type">{{ displayType }}</text>
+          </view>
+          <text v-if="displayThreshold" class="detail-hero__threshold">{{ displayThreshold }}</text>
+          <view v-if="displayStatus" class="detail-hero__status" :class="statusClass">{{ displayStatus }}</view>
+        </view>
+      </view>
+
+      <!-- 优惠信息 -->
+      <view class="card detail-section">
+        <text class="section-title">优惠信息</text>
+        <view class="detail-list">
+          <view class="detail-row">
+            <text class="detail-row__label">券类型</text>
+            <text class="detail-row__value">{{ displayType || '-' }}</text>
+          </view>
+          <view v-if="displayDiscountType" class="detail-row">
+            <text class="detail-row__label">优惠方式</text>
+            <text class="detail-row__value">{{ displayDiscountType }}</text>
+          </view>
+          <view class="detail-row">
+            <text class="detail-row__label">优惠值</text>
+            <text class="detail-row__value">{{ displayDiscountValue }}</text>
+          </view>
+          <view class="detail-row">
+            <text class="detail-row__label">使用门槛</text>
+            <text class="detail-row__value">{{ displayThreshold || '无门槛' }}</text>
+          </view>
+          <view v-if="displayMaxDiscount" class="detail-row">
+            <text class="detail-row__label">最大优惠</text>
+            <text class="detail-row__value">¥{{ displayMaxDiscount }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 有效期 -->
+      <view class="card detail-section">
+        <text class="section-title">有效期</text>
+        <view class="detail-list">
+          <view class="detail-row">
+            <text class="detail-row__label">有效期</text>
+            <text class="detail-row__value">{{ displayValidPeriod || '-' }}</text>
+          </view>
+          <view v-if="displayIssuePeriod" class="detail-row">
+            <text class="detail-row__label">发放周期</text>
+            <text class="detail-row__value">{{ displayIssuePeriod }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 领取信息(mode=mine) -->
+      <view v-if="mode === 'mine' && mineCoupon" class="card detail-section">
+        <text class="section-title">领取信息</text>
+        <view class="detail-list">
+          <view v-if="mineCoupon.getTypeName" class="detail-row">
+            <text class="detail-row__label">领取方式</text>
+            <text class="detail-row__value">{{ mineCoupon.getTypeName }}</text>
+          </view>
+          <view v-if="mineCoupon.getDate" class="detail-row">
+            <text class="detail-row__label">领取时间</text>
+            <text class="detail-row__value">{{ formatDate(String(mineCoupon.getDate)) }}</text>
+          </view>
+          <view v-if="mineCoupon.exchangePoints != null && mineCoupon.exchangePoints > 0" class="detail-row">
+            <text class="detail-row__label">兑换积分</text>
+            <text class="detail-row__value">{{ mineCoupon.exchangePoints }} 积分</text>
+          </view>
+          <view v-if="mineCoupon.couponSn" class="detail-row">
+            <text class="detail-row__label">券编号</text>
+            <text class="detail-row__value">{{ mineCoupon.couponSn }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 使用范围 -->
+      <view class="card detail-section">
+        <text class="section-title">使用范围</text>
+        <view class="detail-list">
+          <view v-if="displaySceneName" class="detail-row">
+            <text class="detail-row__label">使用场景</text>
+            <text class="detail-row__value">{{ displaySceneName }}</text>
+          </view>
+          <view v-if="displayScopeName" class="detail-row">
+            <text class="detail-row__label">适用范围</text>
+            <text class="detail-row__value">{{ displayScopeName }}</text>
+          </view>
+          <view v-if="displayScopeLabels && displayScopeLabels.length > 0" class="detail-row">
+            <text class="detail-row__label">适用标签</text>
+            <view class="detail-row__tags">
+              <text v-for="(label, idx) in displayScopeLabels" :key="idx" class="detail-scope-tag">{{ label }}</text>
+            </view>
+          </view>
+          <view v-if="displayStackRule" class="detail-row">
+            <text class="detail-row__label">叠加规则</text>
+            <text class="detail-row__value">{{ displayStackRule }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 使用记录(mode=mine 且已使用) -->
+      <view v-if="mode === 'mine' && mineCoupon && mineCoupon.status === 1" class="card detail-section">
+        <text class="section-title">使用记录</text>
+        <view class="detail-list">
+          <view v-if="mineCoupon.useDate" class="detail-row">
+            <text class="detail-row__label">使用时间</text>
+            <text class="detail-row__value">{{ formatDate(String(mineCoupon.useDate)) }}</text>
+          </view>
+          <view v-if="mineCoupon.useAmount != null" class="detail-row">
+            <text class="detail-row__label">抵扣金额</text>
+            <text class="detail-row__value">¥{{ mineCoupon.useAmount }}</text>
+          </view>
+          <view v-if="mineCoupon.useOrderNo" class="detail-row">
+            <text class="detail-row__label">订单编号</text>
+            <text class="detail-row__value">{{ mineCoupon.useOrderNo }}</text>
+          </view>
+          <view v-if="mineCoupon.orderAmount != null" class="detail-row">
+            <text class="detail-row__label">订单金额</text>
+            <text class="detail-row__value">¥{{ mineCoupon.orderAmount }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 冻结/退回信息 -->
+      <view v-if="mode === 'mine' && mineCoupon && mineCoupon.status === 3 && mineCoupon.freezeReason" class="card detail-section">
+        <text class="section-title">冻结信息</text>
+        <view class="detail-list">
+          <view v-if="mineCoupon.freezeDate" class="detail-row">
+            <text class="detail-row__label">冻结时间</text>
+            <text class="detail-row__value">{{ formatDate(String(mineCoupon.freezeDate)) }}</text>
+          </view>
+          <view class="detail-row">
+            <text class="detail-row__label">冻结原因</text>
+            <text class="detail-row__value">{{ mineCoupon.freezeReason }}</text>
+          </view>
+          <view v-if="mineCoupon.freezeOrderId" class="detail-row">
+            <text class="detail-row__label">冻结订单</text>
+            <text class="detail-row__value">{{ mineCoupon.freezeOrderId }}</text>
+          </view>
+        </view>
+      </view>
+
+      <view v-if="mode === 'mine' && mineCoupon && mineCoupon.isReturned === 1" class="card detail-section">
+        <text class="section-title">退回信息</text>
+        <view class="detail-list">
+          <view v-if="mineCoupon.returnDate" class="detail-row">
+            <text class="detail-row__label">退回时间</text>
+            <text class="detail-row__value">{{ formatDate(String(mineCoupon.returnDate)) }}</text>
+          </view>
+          <view v-if="mineCoupon.returnReason" class="detail-row">
+            <text class="detail-row__label">退回原因</text>
+            <text class="detail-row__value">{{ mineCoupon.returnReason }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 规则说明 -->
+      <view v-if="displayRules && displayRules.length > 0" class="card detail-section">
+        <text class="section-title">使用规则</text>
+        <view class="detail-rules">
+          <view v-for="(rule, idx) in displayRules" :key="idx" class="detail-rule-item">
+            <text class="detail-rule-dot">·</text>
+            <text class="detail-rule-text">{{ rule }}</text>
+          </view>
+        </view>
+      </view>
+
+      <view v-if="displayVerifyTip" class="card detail-section">
+        <text class="section-title">核销提示</text>
+        <text class="detail-verify-tip">{{ displayVerifyTip }}</text>
+      </view>
+    </template>
+
+    <view v-else class="card empty-card">
+      <view class="empty-block">
+        <text class="muted">优惠券信息加载失败</text>
+      </view>
+    </view>
+
+    <!-- 底部操作栏 -->
+    <view v-if="showBottomBar" class="detail-bottom">
+      <button v-if="mode === 'mine' && canShowVerify" class="primary-btn detail-bottom__btn" @click="goVerifyCode">
+        查看核销码
+      </button>
+      <button
+        v-else-if="mode === 'available' && canReceive"
+        class="primary-btn detail-bottom__btn"
+        :loading="receiving"
+        @click="handleReceive"
+      >
+        立即领取
+      </button>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import {
+  fetchCouponDetail,
+  receiveMarketingCoupon,
+  type MarketingCouponItem,
+  type MemberCouponDetailVO
+} from '@/api/modules/marketing'
+import { isLoggedIn, requireLoginNavigate } from '@/utils/auth'
+
+const loading = ref(true)
+const receiving = ref(false)
+const mode = ref<'mine' | 'available'>('mine')
+const tenantCode = ref('')
+const memberCouponId = ref('')
+
+const mineCoupon = ref<MemberCouponDetailVO | null>(null)
+const availableCoupon = ref<MarketingCouponItem | null>(null)
+
+const coupon = computed(() => (mode.value === 'mine' ? mineCoupon.value : availableCoupon.value))
+
+onLoad((options) => {
+  const m = (options?.mode as 'mine' | 'available') || 'mine'
+  mode.value = m
+  tenantCode.value = options?.tenantCode || ''
+  memberCouponId.value = options?.memberCouponId || ''
+
+  if (m === 'mine' && memberCouponId.value) {
+    loadDetail()
+  } else if (m === 'available') {
+    // 通过 eventChannel 接收可领券数据
+    const pages = getCurrentPages()
+    const currentPage = pages[pages.length - 1] as any
+    const eventChannel = currentPage?.getOpenerEventChannel?.()
+    if (eventChannel) {
+      eventChannel.on('couponData', (data: { item: MarketingCouponItem }) => {
+        availableCoupon.value = data.item
+        loading.value = false
+      })
+    } else {
+      loading.value = false
+    }
+  } else {
+    loading.value = false
+  }
+})
+
+async function loadDetail() {
+  loading.value = true
+  try {
+    const res = await fetchCouponDetail(memberCouponId.value, tenantCode.value)
+    mineCoupon.value = res.data ?? null
+  } catch (e) {
+    uni.showToast({ title: (e as Error).message || '加载失败', icon: 'none' })
+  } finally {
+    loading.value = false
+  }
+}
+
+function formatDate(value: string) {
+  return value.length >= 10 ? value.slice(0, 10) : value
+}
+
+// ---- 显示计算 ----
+
+const isDimmed = computed(() => {
+  if (mode.value === 'mine' && mineCoupon.value) {
+    const status = mineCoupon.value.status
+    return status === 1 || status === 2 || status === 3 || status === 4
+  }
+  return false
+})
+
+const displayName = computed(() => {
+  const c = coupon.value
+  return (c?.name || c?.couponName || '优惠券') as string
+})
+
+const displayType = computed(() => {
+  const c = coupon.value
+  return (c?.couponTypeName || c?.couponType || '') as string
+})
+
+const displayThreshold = computed(() => {
+  const c = coupon.value
+  if (c?.threshold) return c.threshold as string
+  const min = c?.minConsumeAmount ?? c?.minOrderAmount
+  if (min != null && Number(min) > 0) return `满${min}元可用`
+  return ''
+})
+
+const displayStatus = computed(() => {
+  if (mode.value === 'mine' && mineCoupon.value) {
+    return mineCoupon.value.statusName || ''
+  }
+  if (mode.value === 'available' && availableCoupon.value) {
+    return (availableCoupon.value.status || '可领取') as string
+  }
+  return ''
+})
+
+const statusClass = computed(() => {
+  const s = displayStatus.value
+  if (s === '已使用' || s === '已过期' || s === '已作废' || s === '已冻结') return 'detail-hero__status--muted'
+  return 'detail-hero__status--active'
+})
+
+const heroAmount = computed(() => {
+  const c = coupon.value
+  if (c?.face) return c.face as string
+  const amount = c?.discountValue ?? c?.discountAmount
+  return amount != null ? String(amount) : '惠'
+})
+
+const heroUnit = computed(() => {
+  const c = coupon.value
+  if (c?.face) return ''
+  return '元'
+})
+
+const displayDiscountType = computed(() => {
+  const c = coupon.value
+  return (c?.discountTypeName || '') as string
+})
+
+const displayDiscountValue = computed(() => {
+  const c = coupon.value
+  const v = c?.discountValue
+  if (v == null) return '-'
+  const dt = c?.discountType
+  if (dt === 2) return `${v}折`
+  return `¥${v}`
+})
+
+const displayMaxDiscount = computed(() => {
+  const c = coupon.value
+  const v = c?.maxDiscountAmount
+  if (v == null || Number(v) <= 0) return ''
+  return String(v)
+})
+
+const displayValidPeriod = computed(() => {
+  const c = coupon.value
+  if (mode.value === 'available') {
+    const from = c?.validFrom ? String(c.validFrom).slice(0, 10) : ''
+    const to = c?.validTo ? String(c.validTo).slice(0, 10) : ''
+    if (from && to) return `${from} ~ ${to}`
+    if (to) return `至 ${to}`
+    return ''
+  }
+  const start = c?.validStartDate ? formatDate(String(c.validStartDate)) : ''
+  const end = c?.validEndDate ? formatDate(String(c.validEndDate)) : ''
+  if (start && end) return `${start} ~ ${end}`
+  if (end) return `至 ${end}`
+  return ''
+})
+
+const displayIssuePeriod = computed(() => {
+  return (coupon.value?.issuePeriod || '') as string
+})
+
+const SCENE_CODE_MAP: Record<string, string> = {
+  MALL: '商城',
+  HOTEL: '酒店',
+  FOOD: '美食',
+  SCENIC: '门票',
+  STUDY: '研学',
+  YEARCARD: '年卡',
+  FOOD_MEAL: '个人订餐',
+  STORE_OFFLINE: '店铺线下到店'
+}
+
+function resolveSceneName(code?: string, name?: string) {
+  if (name) return name
+  if (!code) return ''
+  return SCENE_CODE_MAP[String(code).trim().toUpperCase()] || code
+}
+
+const displaySceneName = computed(() => {
+  return resolveSceneName(coupon.value?.sceneCode as string, coupon.value?.sceneName as string)
+})
+
+const displayScopeName = computed(() => {
+  return (coupon.value?.useScopeName || '') as string
+})
+
+const displayScopeLabels = computed(() => {
+  const c = coupon.value
+  return (c?.scope as string[]) || []
+})
+
+const displayStackRule = computed(() => {
+  return (coupon.value?.stackRule || '') as string
+})
+
+const displayRules = computed(() => {
+  const c = coupon.value
+  return (c?.rules as string[]) || []
+})
+
+const displayVerifyTip = computed(() => {
+  return (coupon.value?.verifyTip || '') as string
+})
+
+// ---- 底部操作栏 ----
+
+const showBottomBar = computed(() => {
+  if (loading.value || !coupon.value) return false
+  if (mode.value === 'mine') return canShowVerify.value
+  return canReceive.value
+})
+
+const canShowVerify = computed(() => {
+  return mode.value === 'mine' && mineCoupon.value?.status === 0
+})
+
+const canReceive = computed(() => {
+  if (mode.value !== 'available') return false
+  const status = availableCoupon.value?.status
+  return !status || status === '可领取'
+})
+
+function goVerifyCode() {
+  if (!memberCouponId.value || !tenantCode.value) return
+  uni.navigateTo({
+    url: `/package-views/pages/marketing/verify-code?memberCouponId=${encodeURIComponent(memberCouponId.value)}&tenantCode=${encodeURIComponent(tenantCode.value)}`
+  })
+}
+
+async function handleReceive() {
+  if (!isLoggedIn()) {
+    requireLoginNavigate('/pages/home/index')
+    return
+  }
+  if (!tenantCode.value || !availableCoupon.value) return
+  const templateId = String(
+    availableCoupon.value.templateId || availableCoupon.value.couponId || availableCoupon.value.id || ''
+  )
+  if (!templateId) return
+  receiving.value = true
+  try {
+    await receiveMarketingCoupon({ tenantCode: tenantCode.value, templateId })
+    uni.showToast({ title: '领取成功', icon: 'none' })
+    setTimeout(() => uni.navigateBack(), 1200)
+  } catch (e) {
+    uni.showToast({ title: (e as Error).message || '领取失败', icon: 'none' })
+  } finally {
+    receiving.value = false
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.coupon-detail {
+  padding: 24rpx 24rpx 160rpx;
+  min-height: 100vh;
+}
+
+.detail-hero {
+  display: flex;
+  align-items: stretch;
+  background: #fff;
+  border-radius: 28rpx;
+  overflow: hidden;
+  box-shadow: 0 12rpx 40rpx rgba(255, 107, 53, 0.12);
+  margin-bottom: 24rpx;
+
+  &--dimmed {
+    .detail-hero__left {
+      background: linear-gradient(160deg, #c4b5a5, #a89888);
+    }
+    .detail-hero__name {
+      color: #8c7b6a;
+    }
+  }
+}
+
+.detail-hero__left {
+  width: 200rpx;
+  background: linear-gradient(160deg, #ff9a3c, #ff5722);
+  color: #fff;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 32rpx 16rpx;
+}
+
+.detail-hero__amount {
+  font-size: 52rpx;
+  font-weight: 800;
+  line-height: 1.1;
+}
+
+.detail-hero__unit {
+  margin-top: 6rpx;
+  font-size: 24rpx;
+  opacity: 0.92;
+}
+
+.detail-hero__body {
+  flex: 1;
+  padding: 28rpx 24rpx;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  min-width: 0;
+}
+
+.detail-hero__name-row {
+  display: flex;
+  align-items: center;
+  gap: 10rpx;
+}
+
+.detail-hero__name {
+  font-size: 34rpx;
+  font-weight: 700;
+  color: #2c1810;
+}
+
+.detail-hero__type {
+  flex-shrink: 0;
+  padding: 2rpx 12rpx;
+  border-radius: 6rpx;
+  background: #fff3e6;
+  color: #ff7a1a;
+  font-size: 20rpx;
+  font-weight: 600;
+}
+
+.detail-hero__threshold {
+  margin-top: 10rpx;
+  font-size: 26rpx;
+  color: #8c7b6a;
+}
+
+.detail-hero__status {
+  display: inline-block;
+  margin-top: 14rpx;
+  padding: 6rpx 18rpx;
+  border-radius: 999rpx;
+  font-size: 22rpx;
+  font-weight: 600;
+  align-self: flex-start;
+
+  &--active {
+    background: #e8f8ef;
+    color: #1f9d55;
+  }
+
+  &--muted {
+    background: #f5f0ea;
+    color: #a89888;
+  }
+}
+
+.detail-section {
+  margin-bottom: 24rpx;
+}
+
+.detail-list {
+  margin-top: 16rpx;
+}
+
+.detail-row {
+  display: flex;
+  align-items: flex-start;
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid rgba(0, 0, 0, 0.04);
+
+  &:last-child {
+    border-bottom: none;
+  }
+}
+
+.detail-row__label {
+  width: 160rpx;
+  flex-shrink: 0;
+  font-size: 26rpx;
+  color: #8c7b6a;
+}
+
+.detail-row__value {
+  flex: 1;
+  font-size: 26rpx;
+  color: #2c1810;
+  word-break: break-all;
+}
+
+.detail-row__tags {
+  flex: 1;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8rpx;
+}
+
+.detail-scope-tag {
+  padding: 4rpx 14rpx;
+  border-radius: 8rpx;
+  background: #fff3e6;
+  color: #ff7a1a;
+  font-size: 22rpx;
+}
+
+.detail-rules {
+  margin-top: 16rpx;
+}
+
+.detail-rule-item {
+  display: flex;
+  align-items: flex-start;
+  gap: 8rpx;
+  padding: 6rpx 0;
+}
+
+.detail-rule-dot {
+  color: #ff7a1a;
+  font-weight: 700;
+  line-height: 1.6;
+}
+
+.detail-rule-text {
+  flex: 1;
+  font-size: 24rpx;
+  color: #8c7b6a;
+  line-height: 1.6;
+}
+
+.detail-verify-tip {
+  display: block;
+  margin-top: 14rpx;
+  font-size: 24rpx;
+  color: #8c7b6a;
+  line-height: 1.6;
+}
+
+.empty-card {
+  margin-bottom: 24rpx;
+  text-align: center;
+}
+
+.detail-bottom {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
+  background: rgba(255, 255, 255, 0.95);
+  backdrop-filter: blur(12px);
+  box-shadow: 0 -8rpx 24rpx rgba(44, 24, 16, 0.06);
+}
+
+.detail-bottom__btn {
+  width: 100%;
+}
+</style>

+ 212 - 0
src/package-views/pages/profile/complete-info.vue

@@ -0,0 +1,212 @@
+<template>
+  <view class="page-gradient complete-page">
+    <view class="hero">
+      <view class="title">完善个人信息</view>
+      <view class="subtitle">请补充姓名和手机号,以便享受完整会员权益</view>
+    </view>
+
+    <view class="card form-box">
+      <view class="form-item">
+        <text class="form-label">姓名</text>
+        <input
+          v-model="form.realName"
+          class="form-input"
+          type="text"
+          placeholder="请输入真实姓名"
+          maxlength="20"
+        />
+      </view>
+      <view class="form-item">
+        <text class="form-label">手机号</text>
+        <input
+          v-model="form.mobile"
+          class="form-input"
+          type="number"
+          placeholder="请输入手机号"
+          maxlength="11"
+        />
+        <!-- #ifdef MP-WEIXIN -->
+        <button class="wx-phone-btn" open-type="getPhoneNumber" @getphonenumber="onGetPhoneNumber">
+          微信获取
+        </button>
+        <!-- #endif -->
+      </view>
+    </view>
+
+    <button class="submit-btn" :loading="submitting" @click="submit">保存</button>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { onLoad } from '@dcloudio/uni-app'
+import { reactive, ref } from 'vue'
+import { completeProfile, getPhoneByCode } from '@/api/modules/member'
+import { useAuthStore } from '@/store/modules/auth'
+import { isTabBarPage } from '@/utils/auth'
+
+const auth = useAuthStore()
+const submitting = ref(false)
+const redirect = ref('')
+const form = reactive({
+  realName: '',
+  mobile: ''
+})
+
+onLoad(query => {
+  redirect.value = decodeURIComponent(String(query?.redirect || ''))
+  form.realName = auth.userInfo?.realName || ''
+  // 登录返回的手机号为脱敏值(含 *),不可直接提交,需用户重新获取/输入
+  const storedPhone = auth.userInfo?.phone || ''
+  form.mobile = storedPhone.includes('*') ? '' : storedPhone
+})
+
+async function onGetPhoneNumber(e: { detail?: { errMsg?: string; code?: string } }) {
+  const detail = e.detail || {}
+  if (detail.errMsg !== 'getPhoneNumber:ok') {
+    uni.showToast({ title: '已取消微信获取', icon: 'none' })
+    return
+  }
+  const phoneCode = detail.code
+  if (!phoneCode) {
+    uni.showToast({ title: '获取手机号失败,请手动输入', icon: 'none' })
+    return
+  }
+  uni.showLoading({ title: '获取中...', mask: true })
+  try {
+    const res = await getPhoneByCode({ code: phoneCode })
+    const mobile = (res.data || '').trim()
+    if (mobile) {
+      form.mobile = mobile
+    } else {
+      uni.showToast({ title: '未取到手机号,请手动输入', icon: 'none' })
+    }
+  } catch {
+    uni.showToast({ title: '获取手机号失败,请手动输入', icon: 'none' })
+  } finally {
+    uni.hideLoading()
+  }
+}
+
+async function submit() {
+  const realName = form.realName.trim()
+  const mobile = form.mobile.trim()
+  if (!realName) {
+    uni.showToast({ title: '请输入姓名', icon: 'none' })
+    return
+  }
+  if (!mobile || !/^1\d{10}$/.test(mobile)) {
+    uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
+    return
+  }
+  submitting.value = true
+  try {
+    await completeProfile({ realName, mobile })
+    // 同步本地存储
+    auth.setUser({
+      ...(auth.userInfo || {}),
+      realName,
+      phone: mobile
+    })
+    uni.showToast({ title: '完善成功', icon: 'none' })
+    setTimeout(goAfterComplete, 300)
+  } catch (e) {
+    uni.showToast({ title: (e as Error).message || '完善失败', icon: 'none' })
+  } finally {
+    submitting.value = false
+  }
+}
+
+function goAfterComplete() {
+  const fallbackUrl = '/pages/home/index'
+  const url = redirect.value || fallbackUrl
+  if (isTabBarPage(url)) {
+    uni.switchTab({ url, fail: () => uni.reLaunch({ url }) })
+    return
+  }
+  uni.redirectTo({
+    url,
+    fail: () => uni.switchTab({ url: fallbackUrl })
+  })
+}
+</script>
+
+<style scoped lang="scss">
+.complete-page {
+  min-height: 100vh;
+  padding: 24rpx;
+  box-sizing: border-box;
+  background: linear-gradient(180deg, #ffe8cc 0%, #fff9f3 45%, #ffffff 100%);
+}
+.hero {
+  padding: 72rpx 32rpx 28rpx;
+  text-align: center;
+}
+.title {
+  font-size: 40rpx;
+  font-weight: 800;
+  color: #2c1810;
+}
+.subtitle {
+  margin-top: 14rpx;
+  font-size: 26rpx;
+  color: #8c7b6a;
+}
+.form-box {
+  margin: 32rpx 24rpx 0;
+  padding: 16rpx 32rpx;
+}
+.form-item {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+  padding: 28rpx 0;
+  border-bottom: 1px solid #f5ebe0;
+  &:last-child {
+    border-bottom: none;
+  }
+}
+.form-label {
+  flex-shrink: 0;
+  width: 120rpx;
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #2c1810;
+}
+.form-input {
+  flex: 1;
+  min-width: 0;
+  font-size: 30rpx;
+  color: #333;
+}
+.wx-phone-btn {
+  flex-shrink: 0;
+  margin: 0;
+  padding: 0 24rpx;
+  height: 60rpx;
+  line-height: 60rpx;
+  font-size: 24rpx;
+  color: #ff6b35;
+  background: #fff7ed;
+  border: 1px solid rgba(255, 107, 53, 0.25);
+  border-radius: 999rpx;
+  &::after {
+    border: none;
+  }
+}
+.submit-btn {
+  margin: 48rpx 24rpx 0;
+  width: calc(100% - 48rpx);
+  height: 96rpx;
+  line-height: 96rpx;
+  border-radius: 999rpx;
+  background: linear-gradient(135deg, #ff9a3c, #ff6b35);
+  color: #fff;
+  font-size: 32rpx;
+  font-weight: 700;
+  border: none;
+  box-shadow: 0 14rpx 32rpx rgba(255, 107, 53, 0.25);
+  &::after {
+    border: none;
+  }
+}
+</style>

+ 14 - 0
src/pages.json

@@ -77,6 +77,13 @@
             "navigationBarBackgroundColor": "#FFE8CC"
           }
         },
+        {
+          "path": "pages/profile/complete-info",
+          "style": {
+            "navigationBarTitleText": "完善信息",
+            "navigationBarBackgroundColor": "#FFE8CC"
+          }
+        },
         {
           "path": "pages/marketing/verify-code",
           "style": {
@@ -84,6 +91,13 @@
             "navigationBarBackgroundColor": "#FFE8CC"
           }
         },
+        {
+          "path": "pages/marketing/coupon-detail",
+          "style": {
+            "navigationBarTitleText": "优惠券详情",
+            "navigationBarBackgroundColor": "#FFE8CC"
+          }
+        },
         {
           "path": "pages/marketing/clerk-home",
           "style": {

+ 88 - 6
src/pages/coupon/index.vue

@@ -112,6 +112,16 @@
 
         :dimmed="activeTab !== 'unused'"
 
+        :coupon-type="couponTypeText(item)"
+
+        :valid-period="validPeriodText(item)"
+
+        :sub-meta="subMetaText(item)"
+
+        clickable
+
+        @click="goDetail(item)"
+
       >
 
         <template v-if="canShowVerify(item)" #action>
@@ -284,19 +294,75 @@ function couponMeta(item: MarketingCouponItem) {
 
   const minAmount = item.minConsumeAmount ?? item.minOrderAmount
 
-  if (minAmount != null) parts.push(`满${minAmount}可用`)
+  if (minAmount != null && Number(minAmount) > 0) {
+    parts.push(`满${minAmount}可用`)
+  } else {
+    parts.push('无门槛')
+  }
+
+  if (item.maxDiscountAmount != null && Number(item.maxDiscountAmount) > 0) {
+    parts.push(`最多优惠¥${item.maxDiscountAmount}`)
+  }
+
+  return parts.join(' · ')
+
+}
+
+
+
+function couponTypeText(item: MarketingCouponItem) {
+
+  return String(item.couponTypeName || item.couponType || '')
+
+}
+
+
+
+function validPeriodText(item: MarketingCouponItem) {
+
+  const start = item.validStartDate ? formatDate(String(item.validStartDate)) : ''
+
+  const end = item.validEndDate ? formatDate(String(item.validEndDate)) : ''
+
+  if (start && end) return `${start} ~ ${end}`
+
+  if (end) return `有效期至 ${end}`
+
+  return ''
+
+}
+
+
+
+function subMetaText(item: MarketingCouponItem) {
+
+  const status = normalizeStatus(item)
+
+  if (status === 1 && item.useDate) {
+
+    const parts: string[] = [`使用于 ${formatDate(String(item.useDate))}`]
+
+    if (item.useAmount != null) parts.push(`抵扣¥${item.useAmount}`)
 
-  const endDate = item.validEndDate ?? item.expireDate
+    return parts.join(' · ')
 
-  if (endDate) parts.push(`有效期至 ${formatDate(String(endDate))}`)
+  }
 
-  if (activeTab.value === 'used' && item.useDate) {
+  if (item.getDate) {
 
-    parts.push(`使用时间 ${formatDate(String(item.useDate))}`)
+    return `领取于 ${formatDate(String(item.getDate))}`
 
   }
 
-  return parts.join(' · ') || '店铺线下券'
+  if (item.couponSn) {
+
+    const sn = String(item.couponSn)
+
+    return `券号 ${sn.length > 6 ? sn.slice(-6) : sn}`
+
+  }
+
+  return ''
 
 }
 
@@ -356,6 +422,22 @@ function goVerifyCode(item: MarketingCouponItem) {
 
 
 
+function goDetail(item: MarketingCouponItem) {
+
+  const memberCouponId = String(item.id || item.memberCouponId || '')
+
+  if (!memberCouponId || !tenantCode.value) return
+
+  uni.navigateTo({
+
+    url: `/package-views/pages/marketing/coupon-detail?mode=mine&memberCouponId=${encodeURIComponent(memberCouponId)}&tenantCode=${encodeURIComponent(tenantCode.value)}`
+
+  })
+
+}
+
+
+
 function goLogin() {
 
   requireLoginNavigate('/pages/coupon/index')

+ 44 - 8
src/pages/home/index.vue

@@ -49,17 +49,23 @@
       <MkCouponCard
         v-for="item in available"
         :key="String(item.id || item.couponId || item.templateId)"
-        :name="item.couponName || '优惠券'"
+        :name="item.name || item.couponName || '优惠券'"
         :meta="couponMeta(item)"
         :amount-text="amountText(item)"
         unit-text="元"
+        :coupon-type="couponTypeText(item)"
+        :valid-period="validPeriodText(item)"
+        :sub-meta="subMetaText(item)"
+        :tag="item.status || '可领取'"
+        clickable
+        @click="goDetail(item)"
       >
         <template #action>
           <button
             class="receive-btn"
             size="mini"
             :loading="receivingId === receiveKey(item)"
-            @click="handleReceive(item)"
+            @click.stop="handleReceive(item)"
           >
             领取
           </button>
@@ -81,7 +87,7 @@ import {
   type MarketingCouponItem
 } from '@/api/modules/marketing'
 import { useTenantStore } from '@/store/modules/tenant'
-import { isLoggedIn, requireLoginNavigate } from '@/utils/auth'
+import { isLoggedIn, requireLoginNavigate, requireCompleteProfile } from '@/utils/auth'
 import { scanStoreQrCode } from '@/utils/tenantScan'
 
 const tenantStore = useTenantStore()
@@ -97,17 +103,37 @@ const receivingId = ref('')
 const joined = computed(() => !!tenantCode.value && myTenantCodes.value.includes(tenantCode.value))
 
 function couponMeta(item: MarketingCouponItem) {
-  const parts: string[] = []
-  if (item.minOrderAmount != null) parts.push(`满${item.minOrderAmount}可用`)
-  if (item.expireDate) parts.push(`至 ${item.expireDate}`)
-  return parts.join(' · ') || '店铺线下券'
+  return item.threshold || '店铺线下券'
 }
 
 function amountText(item: MarketingCouponItem) {
+  if (item.face) return item.face
   if (item.discountAmount != null) return String(item.discountAmount)
+  if (item.discountValue != null) return String(item.discountValue)
   return '惠'
 }
 
+function couponTypeText(item: MarketingCouponItem) {
+  return String(item.couponType || '')
+}
+
+function validPeriodText(item: MarketingCouponItem) {
+  const from = item.validFrom ? String(item.validFrom).slice(0, 10) : ''
+  const to = item.validTo ? String(item.validTo).slice(0, 10) : ''
+  if (from && to) return `${from} ~ ${to}`
+  if (to) return `有效期至 ${to}`
+  return ''
+}
+
+function subMetaText(item: MarketingCouponItem) {
+  const parts: string[] = []
+  if (item.remainQuota != null && item.remainQuota !== -1) {
+    parts.push(`剩余 ${item.remainQuota} 张`)
+  }
+  if (item.verifyTip) parts.push(String(item.verifyTip))
+  return parts.join(' · ')
+}
+
 function receiveKey(item: MarketingCouponItem) {
   return String(item.templateId || item.couponId || item.id || '')
 }
@@ -182,7 +208,7 @@ async function handleJoin() {
 }
 
 async function handleReceive(item: MarketingCouponItem) {
-  if (!requireLoginNavigate('/pages/home/index')) return
+  if (!requireCompleteProfile('/pages/home/index')) return
   if (!joined.value) {
     uni.showToast({ title: '请先关注店铺', icon: 'none' })
     return
@@ -201,6 +227,16 @@ async function handleReceive(item: MarketingCouponItem) {
   }
 }
 
+function goDetail(item: MarketingCouponItem) {
+  if (!tenantCode.value) return
+  uni.navigateTo({
+    url: `/package-views/pages/marketing/coupon-detail?mode=available&tenantCode=${encodeURIComponent(tenantCode.value)}`,
+    success: (res) => {
+      res.eventChannel.emit('couponData', { item })
+    }
+  })
+}
+
 onShow(() => {
   tenantStore.initTenant()
   refresh()

+ 14 - 2
src/pages/profile/index.vue

@@ -49,6 +49,13 @@
     </view>
 
     <view class="card menu-card">
+      <view v-if="loggedIn && !profileComplete" class="menu-item" @click="goCompleteInfo">
+        <view class="menu-item__left">
+          <text class="menu-item__icon">人</text>
+          <text>完善信息</text>
+        </view>
+        <text class="arrow muted">未完善</text>
+      </view>
       <view v-if="hasClerkAccess" class="menu-item" @click="goClerk">
         <view class="menu-item__left">
           <text class="menu-item__icon clerk">✓</text>
@@ -100,7 +107,7 @@ import { fetchMemberCodeCard } from '@/api/modules/member'
 import { fetchMyClerkTenants, fetchMyMarketingTenants, type MarketingTenantBrief } from '@/api/modules/marketing'
 import { useAuthStore } from '@/store/modules/auth'
 import { useTenantStore } from '@/store/modules/tenant'
-import { isLoggedIn, requireLoginNavigate } from '@/utils/auth'
+import { isLoggedIn, requireLoginNavigate, isProfileComplete } from '@/utils/auth'
 import { scanStoreQrCode } from '@/utils/tenantScan'
 
 const auth = useAuthStore()
@@ -119,7 +126,8 @@ const qrSize = 160
 const hasClerkAccess = computed(() => clerkTenants.value.length > 0)
 
 const loggedIn = computed(() => auth.loggedIn)
-const userName = computed(() => auth.userInfo?.nickname || '游客')
+const profileComplete = computed(() => isProfileComplete())
+const userName = computed(() => auth.userInfo?.realName || auth.userInfo?.nickname || '游客')
 const userPhone = computed(() => auth.userInfo?.phone || '')
 const tenantCode = computed(() => tenantStore.tenantCode)
 const avatarText = computed(() => (userName.value || '游').slice(0, 1))
@@ -152,6 +160,10 @@ function goClerk() {
   uni.navigateTo({ url: '/package-views/pages/marketing/clerk-home' })
 }
 
+function goCompleteInfo() {
+  uni.navigateTo({ url: '/package-views/pages/profile/complete-info' })
+}
+
 function switchTenant(item: MarketingTenantBrief) {
   tenantStore.setTenant({
     tenantCode: item.tenantCode,

+ 43 - 1
src/utils/auth.ts

@@ -1,6 +1,6 @@
 import { getAccessToken } from './tokenStorage'
 import { useAuthStore } from '@/store/modules/auth'
-import { ROUTE_LOGIN_INDEX } from '@/constants/routes'
+import { ROUTE_LOGIN_INDEX, ROUTE_COMPLETE_INFO } from '@/constants/routes'
 
 const TAB_BAR_PAGES = ['/pages/home/index', '/pages/scan/index', '/pages/coupon/index', '/pages/profile/index']
 
@@ -11,6 +11,13 @@ const DEFAULT_LOGIN_PROMPT = {
   cancelText: '稍后再说'
 }
 
+const DEFAULT_PROFILE_PROMPT = {
+  title: '需要完善信息',
+  content: '请先完善姓名和手机号后再使用该功能',
+  confirmText: '去完善',
+  cancelText: '稍后'
+}
+
 export function getToken() {
   return getAccessToken()
 }
@@ -34,6 +41,14 @@ function goLogin(redirect?: string) {
   })
 }
 
+function goCompleteInfo(redirect?: string) {
+  const target = `${ROUTE_COMPLETE_INFO}${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`
+  uni.navigateTo({
+    url: target,
+    fail: () => uni.reLaunch({ url: target })
+  })
+}
+
 export function requireLogin() {
   if (isLoggedIn()) return true
   uni.showModal({
@@ -50,3 +65,30 @@ export function requireLoginNavigate(redirect?: string) {
   goLogin(redirect)
   return false
 }
+
+/**
+ * 是否已完善个人信息(姓名 + 手机号)。
+ */
+export function isProfileComplete() {
+  const auth = useAuthStore()
+  if (!auth.userInfo) return false
+  const realName = (auth.userInfo.realName || '').trim()
+  const phone = (auth.userInfo.phone || '').trim()
+  return realName.length > 0 && phone.length > 0
+}
+
+/**
+ * 校验登录态 + 信息完善:未登录提示登录,登录但未完善提示完善。
+ * 返回 true 表示已登录且信息已完善,可继续业务。
+ */
+export function requireCompleteProfile(redirect?: string) {
+  if (!requireLoginNavigate(redirect)) return false
+  if (isProfileComplete()) return true
+  uni.showModal({
+    ...DEFAULT_PROFILE_PROMPT,
+    success: res => {
+      if (res.confirm) goCompleteInfo(redirect)
+    }
+  })
+  return false
+}

+ 1 - 0
src/utils/tokenStorage.ts

@@ -5,6 +5,7 @@ const USER_INFO_KEY = 'marketing_user_info'
 export type StoredUserInfo = {
   id?: string
   nickname?: string
+  realName?: string
   loginName?: string
   memberNo?: string
   avatar?: string