shinnosuke
2026-09-16 c5a63165aca6468157e0b4807d511e828de2076a
再次更新
2个文件已添加
37个文件已修改
1155 ■■■■■ 已修改文件
rencaika/pages/admin-approval/admin-approval.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/benefit-search/benefit-search.uvue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/benefits/benefits.uvue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/children-entrustment/children-entrustment.uvue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/cinema-merchant/cinema-merchant.uvue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/family-card-unavailable/family-card-unavailable.uvue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/finance-merchant/finance-merchant.uvue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/food-benefits/food-benefits.uvue 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/merchant/merchant.uvue 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/news-detail/news-detail.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/plate-info/plate-info.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/premium-benefits/premium-benefits.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/shopping-benefits/shopping-benefits.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/shopping-merchant/shopping-merchant.uvue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/spouse-employment/spouse-employment.uvue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/stay-benefits/stay-benefits.uvue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/transport-benefits/transport-benefits.uvue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/travel-benefits/travel-benefits.uvue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/merchant-assets/car-wash-service-orange.svg 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/utils/talent-id.uts 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/consume-input/consume-input.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/style.css 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/BusinessManagementView.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantView.vue 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MovieTicketView.vue 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentCreateView.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/ManualConsumptionService.java 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/FinanceMerchantPortalService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/FuelMerchantPortalService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/ParkingMerchantPortalService.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/ShoppingMerchantPortalService.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/SpouseEmploymentPortalService.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/ManualConsumptionServiceTests.java 221 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationServiceTests.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/portal/ParkingMerchantPortalServiceTests.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/admin-approval/admin-approval.uvue
@@ -11,7 +11,7 @@
        </view>
        <view class="content-sheet">
            <view class="service-row"><text class="service-name">{{benefitTitle}}</text><text class="free-label">{{freeLabel}}</text></view>
            <view class="service-row"><text class="service-name">{{benefitTitle}}</text></view>
            <view class="section-title"><text>权益详情</text><view></view></view>
            <scroll-view class="org-scroll" scroll-y :show-scrollbar="false">
                <view v-if="listLoading" class="list-state"><view class="loading-spinner"></view><text>正在加载服务机构</text></view>
@@ -46,7 +46,6 @@
    const serviceId = ref('')
    const heroImage = ref('')
    const benefitTitle = ref('行政审批')
    const freeLabel = ref('免费')
    const benefitAvailable = ref(true)
    const unavailableReason = ref('该权益暂未开放')
    const orgs = ref<any[]>([])
@@ -99,7 +98,6 @@
        if (serviceId.value == '') { listLoading.value = false; listError.value = true; return }
        getAdminApprovalBenefit(serviceId.value, (data: any) => {
            benefitTitle.value = data.title || '行政审批'
            freeLabel.value = data.freeLabel || '免费'
            heroImage.value = data.heroImage != null && data.heroImage != '' ? resolveApiUrl(data.heroImage as string) : ''
            benefitAvailable.value = data.available === true
            unavailableReason.value = data.unavailableReason || '该权益暂未开放'
rencaika/pages/benefit-search/benefit-search.uvue
@@ -59,7 +59,7 @@
    import { apiEnvironment, isApiConfigured } from '../../config/api.uts'
    import { searchMerchants } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { resolveTalentId } from '../../utils/talent-id.uts'
    import { safeBack } from '../../utils/navigation.uts'
    import { openMerchantDetail } from '../../utils/merchant-navigation.uts'
@@ -118,7 +118,7 @@
        for (let index = 0; index < records.length; index++) {
            const item: any = records[index]
            const rawType = item.merchantType != null ? item.merchantType as string : 'food'
            const hasConfiguredTags = item.benefitTags instanceof Array && item.benefitTags.length > 0
            const hasConfiguredTags = item.benefitTags instanceof Array
            const configuredTags: any[] = hasConfiguredTags ? item.benefitTags as any[] : []
            const tags: string[] = []
            for (let tagIndex = 0; tagIndex < configuredTags.length; tagIndex++) {
@@ -201,8 +201,7 @@
            failLoadMoreOnce.value = previewState == 'loadMoreError'
        }
        loadHistories()
        const session = getSession()
        if (session != null && session.talentId != null) talentId.value = '' + session.talentId
        talentId.value = resolveTalentId()
        if (options != null && options['mode'] == 'all') {
            isAllMode.value = true
            if (isApiConfigured()) loadRemoteMerchants(false)
rencaika/pages/benefits/benefits.uvue
@@ -68,7 +68,7 @@
    import { isApiConfigured } from '../../config/api.uts'
    import { getMerchants } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { resolveTalentId } from '../../utils/talent-id.uts'
    import { openMerchantDetail } from '../../utils/merchant-navigation.uts'
    const navTop = ref(28)
@@ -94,8 +94,7 @@
            failInitialOnce.value = previewState == 'initialError'
            failLoadMoreOnce.value = previewState == 'loadMoreError'
        }
        const session = getSession()
        if (session != null && session.talentId != null) talentId.value = '' + session.talentId
        talentId.value = resolveTalentId()
        loadInitialBenefits()
    })
rencaika/pages/children-entrustment/children-entrustment.uvue
@@ -54,9 +54,8 @@
<script setup lang="uts">
    import { getChildrenEntrustmentBenefit, getChildrenEntrustmentSchools } from '../../services/portal.uts'
    import { apiEnvironment } from '../../config/api.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { resolveTalentId } from '../../utils/talent-id.uts'
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28)
@@ -87,9 +86,7 @@
        navRight.value = info.windowWidth - capsule.right
        // #endif
        serviceId.value = options != null && options['serviceId'] != null ? options['serviceId'] as string : ''
        const session = getSession()
        if (session != null && session.talentId != null) talentId.value = session.talentId as string
        if (talentId.value == '' && apiEnvironment == 'development') talentId.value = '2'
        talentId.value = resolveTalentId()
        loadBenefit()
        loadSchools()
    })
@@ -122,7 +119,7 @@
    const canNavigate = (item: any): boolean => Number(item.latitude) > 0 && Number(item.longitude) > 0
    const openLocation = (item: any) => { if (!canNavigate(item)) { uni.showToast({ title: '学校暂未配置位置', icon: 'none' }); return }; uni.openLocation({ latitude: Number(item.latitude), longitude: Number(item.longitude), name: item.name, address: item.address, scale: 18, fail: () => uni.showToast({ title: '暂时无法打开地图导航', icon: 'none' }) }) }
    const callSchool = (item: any) => { if (item.phone == null || item.phone == '') return; uni.makePhoneCall({ phoneNumber: item.phone, fail: () => uni.showToast({ title: '暂时无法拨打电话', icon: 'none' }) }) }
    const showTalentCode = () => { const session = getSession(); const talentId = session != null && session.talentId != null ? session.talentId as string : (apiEnvironment == 'development' ? '2' : ''); uni.navigateTo({ url: '/pages/talent-code/talent-code?talentId=' + encodeURIComponent(talentId) + '&merchantId=' + encodeURIComponent(serviceId.value) + '&scene=children' }) }
    const showTalentCode = () => { const talentId = resolveTalentId(); uni.navigateTo({ url: '/pages/talent-code/talent-code?talentId=' + encodeURIComponent(talentId) + '&merchantId=' + encodeURIComponent(serviceId.value) + '&scene=children' }) }
    const goChildrenInfo = () => uni.navigateTo({ url: '/pages/children-info/children-info' })
</script>
rencaika/pages/cinema-merchant/cinema-merchant.uvue
@@ -7,7 +7,7 @@
                <view v-if="detailLoading" class="detail-state"><view class="loading-spinner"></view><text>正在加载影城信息</text></view>
                <view v-else-if="detailError" class="detail-state error-state"><text>影城信息加载失败</text><text class="retry-text" @tap="loadCinema">重新加载</text></view>
                <view v-else class="merchant-detail">
                    <view class="merchant-info"><view class="title-row"><text class="merchant-name">{{cinemaName}}</text><view class="coupon-badge"><text>券</text></view></view><view class="service-tag"><text>{{talentCardTag}}</text></view><view class="address-row"><image class="address-icon" src="/static/benefits-assets/map-pin.svg" mode="aspectFit"></image><text class="address-text">{{cinemaAddress}}</text><view class="merchant-actions"><view class="action-button" @tap="openMap"><image src="/static/merchant-assets/navigation.svg" mode="aspectFit"></image></view><view v-if="cinemaMobile != ''" class="action-button" @tap="callCinema"><image src="/static/merchant-assets/phone.svg" mode="aspectFit"></image></view></view></view></view>
                    <view class="merchant-info"><view class="title-row"><text class="merchant-name">{{cinemaName}}</text><view class="coupon-badge"><text>券</text></view></view><view v-if="talentCardTag != ''" class="service-tag"><text>{{talentCardTag}}</text></view><view class="address-row"><image class="address-icon" src="/static/benefits-assets/map-pin.svg" mode="aspectFit"></image><text class="address-text">{{cinemaAddress}}</text><view class="merchant-actions"><view class="action-button" @tap="openMap"><image src="/static/merchant-assets/navigation.svg" mode="aspectFit"></image></view><view v-if="cinemaMobile != ''" class="action-button" @tap="callCinema"><image src="/static/merchant-assets/phone.svg" mode="aspectFit"></image></view></view></view></view>
                    <view class="tabs"><view class="tab-item" :class="currentTab == 'purchase' ? 'active' : ''" @tap="changeTab('purchase')"><text>消费购买</text><view></view></view><view class="tab-item" :class="currentTab == 'rights' ? 'active' : ''" @tap="changeTab('rights')"><text>权益详情</text><view></view></view></view>
                    <view v-if="currentTab == 'purchase'" class="coupon-list"><view v-if="listLoading" class="list-state"><view class="loading-spinner"></view><text>正在加载可领电影券</text></view><view v-else-if="listError" class="list-state error-state"><text>电影券加载失败</text><text class="retry-text" @tap="loadCoupons">重新加载</text></view><view v-else-if="listLoaded && coupons.length == 0" class="list-state"><text>暂无可领取电影券</text></view><view class="coupon-item" v-for="item in coupons" :key="item.id"><image class="coupon-image" :src="item.image" mode="aspectFill" @error="replaceCouponImage(item)"></image><view class="coupon-info"><text class="coupon-name">{{item.name}}</text><view class="stock-row"><text>剩余可领</text><text>{{item.remainingCount}}</text><text>张</text></view><view class="coupon-bottom"><view class="coupon-price"><text>¥</text><text>{{item.price}}</text></view><view class="claim-button" :class="item.claimed ? 'claimed' : (item.soldOut ? 'sold-out' : '')" @tap="requestClaimCoupon(item)"><text>{{item.buttonText}}</text></view></view></view></view></view>
                    <view v-else class="rights-panel"><text class="rights-description">{{rightsDescription}}</text></view>
@@ -20,17 +20,17 @@
</template>
<script setup lang="uts">
    import { apiEnvironment, isApiConfigured } from '../../config/api.uts'
    import { isApiConfigured } from '../../config/api.uts'
    import { getCinemaMerchant, getTalentCardProfile, getTicketPackages, claimTicketPackage } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { resolveTalentId } from '../../utils/talent-id.uts'
    import { safeBack } from '../../utils/navigation.uts'
    const fallbackImage = ''
    const navTop = ref(28); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10)
    const isSolidNav = ref(false); const currentTab = ref('purchase'); const merchantId = ref(''); const talentId = ref(''); const cardId = ref('')
    const cinemaName = ref('影城服务'); const cinemaAddress = ref(''); const cinemaHero = ref(fallbackImage); const cinemaMobile = ref(''); const cinemaLatitude = ref(0); const cinemaLongitude = ref(0)
    const talentCardTag = ref('人才卡专享'); const rightsTitle = ref('人才观影专属权益'); const rightsDescription = ref('')
    const talentCardTag = ref(''); const rightsTitle = ref('人才观影专属权益'); const rightsDescription = ref('')
    const detailLoading = ref(true); const detailError = ref(false); const quotaRemaining = ref(0); const coupons = ref<any[]>([]); const listLoading = ref(false); const listLoaded = ref(false); const listError = ref(false)
    const showClaimDialog = ref(false); const isSubmitting = ref(false); const pendingCouponId = ref(''); const pendingCouponStock = ref(0)
@@ -40,8 +40,7 @@
        // #endif
        if (options != null && options['merchantId'] != null) merchantId.value = options['merchantId'] as string
        if (options != null && options['name'] != null) cinemaName.value = decodeURIComponent(options['name'] as string)
        const session = getSession(); if (session != null && session.talentId != null) talentId.value = session.talentId as string
        if (talentId.value == '' && apiEnvironment == 'development') talentId.value = '2'
        talentId.value = resolveTalentId()
        if (talentId.value != '') {
            getTalentCardProfile(talentId.value, (data: any) => {
                const card = data != null ? data.card : null
@@ -63,7 +62,9 @@
            cinemaHero.value = data.heroImage != null && data.heroImage != '' ? resolveApiUrl(data.heroImage as string) : fallbackImage
            cinemaMobile.value = data.mobile != null ? data.mobile as string : ''
            cinemaLatitude.value = data.latitude != null ? Number(data.latitude) : 0; cinemaLongitude.value = data.longitude != null ? Number(data.longitude) : 0
            talentCardTag.value = data.talentCardTag != null ? data.talentCardTag as string : talentCardTag.value; rightsTitle.value = data.rightsTitle != null ? data.rightsTitle as string : rightsTitle.value; rightsDescription.value = data.rightsDescription != null ? data.rightsDescription as string : '以商户说明为准'
            talentCardTag.value = data.talentCardTag != null ? String(data.talentCardTag) : ''
            rightsTitle.value = data.rightsTitle != null ? data.rightsTitle as string : rightsTitle.value
            rightsDescription.value = data.rightsDescription != null ? data.rightsDescription as string : '以商户说明为准'
            loadCoupons(); loadQuota()
        }, () => { detailLoading.value = false; detailError.value = true })
    }
rencaika/pages/family-card-unavailable/family-card-unavailable.uvue
@@ -9,7 +9,7 @@
            </view>
        </view>
        <view class="content">
        <view class="content" :style="'padding-bottom:' + (navTop + navHeight) + 'px'">
            <view v-if="accessLoading" class="state-block"><view class="loading-spinner"></view><text>正在加载家属卡服务状态</text></view>
            <view v-else-if="accessError" class="state-block error-state"><text>家属卡服务状态加载失败</text><text class="retry-text" @tap="loadAccess">重新加载</text></view>
            <view v-else class="notice-block">
@@ -81,9 +81,9 @@
    .back-button-active { opacity: .55; }
    .nav-title { position: absolute; left: 50%; top: 50%; color: #171B22; font-family: sans-serif; font-size: 37rpx; line-height: 48rpx; font-weight: 700; transform: translate(-50%, -50%); white-space: nowrap; }
    .capsule-space { width: 88px; height: 100%; flex-shrink: 0; }
    .content { display: flex; min-height: 0; flex: 1; justify-content: center; align-items: flex-start; }
    .notice-block { display: flex; width: 100%; box-sizing: border-box; padding: 118rpx 48rpx 0; flex-direction: column; align-items: center; }
    .state-block { display: flex; margin-top: 118rpx; color: #7C8797; font-size: 26rpx; flex-direction: row; align-items: center; }
    .content { display: flex; min-height: 0; flex: 1; box-sizing: border-box; flex-direction: column; justify-content: center; align-items: center; }
    .notice-block { display: flex; width: 100%; box-sizing: border-box; padding: 0 48rpx; flex-direction: column; align-items: center; }
    .state-block { display: flex; color: #7C8797; font-size: 26rpx; flex-direction: row; align-items: center; }
    .error-state { flex-direction: column; }
    .retry-text { margin-top: 22rpx; color: #F26A38; }
    .loading-spinner { width: 30rpx; height: 30rpx; margin-right: 13rpx; box-sizing: border-box; border: 3rpx solid #FFD6C4; border-top-color: #FF6C2B; border-radius: 50%; animation: access-loading 720ms linear infinite; }
rencaika/pages/finance-merchant/finance-merchant.uvue
@@ -15,8 +15,8 @@
            <view v-else-if="detailError" class="detail-state error-state"><text>金融服务加载失败</text><text class="retry-text" @tap="loadDetail">重新加载</text></view>
            <view v-else class="detail-content">
            <view class="merchant-block">
                <view class="title-row"><text class="merchant-name">{{merchantName}}</text><text class="discount-label">{{discount}}</text></view>
                <view class="talent-tag"><text>{{applicableCards}}</text></view>
                <view class="title-row"><text class="merchant-name">{{merchantName}}</text></view>
                <view v-if="applicableCards != ''" class="talent-tag"><text>{{applicableCards}}</text></view>
                <view class="address-row">
                    <image class="location-icon" src="/static/benefits-assets/map-pin.svg" mode="aspectFit"></image>
                    <text class="merchant-address">{{address}}</text>
@@ -45,9 +45,8 @@
<script setup lang="uts">
    import { getFinanceMerchantDetail } from '../../services/portal.uts'
    import { apiEnvironment } from '../../config/api.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { resolveTalentId } from '../../utils/talent-id.uts'
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28)
@@ -57,7 +56,6 @@
    const merchantId = ref('')
    const talentId = ref('')
    const merchantName = ref('')
    const discount = ref('')
    const applicableCards = ref('')
    const address = ref('')
    const heroImage = ref('')
@@ -84,7 +82,7 @@
        navRight.value = info.windowWidth - capsule.right
        // #endif
        merchantId.value = options != null && options['merchantId'] != null ? options['merchantId'] as string : (options != null && options['serviceId'] != null ? options['serviceId'] as string : '')
        const session = getSession(); if (session != null && session.talentId != null) talentId.value = session.talentId as string; if (talentId.value == '' && apiEnvironment == 'development') talentId.value = '2'
        talentId.value = resolveTalentId()
        loadDetail()
    })
    const loadDetail = () => {
@@ -93,8 +91,7 @@
        detailError.value = false
        getFinanceMerchantDetail(merchantId.value, talentId.value, (data: any) => {
            merchantName.value = data.merchantName || ''
            discount.value = data.benefitSummary || '请咨询服务专员'
            applicableCards.value = data.applicableCards || '请咨询服务专员'
            applicableCards.value = data.applicableCards != null ? String(data.applicableCards) : ''
            address.value = data.address || '暂未配置机构地址'
            heroImage.value = data.coverUrl != null && data.coverUrl != '' ? resolveApiUrl(data.coverUrl as string) : ''
            benefitDetail.value = data.benefitDetail || '以服务专员说明为准'
rencaika/pages/food-benefits/food-benefits.uvue
@@ -40,7 +40,8 @@
                        <image class="merchant-image" :src="item.image" mode="aspectFill" @error="replaceMerchantImage(item)"></image>
                        <view class="merchant-info">
                            <text class="merchant-name">{{item.name}}</text>
                            <view v-if="!item.available || item.hasCoupon || item.discount != ''" class="tag-row"><text v-if="item.hasCoupon" class="coupon-tag">券</text><text v-if="!item.available || item.discount != ''" class="discount-tag">{{item.available ? item.discount : '已下架'}}</text></view>
                            <view v-if="!item.available" class="tag-row"><text class="discount-tag">已下架</text></view>
                            <view v-else-if="item.tags.length > 0" class="tag-row"><text v-for="tag in item.tags" :key="tag" :class="tag == '券' ? 'coupon-tag' : 'discount-tag'">{{tag}}</text></view>
                            <view class="address-row"><image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text></view>
                        </view>
                    </view>
@@ -58,7 +59,7 @@
    import { isApiConfigured } from '../../config/api.uts'
    import { getFoodTopic, getFoodTopicMerchants } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { resolveTalentId } from '../../utils/talent-id.uts'
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28)
@@ -88,6 +89,20 @@
    const currentPage = ref(0)
    const listRequestVersion = ref(0)
    const talentId = ref('')
    const readListTags = (item: any, hasCoupon: boolean, discount: string): string[] => {
        const tags: string[] = []
        if (item != null && item.benefitTags instanceof Array) {
            const records: any[] = item.benefitTags as any[]
            for (let index = 0; index < records.length; index++) {
                const tag = String(records[index]).trim()
                if (tag != '' && tag != '人才专享' && tags.indexOf(tag) < 0) tags.push(tag)
            }
            return tags
        }
        if (hasCoupon) tags.push('券')
        if (discount != '' && discount != '人才专享') tags.push(discount)
        return tags
    }
    onLoad((options: any) => {
        // #ifdef MP-WEIXIN
@@ -104,8 +119,7 @@
            failInitialOnce.value = previewState == 'initialError'
            failLoadMoreOnce.value = previewState == 'loadMoreError'
        }
        const session = getSession()
        if (session != null && session.talentId != null) talentId.value = session.talentId as string
        talentId.value = resolveTalentId()
        loadFoodTopic()
        loadInitialMerchants()
    })
@@ -190,12 +204,15 @@
            const next = append ? visibleMerchants.value.slice() : []
            for (let index = 0; index < records.length; index++) {
                const item = records[index]
                const hasCoupon = item.couponAvailable === true || Number(item.couponAvailable) == 1
                const discount = item.discountText != null && item.discountText != '人才专享' ? item.discountText as string : ''
                next.push({
                    id: '' + item.id,
                    name: item.merchantName != null ? item.merchantName as string : '餐饮商户',
                    merchantType: item.merchantType != null ? item.merchantType as string : 'food',
                    discount: item.discountText != null && item.discountText != '人才专享' ? item.discountText as string : '',
                    hasCoupon: item.couponAvailable === true || Number(item.couponAvailable) == 1,
                    discount: discount,
                    hasCoupon: hasCoupon,
                    tags: readListTags(item, hasCoupon, discount),
                    address: item.address != null ? item.address as string : '',
                    image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackFoodImage,
                    available: item.available !== false
@@ -286,6 +303,7 @@
    .merchant-info { display: flex; min-width: 0; min-height: 184rpx; flex: 1; box-sizing: border-box; padding: 4rpx 0 4rpx 24rpx; flex-direction: column; justify-content: space-between; }
    .merchant-name { color: #292929; font-size: 29rpx; line-height: 40rpx; font-weight: 700; }
    .tag-row { display: flex; flex-direction: row; align-items: center; }
    .tag-row text:first-child { margin-left: 0; }
    .coupon-tag { padding: 6rpx 8rpx; border-radius: 6rpx; background-color: #FFF0EC; color: #FF5D37; font-size: 21rpx; }
    .discount-tag { margin-left: 11rpx; padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF5E7; color: #FF7C2B; font-size: 21rpx; }
    .address-row { display: flex; min-width: 0; box-sizing: border-box; padding: 7rpx 10rpx; border-radius: 7rpx; background-image: linear-gradient(90deg, rgba(239,240,242,.98) 0%, rgba(242,243,245,.76) 58%, rgba(255,255,255,0) 100%); flex-direction: row; align-items: flex-start; }
rencaika/pages/merchant/merchant.uvue
@@ -25,7 +25,7 @@
                    <view v-if="!merchantAvailable" class="merchant-unavailable"><text>{{merchantUnavailableReason}}</text></view>
                    <view v-if="isParkingMerchant" class="parking-title-block">
                        <view class="parking-title-row"><text class="merchant-title">{{merchantName}}</text><view class="round-action" @tap="callMerchant"><image src="/static/merchant-assets/phone.svg"></image></view></view>
                        <text class="parking-free">{{parkingBenefitText}}</text>
                        <text class="parking-free">{{merchantServiceLabel != '' ? merchantServiceLabel : parkingBenefitText}}</text>
                    </view>
                    <view v-else-if="isMedicalMerchant" class="merchant-title-row">
                        <text class="merchant-title">{{merchantName}}</text>
@@ -33,7 +33,7 @@
                    </view>
                    <view v-else class="merchant-title-row">
                        <text class="merchant-title">{{merchantName}}</text>
                        <view v-if="!isFuelMerchant && merchantDisplayTags.length > 0" class="merchant-tags"><text v-for="tag in merchantDisplayTags" :key="tag" :class="tag == '券' ? 'coupon-tag' : 'discount-tag'">{{tag}}</text></view>
                        <view v-if="merchantDisplayTags.length > 0" class="merchant-tags"><text v-for="tag in merchantDisplayTags" :key="tag" :class="tag == '券' ? 'coupon-tag' : 'discount-tag'">{{tag}}</text></view>
                    </view>
                    <view v-if="(!isFuelMerchant && !isHotelMerchant && merchantServiceLabel != '') || applicableCards != ''" class="service-tags"><text v-if="!isParkingMerchant && !isMedicalMerchant && !isFuelMerchant && !isHotelMerchant && merchantServiceLabel != ''">{{merchantServiceLabel}}</text><text v-if="applicableCards != ''">{{applicableCards}}</text></view>
                    <view v-if="!isParkingMerchant && merchantType != 'apartment'" class="merchant-address-row">
@@ -125,7 +125,7 @@
    import { apiEnvironment, isApiConfigured } from '../../config/api.uts'
    import { getFuelMerchantDetail, getMedicalPackages, getMerchantDetail, getParkingMerchantDetail, getProducts, getTalentCardProfile, submitFuelMobileRegistration } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { resolveTalentId } from '../../utils/talent-id.uts'
    import { safeBack } from '../../utils/navigation.uts'
    import { submitBusiness } from '../../services/business.uts'
@@ -177,10 +177,11 @@
    const merchantBenefitTags = ref<string[]>([])
    const merchantBenefitTagsLoaded = ref(false)
    const merchantDisplayTags = computed((): string[] => {
        if (isFuelMerchant.value || ['employment', 'finance', 'approval', 'apartment', 'housing', 'car', 'appliance', 'digital', 'telecom'].indexOf(merchantType.value) >= 0) return []
        if (merchantBenefitTagsLoaded.value) return merchantBenefitTags.value
        const tags: string[] = []
        if (merchantHasCoupon.value && !isFuelMerchant.value) tags.push('券')
        if (merchantDiscount.value != '' && !isFuelMerchant.value) tags.push(merchantDiscount.value)
        if (merchantHasCoupon.value) tags.push('券')
        if (merchantDiscount.value != '') tags.push(merchantDiscount.value)
        if (isHotelMerchant.value && merchantConsumeDiscount.value != '') tags.push(merchantConsumeDiscount.value)
        return tags
    })
@@ -193,17 +194,9 @@
        }
        return tags
    }
    const normalizeHotelCardLabel = (value: string): string => {
        let label = value.trim()
        const separator = label.indexOf('|')
        if (separator >= 0) label = label.substring(0, separator).trim()
        label = label.replace('(外地)', '').replace('(本地)', '').trim()
        if (label == '') return ''
        return label.endsWith('可用') ? label : label + '可用'
    }
    const merchantAddress = ref('四川省攀枝花市东区炳三区新福路20号中环天地负一层')
    const merchantServiceLabel = ref('到店消费')
    const applicableCards = ref('卓越卡可用')
    const merchantServiceLabel = ref('')
    const applicableCards = ref('')
    const fuelBenefitId = ref('')
    const parkingBenefitId = ref('')
    const parkingBenefitText = ref('免费')
@@ -459,9 +452,7 @@
                }
            }
        }
        const session = getSession()
        if (session != null && session.talentId != null) talentId.value = session.talentId as string
        if (talentId.value == '' && apiEnvironment == 'development') talentId.value = '2'
        talentId.value = resolveTalentId()
        if ((isFuelMerchant.value || isParkingMerchant.value) && talentId.value != '') {
            getTalentCardProfile(talentId.value, (data: any) => {
                const card = data != null ? data.card : null
@@ -506,9 +497,11 @@
            merchantAddress.value = data != null && data.address != null ? data.address as string : ''
            merchantPhone.value = data != null && data.contactMobile != null ? data.contactMobile as string : ''
            merchantAvailable.value = data != null && (data.available === true || Number(data.available) == 1)
            merchantDiscount.value = data != null && data.discountText != null ? data.discountText as string : '请咨询商户'
            merchantServiceLabel.value = data != null && data.serviceLabel != null ? data.serviceLabel as string : '请咨询商户'
            applicableCards.value = data != null && data.applicableCards != null ? data.applicableCards as string : '请咨询商户'
            merchantServiceLabel.value = ''
            merchantDiscount.value = ''
            merchantBenefitTagsLoaded.value = true
            merchantBenefitTags.value = []
            applicableCards.value = data != null && data.applicableCards != null ? String(data.applicableCards) : ''
            usageInstructions.value = data != null && data.usageInstructions != null ? data.usageInstructions as string : '请咨询商户'
            merchantRightsText.value = data != null && data.rightsText != null ? data.rightsText as string : '请咨询商户'
            groupBuyingEnabled.value = false
@@ -563,7 +556,7 @@
            merchantUnavailableReason.value = data != null && data.unavailableReason != null && data.unavailableReason != '' ? data.unavailableReason as string : '该停车权益暂不可用'
            merchantServiceLabel.value = data != null && data.serviceLabel != null ? data.serviceLabel as string : '停车服务'
            parkingBenefitText.value = data != null && data.parkingType == 'free' ? '免费' : '付费'
            applicableCards.value = data != null && data.applicableCards != null ? data.applicableCards as string : '请咨询商户'
            applicableCards.value = data != null && data.applicableCards != null ? String(data.applicableCards) : ''
            const parkingRightDetail = data != null && data.merchantRight != null && data.merchantRight.detail != null
                ? String(data.merchantRight.detail).trim()
                : ''
@@ -640,26 +633,19 @@
            applyMedicalRightsConfig(data)
            // 普通自助洗车复用餐饮商家的团单展示;外链洗车不会进入此通用详情页。
            const isNormalCarWash = merchantType.value == 'carWash'
            groupBuyingEnabled.value = merchantType.value == 'culture'
                ? false
                : isNormalCarWash
                ? true
                : (data == null || data.groupBuyingEnabled == null ? groupBuyingEnabled.value : data.groupBuyingEnabled === true || Number(data.groupBuyingEnabled) == 1)
            groupBuyingEnabled.value = merchantType.value == 'food'
                || merchantType.value == 'scenic'
                || merchantType.value == 'hotel'
                || merchantType.value == 'dental'
                || merchantType.value == 'medical'
                || isNormalCarWash
            if (merchantType.value == 'culture') {
                const cultureStoreConsume = data != null && (data.storeConsume === true || Number(data.storeConsume) == 1) && (data.consumeEnabled === true || Number(data.consumeEnabled) == 1)
                merchantServiceLabel.value = cultureStoreConsume ? '到店消费' : ''
                merchantPrimaryTag.value = merchantHasCoupon.value ? '券' : '权益'
            }
            isHotelMerchant.value = merchantType.value == 'hotel'
            merchantConsumeDiscount.value = ''
            if (isHotelMerchant.value) {
                const configuredServiceLabel = data != null && data.serviceLabel != null ? String(data.serviceLabel) : ''
                const configuredCardTag = data != null && data.applicableCards != null ? String(data.applicableCards) : ''
                const hasStoreConsumeTag = configuredCardTag.indexOf('到店消费') >= 0
                const hotelStoreConsume = (data != null && (data.storeConsume === true || Number(data.storeConsume) == 1) && (data.consumeEnabled === true || Number(data.consumeEnabled) == 1)) || configuredServiceLabel.indexOf('到店') >= 0 || hasStoreConsumeTag
                merchantServiceLabel.value = hotelStoreConsume ? '到店消费' : '住宿服务'
                const normalizedCardLabel = normalizeHotelCardLabel(configuredCardTag)
                if (normalizedCardLabel != '') applicableCards.value = normalizedCardLabel
                const hotelStoreConsume = data != null && (data.storeConsume === true || Number(data.storeConsume) == 1) && (data.consumeEnabled === true || Number(data.consumeEnabled) == 1)
                const consumeType = data != null && data.consumeType != null ? String(data.consumeType) : ''
                const consumeDiscount = data != null && data.consumeDiscount != null ? String(data.consumeDiscount).trim() : ''
                const consumeCoupon = data != null && data.consumeCoupon != null ? String(data.consumeCoupon).trim() : ''
rencaika/pages/news-detail/news-detail.uvue
@@ -3,7 +3,7 @@
        <view class="nav-shell">
            <view class="safe-space" :style="'height:' + navTop + 'px'"></view>
            <view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'">
                <view class="back" @tap="goBack"><view class="back-arrow"></view></view>
                <view class="back" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view>
                <text class="nav-title">资讯详情</text>
                <view class="capsule-reserve" :style="'width:' + navWidth + 'px'"></view>
            </view>
@@ -245,7 +245,7 @@
    .safe-space { height: 28px; }
    .nav-row { display: flex; position: relative; height: 32px; align-items: center; justify-content: flex-end; box-sizing: border-box; }
    .back { display: flex; position: absolute; z-index: 2; left: 24rpx; top: 0; width: 72rpx; height: 100%; justify-content: center; align-items: center; }
    .back-arrow { width: 22rpx; height: 22rpx; margin-left: 10rpx; border-left-width: 4rpx; border-left-style: solid; border-left-color: #2A3441; border-bottom-width: 4rpx; border-bottom-style: solid; border-bottom-color: #2A3441; transform: rotate(45deg); }
    .back image { width: 38rpx; height: 38rpx; }
    .nav-title { position: absolute; left: 190rpx; right: 190rpx; color: #171717; font-size: 35rpx; font-weight: 700; text-align: center; white-space: nowrap; }
    .capsule-reserve { width: 88px; height: 100%; flex-shrink: 0; }
    .nav-bottom-space { height: 34rpx; }
rencaika/pages/plate-info/plate-info.uvue
@@ -16,7 +16,7 @@
                <view v-if="showParkingResults" class="parking-results">
                    <view v-if="parkingSearchLoading" class="parking-result-empty"><text>正在搜索停车场</text></view>
                    <scroll-view v-else-if="filteredParkingLots.length > 0" class="parking-result-scroll" scroll-y>
                        <view class="parking-result-item" v-for="item in filteredParkingLots" :key="item.id" @tap="selectParking(item)"><text class="parking-result-name">{{item.name}}</text><text class="parking-result-address">{{item.address}}</text></view>
                        <view class="parking-result-item" v-for="item in filteredParkingLots" :key="item.id" @tap.stop="selectParking(item)"><text class="parking-result-name">{{item.name}}</text><text class="parking-result-address">{{item.address}}</text></view>
                    </scroll-view>
                    <view v-else class="parking-result-empty"><text>暂无匹配停车场</text></view>
                </view>
@@ -224,6 +224,8 @@
    .capsule-space { height: 100%; flex-shrink: 0; }
    .form-content { padding: 47rpx 54rpx 0; }
    .field-group { display: flex; position: relative; flex-direction: column; }
    .parking-field { z-index: 20; }
    .plate-field { z-index: 1; }
    .field-label { color: #70798A; font-size: 25rpx; line-height: 36rpx; font-weight: 600; }
    .input-box { display: flex; height: 86rpx; margin-top: 17rpx; box-sizing: border-box; padding: 0 24rpx; border-width: 2rpx; border-style: solid; border-color: #E1E5EB; border-radius: 14rpx; background-color: #FFFFFF; flex-direction: row; align-items: center; }
    .input-box input { height: 82rpx; min-width: 0; flex: 1; color: #2D333C; font-size: 28rpx; }
rencaika/pages/premium-benefits/premium-benefits.uvue
@@ -6,7 +6,7 @@
            <view v-if="topicError" class="topic-error"><text>专题配置加载失败</text><text @tap="loadPremiumTopic">重新加载</text></view>
            <view v-if="topicDescription != ''" class="topic-summary"><view class="intro-card"><text>{{topicDescription}}</text></view></view>
            <view class="service-section"><scroll-view class="tab-scroll" scroll-x :show-scrollbar="false"><view class="service-tabs" :style="'width:' + Math.max(750, serviceTabs.length * 234) + 'rpx'"><view v-for="tab in serviceTabs" :key="tab.id" class="service-tab" :class="tab.id == activeTab ? 'active' : ''" @tap="selectTab(tab.id)"><text>{{tab.name}}</text><view class="tab-line"></view></view></view></scroll-view>
                <view v-if="initialLoading" class="service-empty"><view class="loading-spinner"></view><text>正在加载臻享服务</text></view><view v-else-if="initialError" class="service-empty error-state"><text>臻享服务加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view><view v-else-if="hasLoaded && visibleServices.length == 0" class="service-empty"><text>暂无相关臻享服务</text></view><view v-else-if="hasLoaded" class="service-list"><view v-for="item in visibleServices" :key="item.id" class="service-item" :class="item.available ? '' : 'is-unavailable'" @tap="openService(item)"><image class="service-image" :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image><view class="service-info"><text class="service-name">{{item.name}}</text><text class="service-benefit">{{item.available ? item.benefit : '已停用'}}</text><view class="address-row"><image src="/static/benefits-assets/map-pin.svg" mode="aspectFit"></image><text>{{item.address}}</text></view></view></view></view>
                <view v-if="initialLoading" class="service-empty"><view class="loading-spinner"></view><text>正在加载臻享服务</text></view><view v-else-if="initialError" class="service-empty error-state"><text>臻享服务加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view><view v-else-if="hasLoaded && visibleServices.length == 0" class="service-empty"><text>暂无相关臻享服务</text></view><view v-else-if="hasLoaded" class="service-list"><view v-for="item in visibleServices" :key="item.id" class="service-item" :class="item.available ? '' : 'is-unavailable'" @tap="openService(item)"><image class="service-image" :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image><view class="service-info"><text class="service-name">{{item.name}}</text><text v-if="!item.available || item.benefitTagVisible" class="service-benefit">{{item.available ? item.benefit : '已停用'}}</text><view class="address-row"><image src="/static/benefits-assets/map-pin.svg" mode="aspectFit"></image><text>{{item.address}}</text></view></view></view></view>
                <view v-if="loadingMore" class="loading-row"><view class="loading-spinner"></view><text>正在加载</text></view><view v-else-if="loadMoreError" class="loading-row load-error" @tap="retryLoadMore"><text>加载失败,点击重试</text></view><view v-else-if="hasLoaded && !hasMore && visibleServices.length > 0" class="no-more"><view></view><text>已经到底了</text><view></view></view>
            </view><view class="bottom-space"></view>
        </scroll-view>
@@ -50,7 +50,7 @@
    const loadServices = (append: boolean) => {
        if (!isApiConfigured() || activeTab.value == '') { initialLoading.value = false; loadingMore.value = false; initialError.value = true; return }; const requestVersion = listRequestVersion.value
        if (!append && failInitialOnce.value) { failInitialOnce.value = false; initialLoading.value = false; initialError.value = true; return }; if (append && failLoadMoreOnce.value) { failLoadMoreOnce.value = false; loadingMore.value = false; loadMoreError.value = true; return }
        getPremiumTopicServices(activeTab.value, currentPage.value + 1, 4, (data: any) => { if (requestVersion != listRequestVersion.value) return; const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []; const next = append ? visibleServices.value.slice() : []; for (let index = 0; index < records.length; index++) { const item = records[index]; next.push({ id: '' + item.id, businessType: item.businessType != null ? item.businessType as string : 'MERCHANT', merchantType: item.merchantType != null ? item.merchantType as string : 'service', name: item.merchantName != null ? item.merchantName as string : '臻享服务', benefit: activeTab.value == '免费体检' || activeTab.value == '配偶推荐就业' || activeTab.value == '金融服务' || item.businessType == 'ADMIN_APPROVAL' || item.businessType == 'FINANCE' ? '免费' : (item.benefitText != null ? item.benefitText as string : '人才专享'), address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false }) }; visibleServices.value = next; currentPage.value++; hasMore.value = data != null && data.total != null ? next.length < data.total : false; initialLoading.value = false; loadingMore.value = false; hasLoaded.value = true }, () => { if (requestVersion != listRequestVersion.value) return; initialLoading.value = false; loadingMore.value = false; if (append) loadMoreError.value = true; else initialError.value = true })
        getPremiumTopicServices(activeTab.value, currentPage.value + 1, 4, (data: any) => { if (requestVersion != listRequestVersion.value) return; const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []; const next = append ? visibleServices.value.slice() : []; for (let index = 0; index < records.length; index++) { const item = records[index]; const tagVisible = item.benefitTagVisible !== false; next.push({ id: '' + item.id, businessType: item.businessType != null ? item.businessType as string : 'MERCHANT', merchantType: item.merchantType != null ? item.merchantType as string : 'service', name: item.merchantName != null ? item.merchantName as string : '臻享服务', benefit: tagVisible ? (item.benefitText != null ? item.benefitText as string : '') : '', benefitTagVisible: tagVisible, address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false }) }; visibleServices.value = next; currentPage.value++; hasMore.value = data != null && data.total != null ? next.length < data.total : false; initialLoading.value = false; loadingMore.value = false; hasLoaded.value = true }, () => { if (requestVersion != listRequestVersion.value) return; initialLoading.value = false; loadingMore.value = false; if (append) loadMoreError.value = true; else initialError.value = true })
    }
    const loadMore = () => { if (initialLoading.value || loadingMore.value || !hasMore.value) return; loadMoreError.value = false; loadingMore.value = true; loadServices(true) }
    const goBack = () => safeBack('/pages/benefits/benefits')
rencaika/pages/shopping-benefits/shopping-benefits.uvue
@@ -21,7 +21,7 @@
    import { isApiConfigured } from '../../config/api.uts'
    import { getShoppingTopic, getShoppingTopicMerchants } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { resolveTalentId } from '../../utils/talent-id.uts'
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10); const windowWidth = ref(375)
@@ -37,7 +37,7 @@
        // #ifdef MP-WEIXIN
        const capsule = uni.getMenuButtonBoundingClientRect(); const info = uni.getSystemInfoSync(); windowWidth.value = info.windowWidth; navTop.value = capsule.top; navHeight.value = capsule.height; navWidth.value = capsule.width; navRight.value = info.windowWidth - capsule.right
        // #endif
        const session = getSession(); if (session != null && session.talentId != null) talentId.value = session.talentId as string
        talentId.value = resolveTalentId()
        if (options != null && options['previewState'] != null) { const state = options['previewState'] as string; failInitialOnce.value = state == 'initialError'; failLoadMoreOnce.value = state == 'loadMoreError' }
        selectScene(0); loadShoppingTopic()
    })
rencaika/pages/shopping-merchant/shopping-merchant.uvue
@@ -6,7 +6,7 @@
            <view v-if="detailLoading" class="page-state"><view class="loading-spinner"></view><text>正在加载购物服务</text></view>
            <view v-else-if="detailError" class="page-state error-state"><text>购物服务加载失败</text><text class="retry-text" @tap="loadMerchantDetail">重新加载</text></view>
            <view v-else class="detail-content">
                <view class="merchant-block"><view class="title-row"><text class="merchant-name">{{merchantName}}</text><text v-if="discount != ''" class="discount-label">{{discount}}</text></view><view v-if="applicableCards != ''" class="talent-tag"><text>{{applicableCards}}</text></view><view v-if="address != '' || canNavigate() || merchantPhone != ''" class="address-row"><image v-if="address != ''" class="location-icon" src="/static/benefits-assets/map-pin.svg" mode="aspectFit"></image><text v-if="address != ''" class="merchant-address">{{address}}</text><view class="address-actions"><view class="round-action" :class="!available || !canNavigate() ? 'is-disabled' : ''" @tap="openLocation"><image src="/static/merchant-assets/navigation.svg" mode="aspectFit"></image></view><view class="round-action" :class="!available || merchantPhone == '' ? 'is-disabled' : ''" @tap="callMerchant"><image src="/static/merchant-assets/phone.svg" mode="aspectFit"></image></view></view></view><view v-if="!available" class="unavailable-row"><text>{{unavailableReason}}</text></view></view>
                <view class="merchant-block"><view class="title-row"><text class="merchant-name">{{merchantName}}</text><view v-if="displayTags.length > 0" class="discount-tags"><text v-for="tag in displayTags" :key="tag" class="discount-label">{{tag}}</text></view></view><view v-if="applicableCards != ''" class="talent-tag"><text>{{applicableCards}}</text></view><view v-if="address != '' || canNavigate() || merchantPhone != ''" class="address-row"><image v-if="address != ''" class="location-icon" src="/static/benefits-assets/map-pin.svg" mode="aspectFit"></image><text v-if="address != ''" class="merchant-address">{{address}}</text><view class="address-actions"><view class="round-action" :class="!available || !canNavigate() ? 'is-disabled' : ''" @tap="openLocation"><image src="/static/merchant-assets/navigation.svg" mode="aspectFit"></image></view><view class="round-action" :class="!available || merchantPhone == '' ? 'is-disabled' : ''" @tap="callMerchant"><image src="/static/merchant-assets/phone.svg" mode="aspectFit"></image></view></view></view><view v-if="!available" class="unavailable-row"><text>{{unavailableReason}}</text></view></view>
                <view class="detail-section"><view class="section-title"><text>权益详情</text><view></view></view><view class="detail-card"><text>{{benefitDetail == '' ? '权益详情暂未配置' : benefitDetail}}</text></view></view>
            </view>
        </view>
@@ -22,7 +22,7 @@
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10)
    const merchantId = ref(''); const talentId = ref(''); const heroImage = ref(''); const merchantName = ref(''); const discount = ref(''); const applicableCards = ref(''); const address = ref(''); const benefitDetail = ref('')
    const merchantId = ref(''); const talentId = ref(''); const heroImage = ref(''); const merchantName = ref(''); const displayTags = ref<string[]>([]); const applicableCards = ref(''); const address = ref(''); const benefitDetail = ref('')
    const merchantPhone = ref(''); const latitude = ref(0); const longitude = ref(0); const available = ref(false); const unavailableReason = ref('该购物服务当前不可用'); const advisorAvailable = ref(false); const advisorPhone = ref('')
    const detailLoading = ref(true); const detailError = ref(false)
    const canNavigate = () => latitude.value != 0 && longitude.value != 0
@@ -42,7 +42,12 @@
        getShoppingMerchantDetail(merchantId.value, talentId.value, (data: any) => {
            if (data == null) { detailLoading.value = false; detailError.value = true; return }
            merchantName.value = data.merchantName != null ? data.merchantName as string : ''
            discount.value = data.discountText != null ? data.discountText as string : ''
            const tags: any[] = data.displayTags instanceof Array ? data.displayTags as any[] : []
            displayTags.value = []
            for (let index = 0; index < tags.length; index++) {
                const tag = String(tags[index]).trim()
                if (tag != '' && displayTags.value.indexOf(tag) < 0) displayTags.value.push(tag)
            }
            applicableCards.value = data.applicableCards != null ? data.applicableCards as string : ''
            address.value = data.address != null ? data.address as string : ''
            heroImage.value = data.coverUrl != null && data.coverUrl != '' ? resolveApiUrl(data.coverUrl as string) : ''
@@ -64,7 +69,7 @@
<style>
    .page { width:100%; height:100vh; position:relative; overflow:hidden; background-color:#F5F5F5; }.hero-image { position:absolute; left:0; right:0; top:0; width:100%; height:445rpx; background-color:#D8DDE3; }.hero-shade { position:absolute; z-index:1; left:0; right:0; top:0; height:210rpx; background-image:linear-gradient(180deg,rgba(0,0,0,.45),rgba(0,0,0,0)); }.custom-nav { position:absolute; z-index:10; left:0; right:0; top:0; }.safe-space { height:28px; }.nav-row { display:flex; box-sizing:border-box; padding-left:24rpx; flex-direction:row; align-items:center; justify-content:space-between; }.back-button { display:flex; width:54rpx; height:54rpx; border-radius:50%; background-color:rgba(45,45,45,.48); justify-content:center; align-items:center; }.back-button image { width:38rpx; height:38rpx; }.capsule-space { height:100%; flex-shrink:0; }
    .content-sheet { display:flex; position:absolute; z-index:3; left:0; right:0; top:415rpx; bottom:128rpx; bottom:calc(128rpx + env(safe-area-inset-bottom)); overflow:hidden; border-radius:30rpx 30rpx 0 0; background-color:#F5F5F5; flex-direction:column; }.detail-content { display:flex; min-height:0; flex:1; flex-direction:column; }.page-state { display:flex; min-height:0; flex:1; background-color:#FFFFFF; flex-direction:column; justify-content:center; align-items:center; }.page-state text { color:#949BA5; font-size:26rpx; }.page-state.error-state .retry-text { margin-top:22rpx; color:#F26A38; }.loading-spinner { width:30rpx; height:30rpx; margin-bottom:16rpx; box-sizing:border-box; border:3rpx solid #FFD6C4; border-top-color:#FF6C2B; border-radius:50%; animation:shopping-detail-loading 720ms linear infinite; }
    .merchant-block { flex-shrink:0; box-sizing:border-box; padding:28rpx 32rpx 23rpx; background-color:#FFFFFF; }.title-row { display:flex; min-width:0; flex-direction:row; justify-content:space-between; align-items:center; }.merchant-name { min-width:0; flex:1; margin-right:20rpx; color:#282828; font-size:35rpx; line-height:48rpx; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.discount-label { max-width:250rpx; flex-shrink:0; color:#FF7A27; font-size:24rpx; line-height:34rpx; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.talent-tag { display:flex; max-width:100%; width:fit-content; min-height:47rpx; margin-top:18rpx; box-sizing:border-box; padding:8rpx 12rpx; border:1rpx solid #FFB98E; border-radius:5rpx; justify-content:center; align-items:center; }.talent-tag text { max-width:570rpx; color:#D87842; font-size:23rpx; line-height:31rpx; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.address-row { display:flex; min-width:0; min-height:60rpx; margin-top:18rpx; flex-direction:row; align-items:center; }.location-icon { width:27rpx; height:27rpx; margin-right:10rpx; flex-shrink:0; }.merchant-address { min-width:0; flex:1; max-height:62rpx; overflow:hidden; color:#8B919A; font-size:22rpx; line-height:31rpx; }.address-actions { display:flex; margin-left:20rpx; flex-direction:row; align-items:center; flex-shrink:0; }.round-action { display:flex; width:58rpx; height:58rpx; margin-left:12rpx; border-radius:50%; background-color:#F6F7F9; justify-content:center; align-items:center; }.round-action.is-disabled { opacity:.42; }.round-action image { width:31rpx; height:31rpx; }.unavailable-row { margin-top:12rpx; }.unavailable-row text { color:#D86B35; font-size:22rpx; line-height:31rpx; }
    .merchant-block { flex-shrink:0; box-sizing:border-box; padding:28rpx 32rpx 23rpx; background-color:#FFFFFF; }.title-row { display:flex; min-width:0; flex-direction:row; justify-content:space-between; align-items:center; }.merchant-name { min-width:0; flex:1; margin-right:20rpx; color:#282828; font-size:35rpx; line-height:48rpx; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.discount-tags { display:flex; max-width:300rpx; flex-shrink:0; flex-direction:row; align-items:center; }.discount-label { max-width:250rpx; margin-left:10rpx; flex-shrink:0; color:#FF7A27; font-size:24rpx; line-height:34rpx; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.talent-tag { display:flex; max-width:100%; width:fit-content; min-height:47rpx; margin-top:18rpx; box-sizing:border-box; padding:8rpx 12rpx; border:1rpx solid #FFB98E; border-radius:5rpx; justify-content:center; align-items:center; }.talent-tag text { max-width:570rpx; color:#D87842; font-size:23rpx; line-height:31rpx; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.address-row { display:flex; min-width:0; min-height:60rpx; margin-top:18rpx; flex-direction:row; align-items:center; }.location-icon { width:27rpx; height:27rpx; margin-right:10rpx; flex-shrink:0; }.merchant-address { min-width:0; flex:1; max-height:62rpx; overflow:hidden; color:#8B919A; font-size:22rpx; line-height:31rpx; }.address-actions { display:flex; margin-left:20rpx; flex-direction:row; align-items:center; flex-shrink:0; }.round-action { display:flex; width:58rpx; height:58rpx; margin-left:12rpx; border-radius:50%; background-color:#F6F7F9; justify-content:center; align-items:center; }.round-action.is-disabled { opacity:.42; }.round-action image { width:31rpx; height:31rpx; }.unavailable-row { margin-top:12rpx; }.unavailable-row text { color:#D86B35; font-size:22rpx; line-height:31rpx; }
    .detail-section { display:flex; min-height:0; flex:1; box-sizing:border-box; padding:24rpx 26rpx 18rpx; flex-direction:column; }.section-title { display:flex; height:66rpx; padding-left:20rpx; flex-direction:column; align-items:flex-start; flex-shrink:0; }.section-title text { color:#766A64; font-size:31rpx; line-height:42rpx; font-weight:600; }.section-title view { width:61rpx; height:7rpx; margin:9rpx 0 0 29rpx; border-radius:4rpx; background-image:linear-gradient(90deg,#FF4C56,#FFD43A); }.detail-card { min-height:0; flex:1; box-sizing:border-box; margin-top:13rpx; padding:31rpx 47rpx; overflow:hidden; border-radius:15rpx; background-color:#FFFFFF; }.detail-card text { display:block; max-height:228rpx; overflow:hidden; color:#161616; font-size:24rpx; line-height:38rpx; }
    .action-bar { display:flex; position:absolute; z-index:8; left:0; right:0; bottom:0; height:128rpx; height:calc(128rpx + env(safe-area-inset-bottom)); box-sizing:border-box; padding:17rpx 31rpx 13rpx; padding-bottom:calc(13rpx + env(safe-area-inset-bottom)); background-color:#FFFFFF; align-items:flex-start; }.contact-button { display:flex; width:100%; height:82rpx; border-radius:43rpx; background-image:linear-gradient(90deg,#FF4B32,#FF8A1E); flex-direction:row; justify-content:center; align-items:center; }.contact-button-disabled { background-image:none; background-color:#C7CDD5; }.contact-button image { width:35rpx; height:35rpx; margin-right:14rpx; }.contact-button text { color:#FFFFFF; font-size:29rpx; font-weight:600; }.contact-button-active { opacity:.8; transform:scale(.99); }@keyframes shopping-detail-loading { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
</style>
rencaika/pages/spouse-employment/spouse-employment.uvue
@@ -8,7 +8,8 @@
            <view v-if="loading" class="state-box"><text>正在加载权益信息</text></view>
            <view v-else-if="loadError" class="state-box"><text>权益信息加载失败</text><text class="retry" @tap="loadBenefit">重新加载</text></view>
            <view v-else>
                <view class="service-row"><text class="service-name">{{title}}</text><text class="free-label">{{freeLabel}}</text></view>
                <view class="service-row"><text class="service-name">{{title}}</text></view>
                <view v-if="applicableCards != ''" class="talent-tag"><text>{{applicableCards}}</text></view>
                <view class="section-title"><text>权益详情</text><view></view></view>
                <view class="detail-card"><text>{{rulesText}}</text></view>
                <view v-if="!available" class="unavailable"><text>{{unavailableReason}}</text></view>
@@ -19,27 +20,26 @@
</template>
<script setup lang="uts">
    import { apiEnvironment } from '../../config/api.uts'
    import { getSpouseEmploymentBenefit } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { resolveTalentId } from '../../utils/talent-id.uts'
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10)
    const serviceId = ref(''); const talentId = ref(''); const loading = ref(true); const loadError = ref(false); const available = ref(false)
    const fallbackHeroImage = '/static/stay-assets/hotel-vienna.jpg'; const heroImage = ref(fallbackHeroImage); const title = ref('配偶推荐就业'); const freeLabel = ref('免费'); const rulesText = ref(''); const unavailableReason = ref('该权益暂未开放')
    const fallbackHeroImage = '/static/stay-assets/hotel-vienna.jpg'; const heroImage = ref(fallbackHeroImage); const title = ref('配偶推荐就业'); const applicableCards = ref(''); const rulesText = ref(''); const unavailableReason = ref('该权益暂未开放')
    onLoad((options: any) => {
        // #ifdef MP-WEIXIN
        const capsule = uni.getMenuButtonBoundingClientRect(); const info = uni.getSystemInfoSync(); navTop.value = capsule.top; navHeight.value = capsule.height; navWidth.value = capsule.width; navRight.value = info.windowWidth - capsule.right
        // #endif
        serviceId.value = options != null && options['serviceId'] != null ? options['serviceId'] as string : ''; const session = getSession(); if (session != null && session.talentId != null) talentId.value = session.talentId as string; if (talentId.value == '' && apiEnvironment == 'development') talentId.value = '2'
        serviceId.value = options != null && options['serviceId'] != null ? options['serviceId'] as string : ''; talentId.value = resolveTalentId()
        loadBenefit()
    })
    const loadBenefit = () => {
        if (serviceId.value == '') { loading.value = false; loadError.value = true; return }
        loading.value = true; loadError.value = false
        getSpouseEmploymentBenefit(serviceId.value, talentId.value, (data: any) => {
            title.value = data.title || title.value; heroImage.value = data.heroImage != null && data.heroImage != '' ? resolveApiUrl(data.heroImage as string) : fallbackHeroImage; freeLabel.value = data.freeLabel || freeLabel.value; rulesText.value = data.rulesText || ''; available.value = data.available === true; unavailableReason.value = data.unavailableReason || unavailableReason.value; loading.value = false
            title.value = data.title || title.value; heroImage.value = data.heroImage != null && data.heroImage != '' ? resolveApiUrl(data.heroImage as string) : fallbackHeroImage; applicableCards.value = data.applicableCards != null ? String(data.applicableCards) : ''; rulesText.value = data.rulesText || ''; available.value = data.available === true; unavailableReason.value = data.unavailableReason || unavailableReason.value; loading.value = false
        }, () => { loading.value = false; loadError.value = true })
    }
    const replaceHeroImage = () => { heroImage.value = fallbackHeroImage }
@@ -51,6 +51,6 @@
    .page { width:100%; height:100vh; position:relative; overflow:hidden; background-color:#F5F5F5; }
    .hero-image { position:absolute; left:0; right:0; top:0; width:100%; height:425rpx; background-color:#D4DAE1; } .hero-shade { position:absolute; z-index:1; left:0; right:0; top:0; height:210rpx; background-image:linear-gradient(180deg,rgba(0,0,0,.55),rgba(0,0,0,0)); }
    .custom-nav { position:absolute; z-index:10; left:0; right:0; top:0; } .safe-space { height:28px; } .nav-row { display:flex; box-sizing:border-box; padding-left:24rpx; flex-direction:row; align-items:center; justify-content:space-between; } .back-button { display:flex; width:54rpx; height:54rpx; border-radius:50%; background-color:rgba(45,45,45,.38); justify-content:center; align-items:center; } .back-button image { width:38rpx; height:38rpx; } .capsule-space { height:100%; flex-shrink:0; }
    .content-sheet { position:absolute; z-index:3; left:0; right:0; top:405rpx; bottom:128rpx; bottom:calc(128rpx + env(safe-area-inset-bottom)); box-sizing:border-box; padding:28rpx 25rpx 20rpx; border-radius:30rpx 30rpx 0 0; background-color:#F5F5F5; } .service-row { display:flex; height:64rpx; padding:0 10rpx; flex-direction:row; justify-content:space-between; align-items:center; } .service-name { color:#252525; font-size:35rpx; line-height:48rpx; font-weight:700; } .free-label { color:#FF7A27; font-size:24rpx; } .section-title { display:flex; height:84rpx; margin-top:15rpx; padding-left:20rpx; flex-direction:column; align-items:flex-start; } .section-title text { color:#756B67; font-size:31rpx; line-height:42rpx; font-weight:600; } .section-title view { width:61rpx; height:7rpx; margin:10rpx 0 0 28rpx; border-radius:4rpx; background-image:linear-gradient(90deg,#FF4C56,#FFD43A); } .detail-card { box-sizing:border-box; padding:43rpx 47rpx; border-radius:15rpx; background-color:#FFFFFF; } .detail-card text { display:block; color:#727983; font-size:25rpx; line-height:52rpx; } .unavailable { margin-top:20rpx; text-align:center; color:#9A725A; font-size:24rpx; }
    .content-sheet { position:absolute; z-index:3; left:0; right:0; top:405rpx; bottom:128rpx; bottom:calc(128rpx + env(safe-area-inset-bottom)); box-sizing:border-box; padding:28rpx 25rpx 20rpx; border-radius:30rpx 30rpx 0 0; background-color:#F5F5F5; } .service-row { display:flex; height:64rpx; padding:0 10rpx; flex-direction:row; justify-content:space-between; align-items:center; } .service-name { color:#252525; font-size:35rpx; line-height:48rpx; font-weight:700; } .free-label { color:#FF7A27; font-size:24rpx; } .talent-tag { display:flex; align-self:flex-start; min-height:42rpx; max-width:100%; margin:8rpx 10rpx 0; box-sizing:border-box; padding:0 14rpx; border:1rpx solid #FFB98E; border-radius:5rpx; justify-content:center; align-items:center; } .talent-tag text { max-width:640rpx; color:#D87842; font-size:23rpx; line-height:30rpx; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .section-title { display:flex; height:84rpx; margin-top:15rpx; padding-left:20rpx; flex-direction:column; align-items:flex-start; } .section-title text { color:#756B67; font-size:31rpx; line-height:42rpx; font-weight:600; } .section-title view { width:61rpx; height:7rpx; margin:10rpx 0 0 28rpx; border-radius:4rpx; background-image:linear-gradient(90deg,#FF4C56,#FFD43A); } .detail-card { box-sizing:border-box; padding:43rpx 47rpx; border-radius:15rpx; background-color:#FFFFFF; } .detail-card text { display:block; color:#727983; font-size:25rpx; line-height:52rpx; } .unavailable { margin-top:20rpx; text-align:center; color:#9A725A; font-size:24rpx; }
    .state-box { display:flex; height:230rpx; color:#8D96A5; font-size:27rpx; flex-direction:column; align-items:center; justify-content:center; } .retry { margin-top:24rpx; color:#FF7A27; } .action-bar { display:flex; position:absolute; z-index:8; left:0; right:0; bottom:0; height:128rpx; height:calc(128rpx + env(safe-area-inset-bottom)); box-sizing:border-box; padding:17rpx 31rpx 13rpx; padding-bottom:calc(13rpx + env(safe-area-inset-bottom)); background-color:#FFFFFF; } .fill-button { display:flex; width:100%; height:82rpx; border-radius:43rpx; background-image:linear-gradient(90deg,#FF4B32,#FF8A1E); justify-content:center; align-items:center; } .fill-button text { color:#FFFFFF; font-size:29rpx; font-weight:600; } .disabled-button { background-image:none; background-color:#C7CDD5; } .fill-button-active { opacity:.8; transform:scale(.99); }
</style>
rencaika/pages/stay-benefits/stay-benefits.uvue
@@ -77,7 +77,7 @@
    import { getStayTopic, getStayTopicMerchants } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { safeBack } from '../../utils/navigation.uts'
    import { getSession } from '../../services/session.uts'
    import { resolveTalentId } from '../../utils/talent-id.uts'
    const navTop = ref(28); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10)
    const isSolidNav = ref(false); const windowWidth = ref(375); const fallbackImage = ''
@@ -90,7 +90,7 @@
        const capsule = uni.getMenuButtonBoundingClientRect(); const info = uni.getSystemInfoSync(); windowWidth.value = info.windowWidth; navTop.value = capsule.top; navHeight.value = capsule.height; navWidth.value = capsule.width; navRight.value = info.windowWidth - capsule.right
        // #endif
        if (options != null && options['previewState'] != null) { const state = options['previewState'] as string; failInitialOnce.value = state == 'initialError'; failLoadMoreOnce.value = state == 'loadMoreError' }
        const session = getSession(); if (session != null && session.talentId != null) talentId.value = session.talentId as string
        talentId.value = resolveTalentId()
        loadStayTopic(); loadInitialServices()
    })
    const readText = (primary : any, fallback : any) : string => {
@@ -164,14 +164,15 @@
                const merchantType = readText(item.merchantType, item['merchant_type'])
                const merchantName = readText(item.merchantName, item['merchant_name'])
                const discountText = readText(item.discountText, item['discount_text'])
                const configuredTags: any[] = item.benefitTags instanceof Array ? (item.benefitTags as any[]) : []
                const hasConfiguredTags = item.benefitTags instanceof Array
                const configuredTags: any[] = hasConfiguredTags ? (item.benefitTags as any[]) : []
                const displayTags: string[] = []
                for (let tagIndex = 0; tagIndex < configuredTags.length; tagIndex++) {
                    const tag = String(configuredTags[tagIndex]).trim()
                    if (tag != '' && tag != '人才专享' && displayTags.indexOf(tag) < 0) displayTags.push(tag)
                }
                const hasCoupon = item.couponAvailable === true || Number(item.couponAvailable) == 1
                if (displayTags.length == 0) {
                if (!hasConfiguredTags) {
                    if (hasCoupon) displayTags.push('券')
                    if (discountText != '' && discountText != '人才专享') displayTags.push(discountText)
                }
rencaika/pages/transport-benefits/transport-benefits.uvue
@@ -60,7 +60,7 @@
    import { isApiConfigured } from '../../config/api.uts'
    import { getTransportTopic, getTransportTopicMerchants } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { resolveTalentId } from '../../utils/talent-id.uts'
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10)
@@ -77,7 +77,7 @@
        const capsule = uni.getMenuButtonBoundingClientRect(); const info = uni.getSystemInfoSync(); windowWidth.value = info.windowWidth; navTop.value = capsule.top; navHeight.value = capsule.height; navWidth.value = capsule.width; navRight.value = info.windowWidth - capsule.right
        // #endif
        if (options != null && options['previewState'] != null) { const state = options['previewState'] as string; failInitialOnce.value = state == 'initialError'; failLoadMoreOnce.value = state == 'loadMoreError' }
        const session = getSession(); if (session != null && session.talentId != null) talentId.value = session.talentId as string
        talentId.value = resolveTalentId()
        selectScene(0); loadTransportTopic(); loadInitialServices()
    })
    const loadTransportTopic = () => {
@@ -105,7 +105,7 @@
        getTransportTopicMerchants(activeTab.value, talentId.value, currentPage.value + 1, 4, (data: any) => {
            if (requestVersion != listRequestVersion.value) return
            const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []; const next = append ? visibleServices.value.slice() : []
            for (let index = 0; index < records.length; index++) { const item = records[index]; const merchantType = item.merchantType != null ? item.merchantType as string : activeTab.value; const discount = item.discountText != null ? item.discountText as string : ''; next.push({ id: '' + item.id, merchantType: merchantType, name: item.merchantName != null ? item.merchantName as string : '行服务', discount: discount, benefitTagVisible: merchantType == 'carWash' ? item.benefitTagVisible === true : discount != '', address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false, externalLinkType: item.externalLinkType != null ? item.externalLinkType as string : '' }) }
            for (let index = 0; index < records.length; index++) { const item = records[index]; const merchantType = item.merchantType != null ? item.merchantType as string : activeTab.value; const discount = merchantType == 'fuel' ? '' : (item.discountText != null ? item.discountText as string : ''); next.push({ id: '' + item.id, merchantType: merchantType, name: item.merchantName != null ? item.merchantName as string : '行服务', discount: discount, benefitTagVisible: merchantType == 'fuel' ? false : (merchantType == 'carWash' ? item.benefitTagVisible === true : discount != ''), address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false, externalLinkType: item.externalLinkType != null ? item.externalLinkType as string : '' }) }
            visibleServices.value = next; currentPage.value++; hasMore.value = data != null && data.total != null ? next.length < data.total : false; initialLoading.value = false; loadingMore.value = false; hasLoaded.value = true
        }, () => { if (requestVersion != listRequestVersion.value) return; initialLoading.value = false; loadingMore.value = false; if (append) loadMoreError.value = true; else initialError.value = true })
    }
rencaika/pages/travel-benefits/travel-benefits.uvue
@@ -13,7 +13,7 @@
    import { isApiConfigured } from '../../config/api.uts'
    import { getTravelTopic, getTravelTopicMerchants } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { resolveTalentId } from '../../utils/talent-id.uts'
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28)
@@ -58,8 +58,7 @@
            failLoadMoreOnce.value = state == 'loadMoreError'
        }
        loadTravelTopic()
        const session = getSession()
        if (session != null && session.talentId != null) talentId.value = session.talentId as string
        talentId.value = resolveTalentId()
        loadInitialServices()
    })
rencaika/static/merchant-assets/car-wash-service-orange.svg
@@ -1,7 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#E9924D" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
  <path d="M6.3 11.1 7.6 8a2 2 0 0 1 1.84-1.24h5.12A2 2 0 0 1 16.4 8l1.3 3.1"/>
  <path d="M5.4 11.1h13.2a1.8 1.8 0 0 1 1.8 1.8v4.2a1.8 1.8 0 0 1-1.8 1.8H5.4a1.8 1.8 0 0 1-1.8-1.8v-4.2a1.8 1.8 0 0 1 1.8-1.8Z"/>
  <path d="M6.6 18.9v1.2M17.4 18.9v1.2M7.3 14.4h.01M16.7 14.4h.01"/>
  <path d="M18.6.8c-.9 1.1-1.45 1.9-1.45 2.62a1.45 1.45 0 0 0 2.9 0c0-.72-.55-1.52-1.45-2.62Z"/>
  <path d="M21.5 3.9c-.56.68-.9 1.17-.9 1.61a.9.9 0 0 0 1.8 0c0-.44-.34-.93-.9-1.61Z"/>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FF7A2F" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
  <path d="m3.8 13 1.1-2.6A1.6 1.6 0 0 1 6.4 9.4h5.2a1.6 1.6 0 0 1 1.5 1l1.1 2.6"/>
  <rect x="2.2" y="13" width="13.6" height="6.6" rx="1.6"/>
  <path d="M4.2 19.6v1.4M13.8 19.6v1.4M5.3 16.1h.01M12.7 16.1h.01"/>
  <path d="M20 2.4c-1.45 1.65-2.55 3.15-2.55 4.45a2.55 2.55 0 0 0 5.1 0C22.55 5.55 21.45 4.05 20 2.4Z"/>
  <path d="M15.45 4.55c-.72.82-1.25 1.55-1.25 2.25a1.25 1.25 0 0 0 2.5 0c0-.7-.53-1.43-1.25-2.25Z"/>
</svg>
rencaika/utils/talent-id.uts
New file
@@ -0,0 +1,16 @@
import { getSession } from '../services/session.uts'
/**
 * 解析当前请求使用的人才ID。
 *
 * 列表和详情必须使用同一来源:折扣、券、免费次数这些标签都要先按人才卡等级匹配,
 * 一旦某一侧拿不到人才ID,就会出现同一商家"列表没有标签、详情有标签"的差异。
 *
 * 游客态没有人才卡,按产品要求不展示任何权益标签,因此这里不做开发环境兜底:
 * 未登录时返回空串,让列表和详情一起收敛为不显示。
 */
export const resolveTalentId = (): string => {
    const session = getSession()
    if (session != null && session.talentId != null) return '' + session.talentId
    return ''
}
rencaika_merchant/pages/consume-input/consume-input.uvue
@@ -53,9 +53,9 @@
        for (let index = 0; index < rules.value.length; index++) {
            if (rules.value[index].id == selectedRuleId.value) {
                const rule = rules.value[index]
                if (rule.benefitText.length > 0) return rule.benefitText
                if (rule.fixedReduction > 0) return '到店消费立减' + formatBenefitNumber(rule.fixedReduction) + '元'
                if (rule.discountRate > 0) return '到店消费' + formatBenefitNumber(rule.discountRate * 10) + '折'
                if (rule.benefitText.length > 0) return rule.benefitText
                return rule.name
            }
        }
talent-card-admin/src/style.css
@@ -498,6 +498,21 @@
  font-size: 12px;
}
.merchant-management-table .merchant-action-cell {
  display: grid;
  grid-template-columns: 32px 48px 80px;
  justify-content: center;
  align-items: center;
  column-gap: 4px;
  white-space: nowrap;
}
.merchant-management-table .merchant-action-cell .el-button {
  margin-left: 0;
  width: 100%;
  justify-content: center;
}
.merchant-create-page {
  display: grid;
  gap: 14px;
@@ -1430,12 +1445,13 @@
.hotel-consume-row > strong {
  grid-column: 1;
  align-self: center;
  align-self: start;
  margin-top: 7px;
}
.hotel-consume-row > .el-switch {
  grid-column: 2;
  align-self: center;
  align-self: start;
}
.hotel-consume-options {
@@ -3466,6 +3482,11 @@
  padding: 18px 22px 20px;
}
.business-fill-page.role-management-page > .panel {
  padding-left: 16px;
  padding-right: 16px;
}
.business-fill-filter-form {
  display: grid;
  grid-template-columns: minmax(250px, 1.15fr) minmax(240px, 1fr) minmax(220px, 0.9fr) minmax(260px, 1.1fr) auto;
@@ -3481,8 +3502,8 @@
/* 角色管理筛选项与标题左边缘对齐。 */
.role-management-filter-form .el-form-item__label {
  flex: 0 0 86px;
  width: 86px !important;
  flex: 0 0 auto;
  width: auto;
  justify-content: flex-start;
}
@@ -3839,6 +3860,40 @@
  gap: 12px;
}
.movie-ticket-code-pool-filters {
  container-type: inline-size;
  container-name: movie-ticket-code-pool;
  min-width: 0;
}
.movie-ticket-code-pool-filter-form .el-form-item,
.movie-ticket-code-pool-filter-form .el-form-item__content {
  min-width: 0;
}
@container movie-ticket-code-pool (max-width: 1311px) {
  .movie-ticket-code-pool-filter-form {
    grid-template-columns: repeat(2, minmax(0, 1fr)) 80px;
  }
  .movie-ticket-code-pool-filter-form .movie-ticket-package-filter-actions {
    grid-column: 3;
  }
}
@container movie-ticket-code-pool (max-width: 620px) {
  .movie-ticket-code-pool-filter-form {
    grid-template-columns: minmax(0, 1fr);
  }
  .movie-ticket-code-pool-filter-form .movie-ticket-package-filter-actions {
    grid-column: 1;
    grid-row: auto;
    display: flex;
    justify-content: flex-end;
  }
}
.movie-ticket-package-table {
  color: #111827;
  font-size: 12px;
talent-card-admin/src/views/BusinessManagementView.vue
@@ -173,10 +173,10 @@
</script>
<template>
  <div class="page-stack prototype-page business-fill-page">
  <div class="page-stack prototype-page business-fill-page" :class="{ 'role-management-page': isRole }">
    <section class="panel">
      <div class="panel-header"><h3>{{ title }}</h3></div>
      <el-form class="business-fill-filter-form" :class="{ 'role-management-filter-form': isRole, 'upgrade-management-filter-form': isUpgrade }" label-width="86px">
      <el-form class="business-fill-filter-form" :class="{ 'role-management-filter-form': isRole, 'upgrade-management-filter-form': isUpgrade }" :label-width="isRole ? undefined : '86px'">
        <el-form-item :label="isUpgrade ? '持卡人' : isRole ? '角色' : '员工'">
          <el-input v-model="filters.keyword" :placeholder="isUpgrade ? '持卡人姓名、手机号、卡号' : '请输入关键词'" clearable />
        </el-form-item>
talent-card-admin/src/views/MerchantCreateView.vue
@@ -125,6 +125,7 @@
const isApprovalMerchant = computed(() => merchantTypeCode.value === 'approval')
const isBookMerchant = computed(() => merchantTypeCode.value === 'culture')
const couponWithoutProducts = computed(() => isBookMerchant.value || isChildcareMerchant.value)
const couponStoreConsumeOnly = computed(() => couponWithoutProducts.value || isHotelMerchant.value)
const isServiceMerchant = computed(() => isFoodMerchant.value || merchantTypeCode.value === 'scenic')
const supportsStoreConsumption = computed(() => [
  'hotel', 'scenic', 'food', 'dental', 'culture', 'children',
@@ -212,6 +213,14 @@
  const merchantTypeCode = merchantTypeAliases[merchantType] || merchantType
  form.recommend = merchantTypeCode === 'food' || merchantTypeCode === 'scenic' ? 1 : 0
  form.sortNo = undefined
}
function requiresRightsTag() {
  return !(isCarWashMerchant.value && form.miniProgramJumpType === 'miniprogram') && !isApprovalMerchant.value
}
function hasMissingRightsTag() {
  return requiresRightsTag() && rightsConfigs.some((item) => !String(item.tag || '').trim())
}
const tabs = computed(() => {
@@ -588,7 +597,7 @@
  }],
  validTime: [{ required: true, message: '请选择优惠券有效期', trigger: 'change' }],
  productScope: [{
    validator: (_rule, value, callback) => couponWithoutProducts.value || value !== 'selected' || selectedCouponProductIds.value.length > 0
    validator: (_rule, value, callback) => couponStoreConsumeOnly.value || value !== 'selected' || selectedCouponProductIds.value.length > 0
      ? callback()
      : callback(new Error('请至少选择一个可用商品')),
    trigger: 'change',
@@ -1707,6 +1716,11 @@
    return
  }
  if (hasMissingRightsTag()) {
    ElMessage.warning('请填写各个人才卡等级的标签')
    return
  }
  rightsSaving.value = true
  try {
    await updateMerchant(merchantId, {
@@ -2078,7 +2092,7 @@
}
async function loadCouponProducts() {
  if (couponWithoutProducts.value) {
  if (couponStoreConsumeOnly.value) {
    couponProducts.value = []
    selectedCouponProductIds.value = []
    return
@@ -2112,7 +2126,7 @@
  couponEditForm.couponAmount = ''
  couponEditForm.talentLevels = ['卓越人才卡', '杰出人才卡', '菁英人才卡']
  couponEditForm.validTime = []
  couponEditForm.productScope = couponWithoutProducts.value ? 'all' : 'selected'
  couponEditForm.productScope = couponStoreConsumeOnly.value ? 'all' : 'selected'
  couponEditForm.storeConsume = true
  couponEditForm.governmentSubsidy = true
  couponEditForm.status = 1
@@ -2140,7 +2154,7 @@
  try { savedQuantities = typeof detail.talentLevelQuantities === 'string' ? JSON.parse(detail.talentLevelQuantities) : (detail.talentLevelQuantities || {}) } catch { savedQuantities = {} }
  couponEditForm.talentLevels.forEach((level) => { couponLevelQuantities[level] = Number(savedQuantities[level] || 1) })
  couponEditForm.validTime = detail.validStart && detail.validEnd ? [detail.validStart, detail.validEnd] : []
  couponEditForm.productScope = couponWithoutProducts.value || detail.productScope === 'ALL' ? 'all' : 'selected'
  couponEditForm.productScope = couponStoreConsumeOnly.value || detail.productScope === 'ALL' ? 'all' : 'selected'
  couponEditForm.storeConsume = detail.storeConsume === true || detail.storeConsume === 1
  couponEditForm.governmentSubsidy = detail.governmentSubsidy === true || detail.governmentSubsidy === 1
  couponEditForm.status = detail.status ?? 1
@@ -2212,9 +2226,9 @@
    talentLevelQuantities: Object.fromEntries(couponEditForm.talentLevels.map((level) => [level, Number(couponLevelQuantities[level] || 1)])),
    validStart: couponEditForm.validTime?.[0],
    validEnd: couponEditForm.validTime?.[1],
    productScope: couponWithoutProducts.value || couponEditForm.productScope === 'all' ? 'ALL' : 'SELECTED',
    productIds: couponWithoutProducts.value || couponEditForm.productScope === 'all' ? [] : selectedCouponProductIds.value,
    storeConsume: couponWithoutProducts.value || couponEditForm.storeConsume,
    productScope: couponStoreConsumeOnly.value || couponEditForm.productScope === 'all' ? 'ALL' : 'SELECTED',
    productIds: couponStoreConsumeOnly.value || couponEditForm.productScope === 'all' ? [] : selectedCouponProductIds.value,
    storeConsume: couponStoreConsumeOnly.value || couponEditForm.storeConsume,
    governmentSubsidy: couponEditForm.governmentSubsidy,
    status: couponEditForm.status,
  }
@@ -2530,11 +2544,6 @@
            </el-form-item>
            <template v-if="!isApartmentMerchant && !usesSimplifiedMerchantSettings">
              <div class="merchant-form-row recommend-row">
                <label>开启团购</label>
                <el-switch v-model="form.groupBuyingEnabled" :active-value="1" :inactive-value="0" />
              </div>
              <div class="merchant-form-row">
                <label>服务标签</label>
                <el-input v-model="form.serviceLabel" placeholder="如:到店消费" />
@@ -2636,11 +2645,6 @@
      <section class="merchant-rights-panel" :class="{ 'movie-merchant-rights-panel': isMovieMerchant }">
        <h3 v-if="isMovieMerchant" class="merchant-rights-section-title">商家权益</h3>
        <div v-if="!isApartmentMerchant && !isCarWashMerchant && !isHealthCheckMerchant && !isParkingMerchant && !isChildcareMerchant && !isFinancialMerchant && !isMovieMerchant && !isSpouseEmploymentMerchant && !isApprovalMerchant" class="merchant-rights-top-row">
          <strong>团单服务</strong>
          <el-switch v-model="form.groupBuyingEnabled" :active-value="1" :inactive-value="0" />
        </div>
        <div v-if="isCarWashMerchant" class="merchant-rights-top-row carwash-miniprogram-row">
          <el-radio-group v-model="form.miniProgramJumpType" class="carwash-jump-radio">
            <el-radio label="miniprogram">小程序跳转</el-radio>
@@ -2656,8 +2660,10 @@
          <div class="merchant-rights-level-row">
            <strong>{{ item.level }}</strong>
            <el-switch v-model="item.enabled" />
            <span>标签</span>
            <el-input v-model="item.tag" placeholder="请输入" />
            <template v-if="requiresRightsTag()">
              <span><i class="merchant-rights-required">*</i>标签</span>
              <el-input v-model="item.tag" placeholder="请输入" />
            </template>
          </div>
          <div class="merchant-rights-detail-row">
@@ -2692,7 +2698,7 @@
                        <span>券</span>
                      </div>
                    </div>
                    <div class="phone-preview-tag">{{ item.tag || '人才卡专享' }}</div>
                    <div class="phone-preview-tag">{{ item.tag || '请填写标签' }}</div>
                    <div class="phone-preview-address">
                      <span>⌖</span>
                      <p>{{ form.address || '请填写商家地址' }}</p>
@@ -3470,7 +3476,7 @@
              />
            </el-form-item>
            <div v-if="couponWithoutProducts" class="coupon-edit-row coupon-switch-row">
            <div v-if="couponStoreConsumeOnly" class="coupon-edit-row coupon-switch-row">
              <label>适用范围</label>
              <span>到店消费</span>
            </div>
@@ -3483,7 +3489,7 @@
              </el-radio-group>
            </el-form-item>
            <div v-if="!couponWithoutProducts" class="coupon-product-select-table">
            <div v-if="!couponStoreConsumeOnly" class="coupon-product-select-table">
              <el-table :data="couponProducts" border class="coupon-edit-product-table" :show-header="true">
                <el-table-column width="98" align="center">
                  <template #default="{ row }">
@@ -3511,7 +3517,7 @@
              </el-table>
            </div>
            <div v-if="!couponWithoutProducts" class="coupon-edit-row coupon-switch-row">
            <div v-if="!couponStoreConsumeOnly" class="coupon-edit-row coupon-switch-row">
              <label>是否到店消费可用</label>
              <el-switch v-model="couponEditForm.storeConsume" />
            </div>
talent-card-admin/src/views/MerchantView.vue
@@ -122,9 +122,37 @@
}
const productTabTypeCodes = new Set(['food', 'scenic', 'hotel', 'dental', 'medical'])
const merchantTypeDisplayNames: Record<string, string> = {
  food: '餐饮服务',
  scenic: '景区服务',
  hotel: '酒店服务',
  apartment: '公寓租赁',
  carWash: '自助洗车',
  medical: '免费体检',
  parking: '停车服务',
  fuel: '加油服务',
  children: '子女托管',
  finance: '金融服务',
  employment: '配偶推荐就业',
  approval: '行政审批',
  cinema: '观影服务',
  housing: '购房优惠',
  car: '购车服务',
  appliance: '家电服务',
  digital: '数码服务',
  culture: '文化服务',
  telecom: '电信服务',
  dental: '口腔治疗保健',
}
function resolveMerchantTypeCode(row: Merchant) {
  const raw = String(row.merchantTypeCode || row.merchantType || '').trim()
  return merchantTypeAliases[raw] || raw
}
function displayMerchantType(row: Merchant) {
  const code = resolveMerchantTypeCode(row)
  return merchantTypeDisplayNames[code] || displayDash(row.merchantType)
}
function parseMerchantRights(config: Merchant['merchantRightsConfig']) {
@@ -371,7 +399,7 @@
          <template #default="{ row }">{{ displayDash(row.address) }}</template>
        </el-table-column>
        <el-table-column prop="merchantType" label="商家类型" width="112">
          <template #default="{ row }">{{ displayDash(row.merchantType) }}</template>
          <template #default="{ row }">{{ displayMerchantType(row) }}</template>
        </el-table-column>
        <el-table-column prop="orderCount" label="订单总数" width="76" align="center">
          <template #default="{ row }">{{ displayDash(row.orderCount) }}</template>
@@ -391,13 +419,21 @@
        <el-table-column label="状态" width="68" align="center">
          <template #default="{ row }">{{ statusText(row.status) }}</template>
        </el-table-column>
        <el-table-column label="操作" width="174" fixed="right" align="center">
        <el-table-column label="操作" width="220" fixed="right" align="center">
          <template #default="{ row }">
            <el-button v-permission="'admin.merchants.disable'" link :type="row.status === 0 ? 'success' : 'danger'" @click="toggleStatus(row)">
              {{ row.status === 0 ? '启用' : '禁用' }}
            </el-button>
            <el-button v-if="supportsGroupBuying(row)" link type="primary" :icon="Goods" @click="openProducts(row)">团购管理</el-button>
            <el-button v-permission="'admin.merchants.edit'" link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button>
            <div class="merchant-action-cell">
              <div>
                <el-button v-permission="'admin.merchants.disable'" link :type="row.status === 0 ? 'success' : 'danger'" @click="toggleStatus(row)">
                  {{ row.status === 0 ? '启用' : '禁用' }}
                </el-button>
              </div>
              <div>
                <el-button v-permission="'admin.merchants.edit'" link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button>
              </div>
              <div>
                <el-button v-if="supportsGroupBuying(row)" link type="primary" :icon="Goods" @click="openProducts(row)">团购管理</el-button>
              </div>
            </div>
          </template>
        </el-table-column>
      </el-table>
talent-card-admin/src/views/MovieTicketView.vue
@@ -537,26 +537,28 @@
      </template>
      <template v-else-if="mode === 'codes'">
        <el-form class="movie-ticket-package-filter-form" :model="codeFilters" label-width="72px">
          <el-form-item label="兑换码">
            <el-input v-model="codeFilters.keyword" placeholder="码ID、兑换码" clearable />
          </el-form-item>
          <el-form-item label="票包">
            <el-input v-model="codeFilters.packageKeyword" placeholder="票包名称、编号" clearable />
          </el-form-item>
          <el-form-item label="适用商家">
            <el-input v-model="codeFilters.merchantKeyword" placeholder="商家名称" clearable />
          </el-form-item>
          <el-form-item label="状态">
            <el-select v-model="codeFilters.status" placeholder="全部" clearable>
              <el-option v-for="item in codeStatuses" :key="item.value" :label="item.label" :value="item.value" />
            </el-select>
          </el-form-item>
          <div class="movie-ticket-package-filter-actions">
            <el-button type="primary" :icon="Search" @click="search">查询</el-button>
            <el-button :icon="Refresh" @click="resetCodeFilters">重置</el-button>
          </div>
        </el-form>
        <div class="movie-ticket-code-pool-filters">
          <el-form class="movie-ticket-package-filter-form movie-ticket-code-pool-filter-form" :model="codeFilters" label-width="72px">
            <el-form-item label="兑换码">
              <el-input v-model="codeFilters.keyword" placeholder="码ID、兑换码" clearable />
            </el-form-item>
            <el-form-item label="票包">
              <el-input v-model="codeFilters.packageKeyword" placeholder="票包名称、编号" clearable />
            </el-form-item>
            <el-form-item label="适用商家">
              <el-input v-model="codeFilters.merchantKeyword" placeholder="商家名称" clearable />
            </el-form-item>
            <el-form-item label="状态">
              <el-select v-model="codeFilters.status" placeholder="全部" clearable>
                <el-option v-for="item in codeStatuses" :key="item.value" :label="item.label" :value="item.value" />
              </el-select>
            </el-form-item>
            <div class="movie-ticket-package-filter-actions">
              <el-button type="primary" :icon="Search" @click="search">查询</el-button>
              <el-button :icon="Refresh" @click="resetCodeFilters">重置</el-button>
            </div>
          </el-form>
        </div>
        <div class="movie-ticket-package-tools movie-ticket-code-pool-tools">
          <el-button v-permission="'admin.movie-tickets.import'" type="primary" @click="openImport(selectedPackage || {})">导入码</el-button>
          <el-button @click="openGenerate(selectedPackage || {})">批量生成</el-button>
talent-card-admin/src/views/TalentCreateView.vue
@@ -10,7 +10,6 @@
const saving = ref(false)
const formRef = ref<FormInstance>()
const cardFormRef = ref<FormInstance>()
const serviceStaff = ref<number | string | undefined>()
const staff = ref<AdminStaff[]>([])
const talentLevelConfirmed = ref(false)
const cardDialogVisible = ref(false)
@@ -21,7 +20,7 @@
const levels = ['卓越人才卡', '杰出人才卡', '菁英人才卡', '普通游客']
const form = reactive<TalentPayload>({
const form = reactive<TalentPayload & { serviceStaff?: number | string }>({
  name: '',
  mobile: '',
  idCardNo: '',
@@ -32,6 +31,7 @@
  sourceAreaType: 'LOCAL',
  workAddress: '',
  contactAddress: '',
  serviceStaff: undefined,
})
const rules: FormRules = {
@@ -53,10 +53,7 @@
      : callback(new Error('请先确认人才等级、来源地和人才卡号')),
    trigger: 'change',
  }],
  serviceStaff: [{
    validator: (_rule, _value, callback) => serviceStaff.value ? callback() : callback(new Error('请选择专属服务人员')),
    trigger: 'change',
  }],
  serviceStaff: [{ required: true, message: '请选择专属服务人员', trigger: 'change' }],
}
const cardRules: FormRules = {
  talentLevel: [{ required: true, message: '请选择人才等级', trigger: 'change' }],
@@ -109,8 +106,9 @@
  saving.value = true
  try {
    // 后端仍保留 workAddress 历史字段;新建时同步写入,保证新旧端均可回显。
    const talent = await createTalent({ ...form, workAddress: form.workUnit })
    await updateTalentServiceStaff(talent.id, serviceStaff.value)
    const { serviceStaff, ...talentForm } = form
    const talent = await createTalent({ ...talentForm, workAddress: talentForm.workUnit })
    await updateTalentServiceStaff(talent.id, serviceStaff)
    ElMessage.success('持卡人已保存')
    router.push('/talents')
  } finally {
@@ -201,7 +199,7 @@
      <h3>专属服务人员</h3>
      <el-form-item prop="serviceStaff" class="service-staff-form-item" required label-width="0">
      <div class="service-staff-panel">
        <el-select v-model="serviceStaff" clearable placeholder="请选择专属服务人员" @change="formRef?.validateField('serviceStaff')">
        <el-select v-model="form.serviceStaff" clearable placeholder="请选择专属服务人员">
          <el-option
            v-for="item in staff"
            :key="item.id"
talent-card-server/src/main/java/com/talentcard/server/business/merchant/ManualConsumptionService.java
@@ -161,18 +161,41 @@
    }
    private List<Map<String, Object>> rightsConfigs(Long merchantId) {
        List<Map<String, Object>> merchants = rows("select merchant_rights_config from merchant where id = ? and deleted = 0 limit 1", merchantId);
        List<Map<String, Object>> merchants = rows("select merchant_type_code, merchant_rights_config from merchant where id = ? and deleted = 0 limit 1", merchantId);
        if (merchants.isEmpty()) return List.of();
        Object raw = merchants.get(0).get("merchantRightsConfig");
        if (raw == null) {
            for (Map.Entry<String, Object> entry : merchants.get(0).entrySet()) {
                if (entry.getKey() != null && entry.getKey().toLowerCase().contains("rights")) {
                    raw = entry.getValue();
                    break;
                }
        List<Map<String, Object>> configs = parseRightsConfigs(merchants.get(0).get("merchantRightsConfig"));
        if (!"parking".equalsIgnoreCase(string(merchants.get(0).get("merchantTypeCode")))) return configs;
        // Parking's dedicated card-level configuration takes precedence over legacy JSON.
        List<Map<String, Object>> parkingRights = rows("""
                select card_level as level, enabled, label as tag, benefit_detail as detail,
                       charge_type, store_consume_enabled, discount_text, government_subsidy,
                       consume_usage_text as consume_desc, status
                from transport_parking_card_level_right
                where merchant_id = ? and deleted = 0
                """, merchantId);
        for (Map<String, Object> parkingRight : parkingRights) {
            String level = string(parkingRight.get("level"));
            Map<String, Object> merged = configs.stream()
                    .filter(right -> matchesCardLevel(string(right.get("level")), level))
                    .findFirst().map(LinkedHashMap::new).orElseGet(LinkedHashMap::new);
            for (Map.Entry<String, Object> entry : parkingRight.entrySet()) {
                Object value = entry.getValue();
                if (value != null && (!(value instanceof String text) || !text.isBlank())) merged.put(entry.getKey(), value);
            }
            merged.put("enabled", booleanValue(parkingRight.get("enabled")) && booleanValue(parkingRight.get("status")));
            merged.put("parkingType", "PAID".equalsIgnoreCase(string(parkingRight.get("chargeType"))) ? "paid" : "free");
            merged.put("storeConsume", booleanValue(parkingRight.get("storeConsumeEnabled")));
            String discount = string(parkingRight.get("discountText")).replace("折", "").trim();
            if (!discount.isBlank()) merged.put("discount", discount);
            configs.removeIf(right -> matchesCardLevel(string(right.get("level")), level));
            configs.add(merged);
        }
        if (raw == null || string(raw).isBlank()) return List.of();
        return configs;
    }
    private List<Map<String, Object>> parseRightsConfigs(Object raw) {
        if (raw == null || string(raw).isBlank()) return new ArrayList<>();
        String json = raw instanceof byte[] bytes ? new String(bytes, java.nio.charset.StandardCharsets.UTF_8) : string(raw);
        List<Map<String, Object>> configs = new ArrayList<>();
        try {
@@ -190,7 +213,7 @@
                }
            }
        } catch (Exception ignored) {
            return List.of();
            return new ArrayList<>();
        }
        return configs;
    }
@@ -207,7 +230,8 @@
    boolean configuredStoreConsumptionEnabled(Map<String, Object> right) {
        if (!booleanValue(right.get("storeConsume"))) return false;
        return booleanValue(right.get("consumeEnabled")) || "paid".equalsIgnoreCase(string(right.get("parkingType")));
        if (!string(right.get("parkingType")).isBlank()) return "paid".equalsIgnoreCase(string(right.get("parkingType")));
        return booleanValue(right.get("consumeEnabled"));
    }
    Map<String, Object> configuredRule(Map<String, Object> right) {
@@ -241,9 +265,10 @@
    }
    private List<Map<String, Object>> coupons(Long merchantId, Long talentId, String cardLevel) {
        if (!storeConsumptionCouponsEnabled(merchantId)) return List.of();
        return rows("""
                select c.id, c.coupon_name as name, c.coupon_type as couponType, c.coupon_amount as couponAmount, c.talent_levels as talentLevels,
                       c.condition_type as conditionType, c.condition_amount as conditionAmount, c.valid_end as validEnd
                select c.id, c.coupon_name as name, c.coupon_type, c.coupon_amount, c.talent_levels,
                       c.condition_type, c.condition_amount, c.valid_end
                from merchant_coupon c
                where c.merchant_id = ? and c.deleted = 0 and c.status = 1 and c.store_consume = 1
                  and (c.valid_start is null or c.valid_start <= current_date) and (c.valid_end is null or c.valid_end >= current_date)
@@ -260,9 +285,12 @@
    private Map<String, Object> usableCoupon(Long merchantId, Long talentId, String cardLevel, Long couponId, BigDecimal amount) {
        if (couponId == null) return null;
        if (!storeConsumptionCouponsEnabled(merchantId)) {
            throw new BusinessException(ErrorCode.CONFLICT, "文化服务到店消费仅支持人才卡折扣,不支持优惠券");
        }
        List<Map<String,Object>> found = rows("""
                select c.id, c.coupon_amount as couponAmount, c.condition_type as conditionType, c.condition_amount as conditionAmount,
                       c.talent_levels as talentLevels
                select c.id, c.coupon_amount, c.condition_type, c.condition_amount,
                       c.talent_levels
                from merchant_coupon c
                where c.id = ? and c.merchant_id = ? and c.deleted = 0 and c.status = 1 and c.store_consume = 1
                  and (c.valid_start is null or c.valid_start <= current_date) and (c.valid_end is null or c.valid_end >= current_date)
@@ -279,6 +307,11 @@
        return coupon;
    }
    private boolean storeConsumptionCouponsEnabled(Long merchantId) {
        List<Map<String, Object>> merchants = rows("select merchant_type_code from merchant where id = ? and deleted = 0 limit 1", merchantId);
        return !merchants.isEmpty() && !"culture".equalsIgnoreCase(string(merchants.get(0).get("merchantTypeCode")));
    }
    private boolean supportsCard(String levels, String cardLevel) {
        if (levels == null || levels.isBlank()) return true;
        return java.util.Arrays.stream(levels.split("[,、,]" )).anyMatch(v -> matchesCardLevel(v, cardLevel));
@@ -290,7 +323,8 @@
    private BigDecimal configuredDiscountRate(Map<String, Object> right) {
        try {
            Object value = "折扣".equals(string(right.get("consumeType"))) ? right.get("consumeDiscount") : right.get("discount");
            Object value = !string(right.get("parkingType")).isBlank() ? right.get("discount")
                    : "折扣".equals(string(right.get("consumeType"))) ? right.get("consumeDiscount") : right.get("discount");
            BigDecimal discount = decimal(string(value));
            return discount.signum() > 0 && discount.compareTo(BigDecimal.TEN) <= 0 ? discount.divide(BigDecimal.TEN, 4, RoundingMode.HALF_UP) : BigDecimal.ZERO;
        } catch (Exception ignored) {
@@ -299,6 +333,7 @@
    }
    private BigDecimal configuredFixedReduction(Map<String, Object> right) {
        if (!string(right.get("parkingType")).isBlank()) return BigDecimal.ZERO;
        if (!"优惠".equals(string(right.get("consumeType")))) return BigDecimal.ZERO;
        try {
            BigDecimal reduction = decimal(string(right.get("consumeCoupon")));
@@ -309,6 +344,7 @@
    }
    private boolean configuredSubsidyEnabled(Map<String, Object> right) {
        if (!string(right.get("parkingType")).isBlank()) return booleanValue(right.get("governmentSubsidy"));
        String consumeType = string(right.get("consumeType"));
        if ("折扣".equals(consumeType)) return booleanValue(right.get("consumeDiscountSubsidy"));
        if ("优惠".equals(consumeType)) return booleanValue(right.get("consumeCouponSubsidy"));
@@ -384,6 +420,15 @@
    }
    private String benefitText(Long merchantId, String cardLevel, String sourceAreaType) {
        List<Map<String, Object>> merchants = rows("select merchant_type_code from merchant where id = ? and deleted = 0 limit 1", merchantId);
        String merchantType = merchants.isEmpty() ? "" : string(merchants.get(0).get("merchantTypeCode"));
        if ("children".equalsIgnoreCase(merchantType) || "culture".equalsIgnoreCase(merchantType)) {
            List<Map<String, Object>> configs = rightsConfigs(merchantId).stream()
                    .filter(right -> booleanValue(right.get("enabled")))
                    .toList();
            return firstRightsText(configs, cardLevel, sourceAreaType,
                    "culture".equalsIgnoreCase(merchantType) ? "consumeDesc" : "detail");
        }
        String configuredDescription = consumeDescFromRights(merchantId, cardLevel, sourceAreaType);
        if (!configuredDescription.isBlank()) return configuredDescription;
        List<Map<String, Object>> benefits = rows("""
@@ -435,6 +480,10 @@
    /** Build the displayed benefit from the same structured fields used for pricing. */
    private String configuredBenefitText(Map<String, Object> right) {
        if (!string(right.get("parkingType")).isBlank()) {
            BigDecimal rate = configuredDiscountRate(right);
            return rate.signum() > 0 ? "到店消费" + rate.movePointRight(1).stripTrailingZeros().toPlainString() + "折" : "";
        }
        List<String> parts = new ArrayList<>();
        String consumeType = string(right.get("consumeType"));
        if ("优惠".equals(consumeType)) {
talent-card-server/src/main/java/com/talentcard/server/business/portal/CinemaPortalService.java
@@ -47,10 +47,12 @@
        result.put("longitude", merchant.get("longitude"));
        result.put("available", number(merchant.get("status")) == 1);
        result.put("serviceLabel", text(merchant.get("serviceLabel"), "人才观影券"));
        result.put("talentCardTag", rightsTag(displayRight, merchant));
        // 游客没有人才卡等级时不展示人才卡标签,只保留权益说明内容。
        boolean hasCardLevel = cardLevel != null && !cardLevel.isBlank();
        result.put("talentCardTag", hasCardLevel ? rightsTag(displayRight) : "");
        result.put("rightsTitle", rightsTitle(displayRight));
        result.put("rightsDescription", rightsDescription(displayRight, merchant));
        result.put("applicableCards", applicableCards(rights, displayRight, ticketPackage));
        result.put("applicableCards", hasCardLevel ? rightsTag(displayRight) : "");
        result.put("claimPrice", claimPriceText(ticketPackage));
        result.put("usage", usageText(ticketPackage));
        result.put("scope", scopeText(ticketPackage, merchant));
@@ -331,12 +333,8 @@
        return rows.isEmpty() ? new LinkedHashMap<>() : rows.get(0);
    }
    private String rightsTag(Map<String, Object> right, Map<String, Object> merchant) {
        if (right != null) {
            String tag = text(right.get("tag"), "");
            if (!tag.isBlank()) return tag;
        }
        return text(merchant.get("serviceLabel"), "人才卡专享");
    private String rightsTag(Map<String, Object> right) {
        return right == null ? "" : text(right.get("tag"), "");
    }
    private String rightsTitle(Map<String, Object> right) {
@@ -363,25 +361,6 @@
        detail = text(right.get("rightsText"), "");
        if (!detail.isBlank()) return detail;
        return text(right.get("benefit_text"), "");
    }
    private String applicableCards(List<Map<String, Object>> rights, Map<String, Object> right, Map<String, Object> ticketPackage) {
        if (!rights.isEmpty()) {
            List<String> levels = rights.stream()
                    .filter(item -> booleanValue(item.get("enabled")))
                    .map(item -> normalizedCardLevel(text(item.get("level"), "")))
                    .filter(level -> !level.isBlank())
                    .distinct()
                    .toList();
            if (!levels.isEmpty()) return String.join("、", levels);
        }
        if (right != null) {
            String level = normalizedCardLevel(text(right.get("level"), ""));
            if (!level.isBlank()) return level;
        }
        String packageLevels = text(ticketPackage.get("talentLevels"), "");
        if (!packageLevels.isBlank()) return packageLevels;
        return "卓越、杰出、菁英人才卡";
    }
    private String claimPriceText(Map<String, Object> ticketPackage) {
talent-card-server/src/main/java/com/talentcard/server/business/portal/FinanceMerchantPortalService.java
@@ -43,7 +43,7 @@
        result.put("merchantName", row.get("merchantName"));
        result.put("coverUrl", row.get("coverUrl"));
        result.put("benefitSummary", text(row.get("serviceLabel"), "请咨询服务专员"));
        result.put("applicableCards", text(matchedRight.get("tag"), text(row.get("talentLevels"), "请咨询服务专员")));
        result.put("applicableCards", text(matchedRight.get("tag"), ""));
        result.put("address", text(row.get("address"), "暂未配置机构地址"));
        result.put("contactMobile", text(row.get("contactMobile"), ""));
        result.put("longitude", row.get("longitude"));
talent-card-server/src/main/java/com/talentcard/server/business/portal/FuelMerchantPortalService.java
@@ -48,10 +48,11 @@
        result.put("latitude", merchant.get("latitude"));
        result.put("longitude", merchant.get("longitude"));
        result.put("available", available);
        result.put("serviceLabel", text(merchant.get("serviceLabel"), "请咨询商户"));
        result.put("discountText", text(benefit.get("discountText"), "请咨询商户"));
        result.put("serviceLabel", "");
        result.put("discountText", "");
        result.put("benefitTags", List.of());
        result.put("rightsTag", text(right == null ? null : right.get("tag"), ""));
        result.put("applicableCards", text(right == null ? null : right.get("tag"), text(benefit.get("talentLevels"), "请咨询商户")));
        result.put("applicableCards", text(right == null ? null : right.get("tag"), ""));
        result.put("groupBuyingEnabled", false);
        String configuredDetail = text(right == null ? null : right.get("detail"), "");
        result.put("usageInstructions", text(configuredDetail, text(benefit.get("usageInstructions"), "请咨询商户")));
talent-card-server/src/main/java/com/talentcard/server/business/portal/ParkingMerchantPortalService.java
@@ -1,5 +1,7 @@
package com.talentcard.server.business.portal;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
@@ -49,9 +51,15 @@
        result.put("longitude", merchant.get("longitude"));
        result.put("available", available);
        result.put("unavailableReason", available ? "" : unavailableReason(merchant, right));
        result.put("serviceLabel", text(merchant.get("serviceLabel"), "停车服务"));
        // 游客没有人才卡等级,不具备可展示的权益标签,避免把默认权益当成该用户的标签。
        boolean hasCardLevel = !cardLevel.isBlank();
        List<String> benefitTags = hasCardLevel ? parkingBenefitTags(merchantId, right) : List.of();
        String displayTag = benefitTags.isEmpty() ? "" : benefitTags.get(0);
        result.put("serviceLabel", displayTag);
        result.put("discountText", displayTag);
        result.put("benefitTags", benefitTags);
        result.put("benefitText", benefitText(merchant, right, benefit));
        result.put("applicableCards", applicableCards(right, benefit));
        result.put("applicableCards", hasCardLevel ? applicableCards(right, benefit) : "");
        result.put("usageInstructions", usageInstructions(merchant, right));
        result.put("rightsText", rightsText(merchant, right));
        String parkingType = text(right == null ? null : right.get("parkingType"), "paid");
@@ -277,7 +285,7 @@
    }
    private String applicableCards(Map<String, Object> right, Map<String, Object> benefit) {
        return text(right == null ? null : right.get("level"), text(benefit.get("talentLevels"), "请咨询商户"));
        return text(right == null ? null : right.get("tag"), "");
    }
    private String rightsText(Map<String, Object> merchant, Map<String, Object> right) {
@@ -302,6 +310,42 @@
        return text(benefit.get("benefitText"), text(merchant.get("rightsText"), "免费停车"));
    }
    private List<String> parkingBenefitTags(Long merchantId, Map<String, Object> right) {
        if (right == null || !booleanValue(right.get("enabled")) || !rightStatusEnabled(right)) return List.of();
        String parkingType = text(right.get("parkingType"), "free");
        if ("free".equalsIgnoreCase(parkingType)) return List.of("免费");
        List<String> tags = new ArrayList<>();
        if (hasActiveMerchantCoupon(merchantId)) tags.add("券");
        boolean hasStoredConsumeSetting = right.containsKey("storeConsumeEnabled");
        boolean storeConsume = hasStoredConsumeSetting
                ? booleanValue(right.get("storeConsumeEnabled"))
                : booleanValue(right.get("storeConsume"));
        if (storeConsume) {
            String rawDiscount = text(right.get("discount"), text(right.get("discountText"), ""))
                    .replace("折", "").trim();
            try {
                BigDecimal discount = new BigDecimal(rawDiscount).stripTrailingZeros();
                if (discount.compareTo(BigDecimal.ZERO) > 0 && discount.compareTo(BigDecimal.TEN) <= 0) {
                    tags.add(discount.toPlainString() + "折");
                }
            } catch (NumberFormatException ignored) {
                // Invalid or missing discounts do not produce a display tag.
            }
        }
        return tags;
    }
    private boolean hasActiveMerchantCoupon(Long merchantId) {
        Integer count = jdbc.queryForObject("""
                select count(*) from merchant_coupon
                where merchant_id = ? and deleted = 0 and status = 1
                  and (valid_start is null or valid_start <= current_date)
                  and (valid_end is null or valid_end >= current_date)
                """, Integer.class, merchantId);
        return count != null && count > 0;
    }
    private String unavailableReason(Map<String, Object> merchant, Map<String, Object> right) {
        if (number(merchant.get("status")) != 1) return "停车服务当前不可用";
        if (right == null) return "当前人才卡暂无可用停车权益";
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -29,6 +29,9 @@
public class PortalService {
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
    private static final List<String> TAGLESS_MERCHANT_TYPES = List.of(
            "employment", "finance", "approval", "apartment", "fuel",
            "housing", "car", "appliance", "digital", "telecom");
    private final JdbcTemplate jdbcTemplate;
    public PortalService(JdbcTemplate jdbcTemplate) {
@@ -409,12 +412,21 @@
    private List<String> merchantListBenefitTags(Map<String, Object> item, Map<String, Object> row, Long talentId) {
        String merchantType = MerchantTypeCatalog.canonicalCode(textOrDefault(item.get("merchantType"), ""));
        if (TAGLESS_MERCHANT_TYPES.contains(merchantType)) return List.of();
        boolean couponAvailable = booleanValue(item.get("couponAvailable"));
        String discountText = textOrDefault(item.get("discountText"), "").trim();
        String cardLevel = activeProductCardLevel(talentId);
        String sourceAreaType = activeTalentSourceAreaType(talentId);
        Map<String, Object> right = matchingMerchantRight(
                merchantRightsConfigs(row.get("merchantRightsConfig")), cardLevel, sourceAreaType, true);
        List<Map<String, Object>> rights = merchantRightsConfigs(row.get("merchantRightsConfig"));
        Map<String, Object> right = matchingMerchantRight(rights, cardLevel, sourceAreaType, true);
        if ("parking".equals(merchantType)) {
            Long merchantId = item.get("id") instanceof Number number ? number.longValue() : null;
            return parkingBenefitTags(merchantId, rights, cardLevel, sourceAreaType);
        }
        if ("carWash".equals(merchantType)) {
            String tag = carWashListDiscountText(carWashExternalLinkType(rights), right);
            return tag.isBlank() ? List.of() : List.of(tag);
        }
        if (usesMerchantDetailHeaderTags(merchantType) || "cinema".equals(merchantType)) {
            return merchantBenefitTags(merchantType, couponAvailable, discountText, right);
        }
@@ -449,6 +461,7 @@
            Map<String, Object> right
    ) {
        String type = MerchantTypeCatalog.canonicalCode(merchantType);
        if (TAGLESS_MERCHANT_TYPES.contains(type)) return List.of();
        if ("cinema".equals(type)) {
            return couponAvailable ? List.of("券") : List.of();
        }
@@ -559,8 +572,9 @@
                from merchant
                """ + merchantWhere + " order by sort_no asc, id asc",
                "select count(*) from merchant" + merchantWhere, page, size);
        String cardLevel = "carWash".equals(requestedType) ? activeProductCardLevel(talentId) : null;
        String sourceAreaType = "carWash".equals(requestedType) ? activeTalentSourceAreaType(talentId) : "";
        boolean personalizedTags = "carWash".equals(requestedType) || "parking".equals(requestedType);
        String cardLevel = personalizedTags ? activeProductCardLevel(talentId) : null;
        String sourceAreaType = personalizedTags ? activeTalentSourceAreaType(talentId) : "";
        List<Map<String, Object>> records = result.records().stream().map(row -> {
            Map<String, Object> item = enrichMerchantSearchRow(row);
            item.put("merchantType", requestedType);
@@ -572,8 +586,17 @@
                item.put("externalLinkType", externalLinkType);
                item.put("discountText", discountText);
                item.put("benefitTagVisible", !discountText.isBlank());
            } else if ("fuel".equals(requestedType)) {
                item.put("discountText", "");
                item.put("benefitTags", List.of());
                item.put("benefitTagVisible", false);
            } else {
                item.put("discountText", textOrDefault(item.get("serviceLabel"), textOrDefault(item.get("discountText"), "人才专享")));
                Long merchantId = item.get("id") instanceof Number number ? number.longValue() : null;
                List<String> tags = parkingBenefitTags(
                        merchantId, merchantRightsConfigs(row.get("merchantRightsConfig")), cardLevel, sourceAreaType);
                item.put("discountText", tags.isEmpty() ? "" : tags.get(0));
                item.put("benefitTags", tags);
                item.put("benefitTagVisible", !tags.isEmpty());
            }
            item.remove("merchantRightsConfig");
            return item;
@@ -663,16 +686,11 @@
                item.put("rightsAvailable", true);
            }
            if (usesMerchantDetailHeaderTags(merchantType)) {
                String productDiscount = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId);
                item.put("discountText", productDiscount);
                item.put("benefitTags", merchantBenefitTags(merchantType, couponAvailable, productDiscount, configuredRight));
            } else {
                List<String> tags = new ArrayList<>();
                if (couponAvailable) tags.add("券");
                String storeDiscount = textOrDefault(item.get("discountText"), "").trim();
                if (!storeDiscount.isBlank() && !"人才专享".equals(storeDiscount)) tags.add(storeDiscount);
                item.put("benefitTags", tags);
                item.put("discountText", merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId));
            }
            List<String> displayTags = merchantListBenefitTags(item, row, talentId);
            item.put("benefitTags", displayTags);
            item.put("discountText", displayTags.isEmpty() ? "" : displayTags.get(0));
            item.remove("merchantRightsConfig");
            return item;
        }).toList();
@@ -690,6 +708,78 @@
        } catch (NumberFormatException ignored) {
            return "";
        }
    }
    private List<String> parkingBenefitTags(
            Long merchantId,
            List<Map<String, Object>> legacyRights,
            String cardLevel,
            String sourceAreaType
    ) {
        if (merchantId == null || cardLevel == null || cardLevel.isBlank()) return List.of();
        Map<String, Object> right = matchingMerchantRight(legacyRights, cardLevel, sourceAreaType, false);
        List<Map<String, Object>> configuredRows = query("""
                select enabled, label as tag, benefit_detail as detail, charge_type as charge_type,
                       store_consume_enabled as store_consume_enabled, discount_text as discount_text,
                       status as right_status
                from transport_parking_card_level_right
                where merchant_id = ? and card_level = ? and deleted = 0
                limit 1
                """, merchantId, cardLevel);
        if (!configuredRows.isEmpty()) right = mergeParkingRight(right, configuredRows.get(0));
        if (right == null
                || (right.containsKey("enabled") && !booleanValue(right.get("enabled")))
                || (right.get("rightStatus") != null && !booleanValue(right.get("rightStatus")))) {
            return List.of();
        }
        String chargeType = textOrDefault(right.get("chargeType"), "");
        String parkingType = textOrDefault(right.get("parkingType"),
                "PAID".equalsIgnoreCase(chargeType) ? "paid" : "free");
        if ("free".equalsIgnoreCase(parkingType)) return List.of("免费");
        List<String> tags = new ArrayList<>();
        if (hasActiveMerchantCoupon(merchantId)) tags.add("券");
        String discount = parkingDiscountText(right);
        if (!discount.isBlank()) tags.add(discount);
        return tags;
    }
    private String parkingDiscountText(Map<String, Object> right) {
        boolean hasStoredConsumeSetting = right.containsKey("storeConsumeEnabled");
        boolean storeConsume = hasStoredConsumeSetting
                ? booleanValue(right.get("storeConsumeEnabled"))
                : booleanValue(right.get("storeConsume"));
        if (!storeConsume) return "";
        String discount = textOrDefault(right.get("discount"), textOrDefault(right.get("discountText"), ""))
                .replace("折", "").trim();
        if (discount.isBlank()) return "";
        try {
            BigDecimal value = new BigDecimal(discount).stripTrailingZeros();
            if (value.compareTo(BigDecimal.ZERO) <= 0 || value.compareTo(BigDecimal.TEN) > 0) return "";
            return value.toPlainString() + "折";
        } catch (NumberFormatException ignored) {
            return "";
        }
    }
    private Map<String, Object> mergeParkingRight(Map<String, Object> legacyRight, Map<String, Object> configuredRight) {
        Map<String, Object> result = new LinkedHashMap<>();
        if (legacyRight != null) result.putAll(legacyRight);
        if (configuredRight != null) {
            for (Map.Entry<String, Object> entry : configuredRight.entrySet()) {
                Object value = entry.getValue();
                if (value != null && (!(value instanceof String text) || !text.isBlank())) result.put(entry.getKey(), value);
            }
            String chargeType = textOrDefault(configuredRight.get("chargeType"), "FREE");
            result.put("parkingType", "PAID".equalsIgnoreCase(chargeType) ? "paid" : "free");
            boolean storeConsume = booleanValue(configuredRight.get("storeConsumeEnabled"));
            result.put("storeConsume", storeConsume);
            result.put("consumeEnabled", storeConsume);
            String discount = textOrDefault(configuredRight.get("discountText"), "").replace("折", "").trim();
            if (!discount.isBlank()) result.put("discount", discount);
        }
        return result.isEmpty() ? null : result;
    }
    @Transactional(readOnly = true)
@@ -741,8 +831,12 @@
            Map<String, Object> item = enrichMerchantSearchRow(row);
            item.put("businessType", merchantType.premiumBusinessType());
            item.put("targetAction", merchantType.premiumBusinessType());
            item.put("benefitText", textOrDefault(row.get("serviceLabel"), "人才专享"));
            item.put("discountText", textOrDefault(row.get("serviceLabel"), "人才专享"));
            boolean tagVisible = !TAGLESS_MERCHANT_TYPES.contains(merchantType.code());
            String benefitText = tagVisible ? textOrDefault(row.get("serviceLabel"), "人才专享") : "";
            item.put("benefitText", benefitText);
            item.put("discountText", benefitText);
            item.put("benefitTags", tagVisible ? List.of(benefitText) : List.of());
            item.put("benefitTagVisible", tagVisible);
            return item;
        }).toList();
        return new PageResult<>(records, result.page(), result.size(), result.total());
@@ -874,11 +968,7 @@
        merchant.put("consumeCoupon", consumeCoupon);
        merchant.put("storeConsume", storeConsume);
        merchant.put("consumeEnabled", consumeEnabled);
        merchant.put("benefitTags", merchantBenefitTags(
                textOrDefault(merchant.get("merchantType"), ""),
                booleanValue(merchant.get("couponAvailable")),
                textOrDefault(merchant.get("discountText"), ""),
                configuredRight));
        merchant.put("benefitTags", merchantListBenefitTags(merchant, merchant, talentId));
        merchant.put("usageInstructions", merchantUsageInstructions(
                id, merchant.get("merchantRightsConfig"), cardLevel, merchant.get("usageInstructions")));
        List<String> heroImages = new ArrayList<>();
talent-card-server/src/main/java/com/talentcard/server/business/portal/ShoppingMerchantPortalService.java
@@ -61,7 +61,9 @@
        result.put("categoryId", row.get("categoryKey"));
        result.put("merchantName", row.get("merchantName"));
        result.put("coverUrl", row.get("coverUrl"));
        result.put("discountText", text(row.get("serviceLabel"), ""));
        List<String> displayTags = displayTags(configuredMerchant);
        result.put("displayTags", displayTags);
        result.put("discountText", displayTags.isEmpty() ? "" : displayTags.get(0));
        result.put("applicableCards", text(configuredMerchant.get("applicableCards"), text(configuredMerchant.get("rightsTag"), "")));
        result.put("address", text(row.get("address"), ""));
        result.put("contactMobile", text(row.get("contactMobile"), ""));
@@ -81,6 +83,18 @@
        return result;
    }
    private List<String> displayTags(Map<String, Object> configuredMerchant) {
        List<String> result = new java.util.ArrayList<>();
        Object value = configuredMerchant.get("benefitTags");
        if (value instanceof List<?> tags) {
            for (Object tag : tags) {
                String text = text(tag, "").trim();
                if (!text.isBlank() && !result.contains(text)) result.add(text);
            }
        }
        return result;
    }
    private Map<String, Object> accountAdvisor(Long talentId) {
        if (talentId == null) return Map.of("available", false);
        Object source = talentCardPortalService.profile(talentId).get("advisor");
talent-card-server/src/main/java/com/talentcard/server/business/portal/SpouseEmploymentPortalService.java
@@ -3,6 +3,7 @@
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.alibaba.fastjson.JSON;
@@ -33,10 +34,12 @@
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("benefitId", benefit.get("id") == null ? merchantId : benefit.get("id"));
        result.put("serviceId", benefit.get("merchantId"));
        Map<String, Object> matchedRight = matchedRight(benefit.get("merchantRightsConfig"), activeCardLevel(talentId));
        result.put("title", benefit.get("title"));
        result.put("heroImage", benefit.get("coverUrl"));
        result.put("freeLabel", benefit.get("freeLabel"));
        result.put("rulesText", configuredRulesText(benefit, talentId));
        result.put("applicableCards", text(matchedRight.get("tag"), ""));
        result.put("rulesText", text(matchedRight.get("detail"), text(benefit.get("rulesText"), "请咨询服务单位了解配偶推荐就业权益详情")));
        result.put("consultationPhone", benefit.get("consultationPhone"));
        result.put("available", number(benefit.get("enabled")) == 1);
        result.put("unavailableReason", text(benefit.get("unavailableReason"), "该权益暂未开放"));
@@ -120,24 +123,33 @@
        return rows.get(0);
    }
    private String configuredRulesText(Map<String, Object> benefit, Long talentId) {
        String cardLevel = activeCardLevel(talentId);
        String config = text(benefit.get("merchantRightsConfig"), "");
        if (!cardLevel.isBlank() && !config.isBlank()) {
            try {
                List<Map<String, Object>> rights = JSON.parseObject(config, new com.alibaba.fastjson.TypeReference<>() {});
                if (rights != null) {
                    for (Map<String, Object> right : rights) {
                        if (!booleanValue(right.get("enabled")) || !cardLevel.equals(text(right.get("level"), ""))) continue;
                        String detail = text(right.get("detail"), "");
                        if (!detail.isBlank()) return detail;
                    }
                }
            } catch (RuntimeException ignored) {
                // Fall back to the merchant-level rights description below.
    private Map<String, Object> matchedRight(Object config, String cardLevel) {
        if (cardLevel.isBlank() || config == null) return Map.of();
        String raw = text(config, "");
        if (raw.isBlank()) return Map.of();
        try {
            List<Map<String, Object>> rights = JSON.parseObject(raw, new com.alibaba.fastjson.TypeReference<>() {});
            if (rights == null) return Map.of();
            for (Map<String, Object> right : rights) {
                if (booleanValue(right.get("enabled")) && sameCardLevel(right.get("level"), cardLevel)) return right;
            }
        } catch (RuntimeException ignored) {
            // Fall back to the merchant-level rights description below.
        }
        return text(benefit.get("rulesText"), "请咨询服务单位了解配偶推荐就业权益详情");
        return Map.of();
    }
    private boolean sameCardLevel(Object configured, String actual) {
        String left = normalizedCardLevel(configured);
        return !left.isBlank() && left.equals(normalizedCardLevel(actual));
    }
    private String normalizedCardLevel(Object value) {
        String level = value == null ? "" : String.valueOf(value).trim().toUpperCase(Locale.ROOT);
        if (level.contains("卓越") || "EXCELLENT".equals(level)) return "EXCELLENT";
        if (level.contains("杰出") || "OUTSTANDING".equals(level)) return "OUTSTANDING";
        if (level.contains("菁英") || "ELITE".equals(level)) return "ELITE";
        return "";
    }
    private String activeCardLevel(Long talentId) {
talent-card-server/src/test/java/com/talentcard/server/business/merchant/ManualConsumptionServiceTests.java
@@ -1,6 +1,7 @@
package com.talentcard.server.business.merchant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
@@ -9,6 +10,8 @@
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
@@ -21,7 +24,7 @@
    @BeforeEach
    void setUp() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(
                "jdbc:h2:mem:manual-consumption-" + System.nanoTime() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
                "jdbc:h2:mem:manual-consumption-" + System.nanoTime() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", "sa", "");
        jdbc = new JdbcTemplate(dataSource);
        jdbc.execute("""
                create table talent_dynamic_code (
@@ -34,6 +37,7 @@
        jdbc.execute("""
                create table merchant (
                    id bigint primary key,
                    merchant_type_code varchar(64),
                    merchant_rights_config varchar(4000),
                    deleted tinyint not null default 0
                )
@@ -84,6 +88,221 @@
        assertThat(service.configuredStoreConsumptionEnabled(right)).isTrue();
        right.put("parkingType", "free");
        assertThat(service.configuredStoreConsumptionEnabled(right)).isFalse();
        right.put("consumeEnabled", true);
        assertThat(service.configuredStoreConsumptionEnabled(right)).isFalse();
    }
    @Test
    void paidParkingUsesParkingDiscountInsteadOfUnrelatedConsumeFields() {
        Map<String, Object> right = configuredRight("卓越人才卡", "6");
        right.put("parkingType", "paid");
        right.put("consumeType", "优惠");
        right.put("consumeCoupon", "8");
        right.put("consumeDiscount", "9");
        Map<String, Object> rule = service.configuredRule(right);
        assertThat(rule).containsEntry("benefitText", "到店消费6折");
        assertThat(service.quote(rule, null, new BigDecimal("10")))
                .containsEntry("payAmount", 600L).containsEntry("discountAmount", 400L);
        right.put("consumeType", "折扣");
        assertThat(service.configuredRule(right)).containsEntry("benefitText", "到店消费6折");
    }
    @Test
    void parkingDedicatedRightsOverrideLegacyAndRespectDisabledAndFreeLevels() {
        jdbc.execute("""
                create table transport_parking_card_level_right (
                    merchant_id bigint, card_level varchar(64), enabled tinyint, label varchar(128),
                    benefit_detail varchar(500), charge_type varchar(32), store_consume_enabled tinyint,
                    discount_text varchar(64), government_subsidy tinyint, consume_usage_text varchar(500),
                    status tinyint, deleted tinyint default 0
                )
                """);
        jdbc.update("insert into merchant(id, merchant_type_code, merchant_rights_config) values (88, 'parking', null)");
        jdbc.update("""
                insert into transport_parking_card_level_right
                    (merchant_id, card_level, enabled, charge_type, store_consume_enabled, discount_text, status)
                values (88, '卓越人才卡', 1, 'PAID', 1, '6折', 1)
                """);
        assertThat(service.hasApplicableRule(88L, "EXCELLENT", "")).isTrue();
        assertThat(service.hasApplicableRule(88L, "杰出人才卡", "")).isFalse();
        assertParkingDetailAndQuote();
        jdbc.update("update merchant set merchant_rights_config = ? where id = 88",
                "[{\"level\":\"卓越人才卡\",\"enabled\":true,\"storeConsume\":true,\"consumeEnabled\":true,\"parkingType\":\"paid\",\"discount\":\"9\"}]");
        jdbc.update("update transport_parking_card_level_right set status = 0");
        assertThat(service.hasApplicableRule(88L, "卓越人才卡", "")).isFalse();
        jdbc.update("update transport_parking_card_level_right set status = 1, charge_type = 'FREE'");
        assertThat(service.hasApplicableRule(88L, "卓越人才卡", "")).isFalse();
        jdbc.update("update transport_parking_card_level_right set charge_type = 'PAID', store_consume_enabled = 0");
        assertThat(service.hasApplicableRule(88L, "卓越人才卡", "")).isFalse();
    }
    private void assertParkingDetailAndQuote() {
        prepareConsumptionDetail();
        for (String discount : new String[] {"6折", "7折", "10折"}) {
            jdbc.update("update transport_parking_card_level_right set discount_text = ?", discount);
            Map<String, Object> detail = service.detail(principal, "parking-scan");
            assertThat(detail).containsEntry("cardLevel", "卓越人才卡");
            java.util.List<?> rules = (java.util.List<?>) detail.get("rules");
            assertThat(rules).hasSize(1);
            Map<?, ?> rule = (Map<?, ?>) rules.get(0);
            assertThat(rule.get("benefitText")).isEqualTo("到店消费" + discount);
            Map<String, Object> quote = service.quote(principal, new ManualConsumptionCreateRequest(
                    "parking-scan", ((Number) rule.get("id")).longValue(), new BigDecimal("10")));
            assertThat(quote.get("payAmount")).isEqualTo(Long.parseLong(discount.replace("折", "")) * 100);
        }
    }
    @ParameterizedTest
    @ValueSource(strings = {"carWash", "dental"})
    void merchantUsesEachScannedCardLevelDiscountInsteadOfItsLabel(String merchantType) {
        prepareConsumptionDetail();
        String[] levels = {"卓越人才卡", "杰出人才卡", "菁英人才卡"};
        String[] discounts = {"7", "8.5", "9"};
        java.util.List<Map<String, Object>> rights = new java.util.ArrayList<>();
        for (int index = 0; index < levels.length; index++) {
            Map<String, Object> right = configuredRight(levels[index], discounts[index]);
            right.put("tag", levels[index] + "到店消费可用");
            right.put("enabled", true);
            right.put("storeConsume", true);
            right.put("consumeEnabled", true);
            rights.add(right);
        }
        jdbc.update("insert into merchant(id, merchant_type_code, merchant_rights_config) values (88, ?, ?)",
                merchantType, com.alibaba.fastjson.JSON.toJSONString(rights));
        for (int index = 0; index < levels.length; index++) {
            jdbc.update("update talent_card set card_level = ? where id = 1", levels[index]);
            Map<String, Object> detail = service.detail(principal, "parking-scan");
            assertThat(detail).containsEntry("cardLevel", levels[index]);
            java.util.List<?> rules = (java.util.List<?>) detail.get("rules");
            assertThat(rules).hasSize(1);
            Map<?, ?> rule = (Map<?, ?>) rules.get(0);
            assertThat(rule.get("benefitText")).isEqualTo("到店消费" + discounts[index] + "折");
            Map<String, Object> quote = service.quote(principal, new ManualConsumptionCreateRequest(
                    "parking-scan", ((Number) rule.get("id")).longValue(), new BigDecimal("100")));
            assertThat(quote.get("payAmount")).isEqualTo(new BigDecimal(discounts[index]).multiply(new BigDecimal("1000")).longValue());
        }
    }
    private void prepareConsumptionDetail() {
        jdbc.execute("alter table talent_dynamic_code add column talent_id bigint");
        jdbc.execute("alter table talent_dynamic_code add column card_id bigint");
        jdbc.execute("""
                create table merchant_verification_attempt (
                    id bigint, dynamic_code_id bigint, status tinyint, expires_at timestamp, manual_order_id bigint,
                    verification_id varchar(64), credential_type varchar(64), merchant_id bigint, store_id bigint, operator_id bigint
                )
                """);
        jdbc.execute("create table talent_card (id bigint, card_level varchar(64), deleted tinyint)");
        jdbc.execute("create table talent_user (id bigint, name varchar(64), mobile varchar(32), gender tinyint, source_area_type varchar(32), deleted tinyint)");
        jdbc.execute("create table merchant_card_benefit (id bigint, merchant_id bigint, card_level varchar(64), benefit_text varchar(500), status tinyint, sort_no int)");
        jdbc.execute("""
                create table merchant_coupon (
                    id bigint, merchant_id bigint, coupon_name varchar(64), coupon_type varchar(64), coupon_amount decimal,
                    talent_levels varchar(64), condition_type varchar(64), condition_amount decimal,
                    valid_start date, valid_end date, deleted tinyint, status tinyint, store_consume tinyint
                )
                """);
        jdbc.execute("create table benefit_order (coupon_id bigint, talent_id bigint, deleted tinyint, order_status tinyint, expires_at timestamp)");
        jdbc.update("insert into talent_dynamic_code(id, status, talent_id, card_id) values (1, 0, 1, 1)");
        jdbc.update("insert into talent_card values (1, '卓越人才卡', 0)");
        jdbc.update("insert into talent_user values (1, '测试', '13800000000', 1, 'LOCAL', 0)");
        jdbc.update("insert into merchant_verification_attempt values (1, 1, 0, null, null, 'parking-scan', 'TALENT_CODE', 88, 7, 9)");
    }
    @Test
    void cultureHidesLegacyCouponsAndRejectsCouponQuotesAndOrders() {
        prepareConsumptionDetail();
        Map<String, Object> right = configuredRight("卓越人才卡", "8");
        right.put("enabled", true);
        right.put("storeConsume", true);
        right.put("consumeEnabled", true);
        jdbc.update("insert into merchant(id, merchant_type_code, merchant_rights_config) values (88, 'culture', ?)",
                com.alibaba.fastjson.JSON.toJSONString(java.util.List.of(right)));
        jdbc.update("""
                insert into merchant_coupon (id, merchant_id, coupon_name, coupon_amount, talent_levels,
                    condition_type, deleted, status, store_consume)
                values (1, 88, '旧优惠券', 20, '卓越人才卡', 'NONE', 0, 1, 1)
                """);
        Map<String, Object> detail = service.detail(principal, "parking-scan");
        assertThat((java.util.List<?>) detail.get("coupons")).isEmpty();
        ManualConsumptionCreateRequest discountOnly = new ManualConsumptionCreateRequest("parking-scan", -1L, new BigDecimal("100"));
        assertThat(service.quote(principal, discountOnly)).containsEntry("payAmount", 8000L).containsEntry("couponAmount", 0L);
        ManualConsumptionCreateRequest withCoupon = new ManualConsumptionCreateRequest("parking-scan", -1L, new BigDecimal("100"), 1L);
        assertThatThrownBy(() -> service.quote(principal, withCoupon))
                .isInstanceOf(com.talentcard.server.common.BusinessException.class).hasMessageContaining("不支持优惠券");
        assertThatThrownBy(() -> service.create(principal, withCoupon, "culture-coupon-test"))
                .isInstanceOf(com.talentcard.server.common.BusinessException.class).hasMessageContaining("不支持优惠券");
        assertThat(jdbc.queryForObject("select status from talent_dynamic_code where id = 1", Integer.class)).isZero();
        assertThat(jdbc.queryForObject("select count(*) from benefit_order", Integer.class)).isZero();
        jdbc.update("update merchant set merchant_type_code = 'food' where id = 88");
        assertThat((java.util.List<?>) service.detail(principal, "parking-scan").get("coupons")).hasSize(1);
        assertThat(service.quote(principal, withCoupon)).containsEntry("payAmount", 6000L).containsEntry("couponAmount", 2000L);
    }
    @Test
    void cultureCurrentBenefitUsesOnlyTheScannedLevelsStoreConsumptionDescription() {
        prepareConsumptionDetail();
        String[] levels = {"卓越人才卡", "杰出人才卡", "菁英人才卡"};
        java.util.List<Map<String, Object>> rights = new java.util.ArrayList<>();
        for (String level : levels) {
            Map<String, Object> right = configuredRight(level, "8");
            right.put("enabled", true);
            right.put("storeConsume", true);
            right.put("consumeEnabled", true);
            right.put("detail", "其他权益详情");
            right.put("consumeDesc", level + "到店消费权益\n第二行  保留空格");
            rights.add(right);
        }
        jdbc.update("insert into merchant(id, merchant_type_code, merchant_rights_config) values (88, 'culture', ?)",
                com.alibaba.fastjson.JSON.toJSONString(rights));
        jdbc.update("insert into merchant_card_benefit values (1, 88, 'EXCELLENT', '旧权益文案', 1, 0)");
        for (String level : levels) {
            jdbc.update("update talent_card set card_level = ? where id = 1", level);
            String expected = level + "到店消费权益\n第二行  保留空格";
            assertThat(service.detail(principal, "parking-scan"))
                    .containsEntry("cardLevel", level).containsEntry("benefitText", expected);
            assertThat(service.benefitTextForMerchant(88L, level)).isEqualTo(expected);
        }
        rights.get(0).put("consumeDesc", "");
        rights.get(1).put("enabled", false);
        jdbc.update("update merchant set merchant_rights_config = ? where id = 88", com.alibaba.fastjson.JSON.toJSONString(rights));
        assertThat(service.benefitTextForMerchant(88L, levels[0])).isEmpty();
        assertThat(service.benefitTextForMerchant(88L, levels[1])).isEmpty();
        assertThat(service.benefitTextForMerchant(88L, "普通游客")).isEmpty();
    }
    @Test
    void childcareCurrentBenefitUsesTheScannedLevelsRightsDescription() {
        prepareConsumptionDetail();
        String[] levels = {"卓越人才卡", "杰出人才卡", "菁英人才卡"};
        java.util.List<Map<String, Object>> rights = new java.util.ArrayList<>();
        for (String level : levels) {
            Map<String, Object> right = configuredRight(level, "8");
            right.put("enabled", true);
            right.put("storeConsume", true);
            right.put("consumeEnabled", true);
            right.put("detail", level + "托管权益描述\n第二行内容");
            right.put("consumeDesc", "不同的到店消费使用说明");
            rights.add(right);
        }
        jdbc.update("insert into merchant(id, merchant_type_code, merchant_rights_config) values (88, 'children', ?)",
                com.alibaba.fastjson.JSON.toJSONString(rights));
        jdbc.update("insert into merchant_card_benefit values (1, 88, 'EXCELLENT', '旧权益文案', 1, 0)");
        for (String level : levels) {
            jdbc.update("update talent_card set card_level = ? where id = 1", level);
            assertThat(service.detail(principal, "parking-scan"))
                    .containsEntry("cardLevel", level)
                    .containsEntry("benefitText", level + "托管权益描述\n第二行内容");
            assertThat(service.benefitTextForMerchant(88L, level)).isEqualTo(level + "托管权益描述\n第二行内容");
        }
        rights.get(0).put("detail", "");
        rights.get(1).put("enabled", false);
        jdbc.update("update merchant set merchant_rights_config = ? where id = 88", com.alibaba.fastjson.JSON.toJSONString(rights));
        assertThat(service.benefitTextForMerchant(88L, levels[0])).isEmpty();
        assertThat(service.benefitTextForMerchant(88L, levels[1])).isEmpty();
        assertThat(service.benefitTextForMerchant(88L, "普通游客")).isEmpty();
    }
    @Test
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationServiceTests.java
@@ -123,6 +123,32 @@
                .hasMessageContaining("未配置适用于该人才卡的到店消费权益");
    }
    @Test
    void parkingScanShowsAndPricesTheScannedCardLevelDiscount() {
        jdbc.update("update merchant set merchant_type_code = 'parking', merchant_rights_config = ? where id = ?",
                "[{\"level\":\"卓越人才卡\",\"enabled\":true,\"storeConsume\":true,\"consumeEnabled\":false,\"parkingType\":\"paid\",\"discount\":\"9\"}]", merchantId);
        jdbc.update("""
                insert into transport_parking_card_level_right
                    (merchant_id, card_level, enabled, charge_type, store_consume_enabled, discount_text, government_subsidy, status)
                values (?, '卓越人才卡', 1, 'PAID', 1, '6折', 0, 1),
                       (?, '杰出人才卡', 1, 'PAID', 1, '7折', 0, 1)
                """, merchantId, merchantId);
        Map<String, Object> scanned = verificationService.scan(principal, new VerificationScanRequest(token));
        String verificationId = String.valueOf(scanned.get("verificationId"));
        Map<String, Object> detail = manualConsumptionService.detail(principal, verificationId);
        assertThat(detail).containsEntry("cardLevel", "卓越人才卡");
        java.util.List<?> rules = (java.util.List<?>) detail.get("rules");
        assertThat(rules).hasSize(1);
        Map<?, ?> rule = (Map<?, ?>) rules.get(0);
        assertThat(rule.get("benefitText")).isEqualTo("到店消费6折");
        ManualConsumptionCreateRequest request = new ManualConsumptionCreateRequest(verificationId,
                ((Number) rule.get("id")).longValue(), new BigDecimal("10"));
        assertThat(manualConsumptionService.quote(principal, request))
                .containsEntry("payAmount", 600L).containsEntry("discountAmount", 400L);
        Map<String, Object> created = manualConsumptionService.create(principal, request, "parking-test-" + System.nanoTime());
        assertThat(created).containsEntry("payAmount", 600L);
    }
    @Test
    void talentCodeStoreConsumptionUsesConfiguredCouponAfterTalentDiscount() {
        Map<String, Object> scanned = verificationService.scan(principal, new VerificationScanRequest(token));
talent-card-server/src/test/java/com/talentcard/server/business/portal/ParkingMerchantPortalServiceTests.java
New file
@@ -0,0 +1,46 @@
package com.talentcard.server.business.portal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
@ExtendWith(MockitoExtension.class)
class ParkingMerchantPortalServiceTests {
    @Mock
    private JdbcTemplate jdbc;
    @InjectMocks
    private ParkingMerchantPortalService service;
    @Test
    @SuppressWarnings("unchecked")
    void freeAndPaidParkingUseDifferentTagRules() throws Exception {
        when(jdbc.queryForObject(anyString(), eq(Integer.class), any(Object[].class))).thenReturn(0);
        Method method = ParkingMerchantPortalService.class.getDeclaredMethod(
                "parkingBenefitTags", Long.class, Map.class);
        method.setAccessible(true);
        List<String> freeTags = (List<String>) method.invoke(service, 1L, Map.of(
                "enabled", true, "parkingType", "free"));
        List<String> paidTags = (List<String>) method.invoke(service, 1L, Map.of(
                "enabled", true, "parkingType", "paid", "storeConsume", true,
                "consumeEnabled", false, "discount", "8.50"));
        assertThat(freeTags).containsExactly("免费");
        assertThat(paidTags).containsExactly("8.5折");
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java
@@ -88,6 +88,13 @@
        assertThat(foodTags).containsExactly("券", "8.5折");
        assertThat(cinemaTags).isEmpty();
        for (String type : List.of("employment", "finance", "approval", "apartment", "fuel",
                "housing", "car", "appliance", "digital", "telecom")) {
            List<String> tags = (List<String>) method.invoke(service,
                    Map.of("merchantType", type, "couponAvailable", true, "discountText", "7折"),
                    Map.of("serviceLabel", "人才专享"), null);
            assertThat(tags).as(type).isEmpty();
        }
        List<String> scenicTags = (List<String>) method.invoke(service,
                Map.of("merchantType", "scenic", "couponAvailable", true, "discountText", "8.8折"),
@@ -118,4 +125,23 @@
        assertThat(foodTags).containsExactly("券", "8.8折");
        assertThat(hotelTags).containsExactly("券", "免费2次", "8.5折");
    }
    @Test
    @SuppressWarnings("unchecked")
    void parkingTagsUseFreeForFreeRightsAndDiscountForPaidRights() throws Exception {
        when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());
        when(jdbc.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(0L);
        Method method = PortalService.class.getDeclaredMethod(
                "parkingBenefitTags", Long.class, List.class, String.class, String.class);
        method.setAccessible(true);
        List<String> freeTags = (List<String>) method.invoke(service, 1L, List.of(Map.of(
                "level", "卓越人才卡", "enabled", true, "parkingType", "free")), "卓越人才卡", "LOCAL");
        List<String> paidTags = (List<String>) method.invoke(service, 1L, List.of(Map.of(
                "level", "卓越人才卡", "enabled", true, "parkingType", "paid",
                "storeConsume", true, "consumeEnabled", false, "discount", "8.50")), "卓越人才卡", "LOCAL");
        assertThat(freeTags).containsExactly("免费");
        assertThat(paidTags).containsExactly("8.5折");
    }
}