shinnosuke
2026-09-11 b36f4c2800e88f8c5060ae926d3eb25d4bcb69c0
又又更新
12个文件已修改
437 ■■■■■ 已修改文件
rencaika/pages/merchant/merchant.uvue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/premium-benefits/premium-benefits.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/shopping-benefits/shopping-benefits.uvue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/stay-benefits/stay-benefits.uvue 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/travel-benefits/travel-benefits.uvue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/services/portal.uts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantView.vue 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalController.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java 156 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/merchant/merchant.uvue
@@ -33,7 +33,7 @@
                    </view>
                    <view v-else class="merchant-title-row">
                        <text class="merchant-title">{{merchantName}}</text>
                        <view class="merchant-tags"><text v-if="merchantHasCoupon && !isFuelMerchant" class="coupon-tag">券</text><text v-if="merchantDiscount != '' && !isFuelMerchant" class="discount-tag">{{merchantDiscount}}</text><text v-if="isHotelMerchant && merchantConsumeDiscount != ''" class="discount-tag">{{merchantConsumeDiscount}}</text></view>
                        <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>
                    <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">
@@ -73,7 +73,7 @@
                            <image class="package-image" :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image>
                            <view class="package-info">
                                <text class="package-name">{{item.name}}</text>
                                <view class="package-tags"><text v-if="merchantHasCoupon" class="coupon-tag">券</text><text v-if="merchantDiscount != ''" class="discount-tag">{{merchantDiscount}}</text><text v-if="isHotelMerchant && merchantConsumeDiscount != ''" class="discount-tag">{{merchantConsumeDiscount}}</text></view>
                                <view class="package-tags"><text v-for="tag in merchantDisplayTags" :key="tag" :class="tag == '券' ? 'coupon-tag' : 'discount-tag'">{{tag}}</text></view>
                                <view class="package-bottom"><view class="price-row"><text class="price-symbol">¥</text><text class="price">{{item.price}}</text><text v-if="!isHotelMerchant" class="old-price">¥{{item.originalPrice}}</text></view><view class="buy-button"><text>{{item.available === false ? '已售罄' : (isHotelMerchant ? '预约' : '购买')}}</text></view></view>
                            </view>
                        </view>
@@ -174,6 +174,25 @@
    const merchantHasCoupon = ref(false)
    const merchantDiscount = ref('')
    const merchantConsumeDiscount = ref('')
    const merchantBenefitTags = ref<string[]>([])
    const merchantBenefitTagsLoaded = ref(false)
    const merchantDisplayTags = computed((): string[] => {
        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 (isHotelMerchant.value && merchantConsumeDiscount.value != '') tags.push(merchantConsumeDiscount.value)
        return tags
    })
    const readBenefitTags = (data: any): string[] => {
        const tags: string[] = []
        const records: any[] = data != null && data.benefitTags instanceof Array ? data.benefitTags as any[] : []
        for (let index = 0; index < records.length; index++) {
            const tag = String(records[index]).trim()
            if (tag != '' && tags.indexOf(tag) < 0) tags.push(tag)
        }
        return tags
    }
    const normalizeHotelCardLabel = (value: string): string => {
        let label = value.trim()
        const separator = label.indexOf('|')
@@ -610,6 +629,8 @@
            merchantHasCoupon.value = data != null && (data.couponAvailable === true || Number(data.couponAvailable) == 1)
            const discountText = data != null && data.discountText != null ? data.discountText as string : ''
            merchantDiscount.value = discountText == '人才专享' ? '' : discountText
            merchantBenefitTagsLoaded.value = data != null && data.benefitTags instanceof Array
            merchantBenefitTags.value = merchantBenefitTagsLoaded.value ? readBenefitTags(data) : []
            const configuredTag = data != null && data.rightsTag != null ? '' + data.rightsTag : ''
            merchantServiceLabel.value = configuredTag != '' && data != null && data.serviceLabel != null ? data.serviceLabel as string : ''
            applicableCards.value = data != null && data.applicableCards != null ? data.applicableCards as string : ''
rencaika/pages/premium-benefits/premium-benefits.uvue
@@ -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 == '配偶推荐就业' || item.businessType == 'ADMIN_APPROVAL' ? '免费' : (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]; 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 })
    }
    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
@@ -75,11 +75,19 @@
            for (let index = 0; index < records.length; index++) {
                const item = records[index]
                const merchantType = item.merchantType != null ? item.merchantType as string : ''
                const tags: string[] = []
                if (item.couponAvailable === true || Number(item.couponAvailable) == 1) tags.push('券')
                const configuredTag = item.discountText != null ? String(item.discountText).trim() : ''
                const tags: string[] = []
                if (item.benefitTags instanceof Array) {
                    const tagRecords: any[] = item.benefitTags as any[]
                    for (let tagIndex = 0; tagIndex < tagRecords.length; tagIndex++) {
                        const tag = String(tagRecords[tagIndex]).trim()
                        if (tag != '' && tag != '人才专享' && tags.indexOf(tag) < 0) tags.push(tag)
                    }
                } else {
                    if (item.couponAvailable === true || Number(item.couponAvailable) == 1) tags.push('券')
                if (merchantType == 'culture' && (item.storeConsumeAvailable === true || Number(item.storeConsumeAvailable) == 1)) tags.push('到店消费')
                if (merchantType != 'culture' && configuredTag != '' && tags.indexOf(configuredTag) < 0) tags.push(configuredTag)
                }
                next.push({ id: '' + item.id, merchantType: merchantType, name: item.merchantName != null && item.merchantName != '' ? item.merchantName as string : '商家名称未配置', discount: configuredTag, tags: tags, address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false, unavailableReason: item.unavailableReason != null && item.unavailableReason != '' ? item.unavailableReason 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
rencaika/pages/stay-benefits/stay-benefits.uvue
@@ -52,7 +52,7 @@
                        v-for="item in visibleServices" :key="item.id" @tap="openService(item)">
                        <image class="service-image" :src="item.image" mode="aspectFill"
                            @error="replaceServiceImage(item)"></image>
                        <view class="service-info"><text class="service-name">{{item.name}}</text><view v-if="item.available" class="discount-tags"><text v-for="tag in item.discountTags" :key="tag" class="discount-tag">{{tag}}</text></view><text v-else class="discount-tag">已下架</text>
                        <view class="service-info"><text class="service-name">{{item.name}}</text><view v-if="item.available" class="discount-tags"><text v-for="tag in item.discountTags" :key="tag" :class="tag == '券' ? 'coupon-tag' : 'discount-tag'">{{tag}}</text></view><text v-else class="discount-tag">已下架</text>
                            <view v-if="item.merchantType != 'apartment'" class="address">
                                <image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text>
                            </view>
@@ -165,20 +165,22 @@
                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 hasCoupon = item.couponAvailable === true || Number(item.couponAvailable) == 1 || configuredTags.some((tag: any) => {
                    const value = String(tag).trim()
                    return value == '券' || value == '惠'
                })
                const displayTags: any[] = activeTab.value == 'hotel'
                    ? configuredTags.filter((tag: any) => String(tag).trim() != '券' && String(tag).trim() != '惠')
                    : configuredTags
                if (activeTab.value == 'hotel' && hasCoupon) displayTags.unshift('券')
                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 (hasCoupon) displayTags.push('券')
                    if (discountText != '' && discountText != '人才专享') displayTags.push(discountText)
                }
                next.push({
                    id: '' + item.id,
                    merchantType: merchantType != '' ? merchantType : activeTab.value,
                    name: merchantName != '' ? merchantName : '\u4f4f\u5bbf\u670d\u52a1',
                    discount: discountText != '' ? discountText : '\u4eba\u624d\u4e13\u4eab',
                    discountTags: displayTags.length > 0 ? displayTags : [discountText != '' ? discountText : '\u4eba\u624d\u4e13\u4eab'],
                    discountTags: displayTags,
                    address: readText(item.address, item['merchant_address']),
                    image: coverUrl != '' ? resolveApiUrl(coverUrl) : fallbackImage,
                    available: readAvailable(item)
@@ -459,8 +461,19 @@
        box-sizing: border-box;
        padding: 3rpx 8rpx;
        border-radius: 5rpx;
        background-color: #FFF0EB;
        color: #FF6239;
        background-color: #FFF5E7;
        color: #FF7C2B;
        font-size: 21rpx;
        line-height: 29rpx;
    }
    .coupon-tag {
        display: block;
        width: fit-content;
        box-sizing: border-box;
        padding: 3rpx 8rpx;
        border-radius: 5rpx;
        background-color: #FFF0EC;
        color: #FF5D37;
        font-size: 21rpx;
        line-height: 29rpx;
    }
rencaika/pages/travel-benefits/travel-benefits.uvue
@@ -4,7 +4,7 @@
        <scroll-view class="page-scroll" scroll-y lower-threshold="80" @scroll="handleScroll" @scrolltolower="loadMore">
            <view class="hero"><image class="hero-image" :src="currentScene.image" mode="aspectFill" @error="replaceHeroImage"></image><view class="hero-shade"></view><text v-if="currentScene.name != ''" class="scene-name">{{currentScene.name}}</text><text v-if="scenes.length == 0" class="no-gallery">暂无图册内容</text><scroll-view v-else class="scene-scroll" scroll-x scroll-with-animation :scroll-left="sceneScrollLeft" @scrollend="handleSceneScrollEnd"><view class="scene-row" :style="'width:' + sceneTrackWidth + 'rpx'"><view class="scene-pad"></view><view v-for="(item, index) in scenes" :key="item.id" class="scene-item" :class="index == activeIndex ? 'active' : ''" @tap="selectScene(index)"><image :src="item.image" mode="aspectFill" @error="replaceSceneImage(index)"></image></view><view class="scene-pad"></view></view></scroll-view></view>
            <view v-if="topicDescription != ''" class="topic-summary"><view class="intro-card"><text>{{topicDescription}}</text></view></view>
            <view class="service-section"><view class="section-heading"><text>景区服务</text><view></view></view><view v-if="initialLoading" class="list-state"><view class="loading-spinner"></view><text>正在加载景区服务</text></view><view v-else-if="initialError" class="list-state error-state"><text>景区服务加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view><view v-else-if="hasLoaded && visibleServices.length == 0" class="list-state"><text>暂无相关景区服务</text></view><view v-else-if="hasLoaded"><view class="service-item" :class="item.available ? '' : 'is-unavailable'" v-for="item in visibleServices" :key="item.id" @tap="openService(item)"><image class="service-image" :src="item.image" mode="aspectFill" @error="replaceServiceImage(item)"></image><view class="service-info"><text class="service-name">{{item.name}}</text><text class="service-discount">{{item.available ? item.discount : '已下架'}}</text><view class="address"><image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text></view></view></view></view><view v-if="loadingMore" class="list-state compact"><view class="loading-spinner"></view><text>正在加载</text></view><view v-else-if="loadMoreError" class="list-state compact load-error" @tap="retryLoadMore"><text>加载失败,点击重试</text></view><view v-else-if="hasLoaded && !hasMore && visibleServices.length > 0" class="no-more"><text>已经到底了</text></view></view><view class="bottom-space"></view>
            <view class="service-section"><view class="section-heading"><text>景区服务</text><view></view></view><view v-if="initialLoading" class="list-state"><view class="loading-spinner"></view><text>正在加载景区服务</text></view><view v-else-if="initialError" class="list-state error-state"><text>景区服务加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view><view v-else-if="hasLoaded && visibleServices.length == 0" class="list-state"><text>暂无相关景区服务</text></view><view v-else-if="hasLoaded"><view class="service-item" :class="item.available ? '' : 'is-unavailable'" v-for="item in visibleServices" :key="item.id" @tap="openService(item)"><image class="service-image" :src="item.image" mode="aspectFill" @error="replaceServiceImage(item)"></image><view class="service-info"><text class="service-name">{{item.name}}</text><view v-if="!item.available" class="service-tags"><text class="service-discount">已下架</text></view><view v-else-if="item.tags.length > 0" class="service-tags"><text v-for="tag in item.tags" :key="tag" :class="tag == '券' ? 'coupon-tag' : 'service-discount'">{{tag}}</text></view><view class="address"><image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text></view></view></view></view><view v-if="loadingMore" class="list-state compact"><view class="loading-spinner"></view><text>正在加载</text></view><view v-else-if="loadMoreError" class="list-state compact load-error" @tap="retryLoadMore"><text>加载失败,点击重试</text></view><view v-else-if="hasLoaded && !hasMore && visibleServices.length > 0" class="no-more"><text>已经到底了</text></view></view><view class="bottom-space"></view>
        </scroll-view>
    </view>
</template>
@@ -13,6 +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 { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28)
@@ -28,6 +29,7 @@
    const scenes = ref<any[]>([])
    const currentScene = ref({ id: 'fallback', name: '', image: fallbackImage })
    const topicDescription = ref('')
    const talentId = ref('')
    const visibleServices = ref<any[]>([])
    const initialLoading = ref(true)
    const initialError = ref(false)
@@ -56,6 +58,8 @@
            failLoadMoreOnce.value = state == 'loadMoreError'
        }
        loadTravelTopic()
        const session = getSession()
        if (session != null && session.talentId != null) talentId.value = session.talentId as string
        loadInitialServices()
    })
@@ -107,13 +111,26 @@
        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 }
        getTravelTopicMerchants(currentPage.value + 1, 4, (data: any) => {
        getTravelTopicMerchants(currentPage.value + 1, 4, talentId.value, (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, merchantType: item.merchantType != null ? item.merchantType as string : 'scenic', name: item.merchantName != null ? item.merchantName as string : '景区服务', discount: item.discountText != null ? item.discountText 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 })
                const hasCoupon = item.couponAvailable === true || Number(item.couponAvailable) == 1
                const discount = item.discountText != null && item.discountText != '人才专享' ? item.discountText as string : ''
                const tags: string[] = []
                if (item.benefitTags instanceof Array) {
                    const tagRecords: any[] = item.benefitTags as any[]
                    for (let tagIndex = 0; tagIndex < tagRecords.length; tagIndex++) {
                        const tag = String(tagRecords[tagIndex]).trim()
                        if (tag != '' && tag != '人才专享' && tags.indexOf(tag) < 0) tags.push(tag)
                    }
                } else {
                    if (hasCoupon) tags.push('券')
                    if (discount != '') tags.push(discount)
                }
                next.push({ id: '' + item.id, merchantType: item.merchantType != null ? item.merchantType as string : 'scenic', name: item.merchantName != null ? item.merchantName as string : '景区服务', discount: discount, tags: tags, 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 = currentPage.value + 1
@@ -156,6 +173,6 @@
    .scene-item { width: 106rpx; height: 106rpx; margin-right: 31rpx; box-sizing: border-box; overflow: hidden; border: 3rpx solid rgba(255,255,255,.9); border-radius: 8rpx; flex-shrink: 0; transition: transform 180ms ease-out; } .scene-item image { width: 100%; height: 100%; } .scene-item.active { border-width: 4rpx; border-color: #FFD329; transform: scale(1.28); box-shadow: 0 8rpx 18rpx rgba(0,0,0,.28); }
    .topic-summary { margin-top: -66rpx; position: relative; z-index: 4; } .intro-card { margin: 0 31rpx; padding: 25rpx 31rpx; border-radius: 26rpx; background-image: linear-gradient(90deg,#FF5320,#FF8A22); box-shadow: 0 10rpx 22rpx rgba(255,104,28,.24); } .intro-card text { color: #FFFFFF; font-size: 23rpx; line-height: 34rpx; }
    .service-section { padding: 48rpx 31rpx 0; } .section-heading { display: flex; width: 132rpx; height: 72rpx; flex-direction: column; align-items: center; } .section-heading text { color: #363636; font-size: 32rpx; font-weight: 700; } .section-heading view { width: 61rpx; height: 7rpx; margin-top: 12rpx; border-radius: 4rpx; background-image: linear-gradient(90deg,#FF4D36,#FFBF3A); }
    .service-item { display: flex; min-height: 205rpx; margin-bottom: 20rpx; flex-direction: row; } .service-item.is-unavailable { opacity: .56; } .service-image { width: 184rpx; height: 184rpx; border-radius: 17rpx; flex-shrink: 0; background-color: #EEE; } .service-info { display: flex; min-width: 0; min-height: 184rpx; flex: 1; padding: 4rpx 0 4rpx 24rpx; flex-direction: column; justify-content: space-between; } .service-name { color: #292929; font-size: 29rpx; font-weight: 700; } .service-discount { display: block; width: fit-content; padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF5E7; color: #FF6A34; font-size: 21rpx; } .address { display: flex; min-width: 0; padding: 7rpx 10rpx; border-radius: 7rpx; background-color: #F1F2F3; } .address image { width: 27rpx; height: 27rpx; margin-right: 8rpx; } .address text { min-width: 0; flex: 1; color: #7E8490; font-size: 21rpx; line-height: 31rpx; }
    .service-item { display: flex; min-height: 205rpx; margin-bottom: 20rpx; flex-direction: row; } .service-item.is-unavailable { opacity: .56; } .service-image { width: 184rpx; height: 184rpx; border-radius: 17rpx; flex-shrink: 0; background-color: #EEE; } .service-info { display: flex; min-width: 0; min-height: 184rpx; flex: 1; padding: 4rpx 0 4rpx 24rpx; flex-direction: column; justify-content: space-between; } .service-name { color: #292929; font-size: 29rpx; font-weight: 700; } .service-tags { display: flex; flex-direction: row; align-items: center; } .coupon-tag { padding: 6rpx 8rpx; border-radius: 6rpx; background-color: #FFF0EC; color: #FF5D37; font-size: 21rpx; } .service-discount { display: block; width: fit-content; margin-left: 0; padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF5E7; color: #FF6A34; font-size: 21rpx; } .service-tags .service-discount { margin-left: 11rpx; } .service-tags .coupon-tag + .service-discount { margin-left: 11rpx; } .address { display: flex; min-width: 0; padding: 7rpx 10rpx; border-radius: 7rpx; background-color: #F1F2F3; } .address image { width: 27rpx; height: 27rpx; margin-right: 8rpx; } .address text { min-width: 0; flex: 1; color: #7E8490; font-size: 21rpx; line-height: 31rpx; }
    .list-state, .no-more { display: flex; height: 150rpx; color: #989DA5; font-size: 23rpx; justify-content: center; align-items: center; } .list-state.compact { height: 90rpx; } .error-state { flex-direction: column; } .retry-text, .load-error text { margin-top: 18rpx; color: #F26A38; } .loading-spinner { width: 26rpx; height: 26rpx; margin-right: 12rpx; box-sizing: border-box; border: 3rpx solid #FFD6C4; border-top-color: #FF6C2B; border-radius: 50%; animation: loading 720ms linear infinite; } @keyframes loading { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .bottom-space { height: calc(30rpx + env(safe-area-inset-bottom)); }
</style>
rencaika/services/portal.uts
@@ -39,7 +39,11 @@
}
export const getTravelTopic = (onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appTravelTopic'), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getTravelTopicMerchants = (page: number, size: number, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appTravelTopicMerchants'), method: 'GET', skipAuth: true, data: { page: page, size: size } }, onSuccess, onError)
export const getTravelTopicMerchants = (page: number, size: number, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = { page: page, size: size }
    if (talentId != '') data.talentId = Number(talentId)
    return request<any>({ path: getApiEndpoint('appTravelTopicMerchants'), method: 'GET', skipAuth: true, data: data }, onSuccess, onError)
}
export const getStayTopic = (onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appStayTopic'), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getStayTopicMerchants = (type: string, page: number, size: number, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = { type: type, page: page, size: size }
talent-card-admin/src/views/MerchantCreateView.vue
@@ -92,6 +92,7 @@
  '停车服务': 'parking', parking: 'parking',
  '加油服务': 'fuel', fuel: 'fuel',
  '子女托管': 'children', children: 'children',
  '学生托付': 'children', '学生托管': 'children', '子女托管服务': 'children',
  '金融服务': 'finance', finance: 'finance',
  '配偶推荐就业': 'employment', employment: 'employment',
  '行政审批': 'approval', approval: 'approval',
@@ -123,9 +124,10 @@
const isSpouseEmploymentMerchant = computed(() => merchantTypeCode.value === 'employment')
const isApprovalMerchant = computed(() => merchantTypeCode.value === 'approval')
const isBookMerchant = computed(() => merchantTypeCode.value === 'culture')
const couponWithoutProducts = computed(() => isBookMerchant.value || isChildcareMerchant.value)
const isServiceMerchant = computed(() => isFoodMerchant.value || merchantTypeCode.value === 'scenic')
const supportsStoreConsumption = computed(() => [
  'hotel', 'scenic', 'food', 'dental', 'medical', 'culture',
  'hotel', 'scenic', 'food', 'dental', 'culture', 'children',
].includes(merchantTypeCode.value) || (isCarWashMerchant.value && form.miniProgramJumpType === 'normal'))
const merchantAppLoginOptionalTypes = [
  'housing', 'car', 'appliance', 'digital', 'culture', 'telecom', 'dental', 'fuel',
@@ -213,7 +215,7 @@
}
const tabs = computed(() => {
  if (isBookMerchant.value) {
  if (couponWithoutProducts.value) {
    return [
      { label: '商家设置', value: 'settings' },
      { label: '商家权益', value: 'rights' },
@@ -239,7 +241,7 @@
      { label: '团单', value: 'products' },
    ]
  }
  if (isParkingMerchant.value || isChildcareMerchant.value || isFinancialMerchant.value || isMovieMerchant.value || isSpouseEmploymentMerchant.value || isApprovalMerchant.value) {
  if (isParkingMerchant.value || isFinancialMerchant.value || isMovieMerchant.value || isSpouseEmploymentMerchant.value || isApprovalMerchant.value) {
    return [
      { label: '商家设置', value: 'settings' },
      { label: '商家权益', value: 'rights' },
@@ -586,7 +588,7 @@
  }],
  validTime: [{ required: true, message: '请选择优惠券有效期', trigger: 'change' }],
  productScope: [{
    validator: (_rule, value, callback) => isBookMerchant.value || value !== 'selected' || selectedCouponProductIds.value.length > 0
    validator: (_rule, value, callback) => couponWithoutProducts.value || value !== 'selected' || selectedCouponProductIds.value.length > 0
      ? callback()
      : callback(new Error('请至少选择一个可用商品')),
    trigger: 'change',
@@ -604,7 +606,7 @@
function defaultMerchantRights(): MerchantRightConfig[] {
  const currentType = merchantTypeCode.value
  const supportsDefaultStoreConsumption = ['food', 'scenic', 'dental', 'medical'].includes(currentType)
  const supportsDefaultStoreConsumption = ['food', 'scenic', 'dental', 'culture', 'children'].includes(currentType)
    || (currentType === 'carWash' && form.miniProgramJumpType === 'normal')
  const isHotel = currentType === 'hotel'
  const isCarWash = currentType === 'carWash'
@@ -623,33 +625,33 @@
        enabled: true,
        tag: '',
        detail: '',
        storeConsume: true,
        storeConsume: false,
        discount: '',
        governmentSubsidy: true,
        governmentSubsidy: false,
        consumeDesc: '',
        consumeEnabled: true,
        consumeEnabled: false,
      },
      {
        level: '杰出人才卡',
        enabled: true,
        tag: '',
        detail: '',
        storeConsume: true,
        storeConsume: false,
        discount: '',
        governmentSubsidy: true,
        governmentSubsidy: false,
        consumeDesc: '',
        consumeEnabled: true,
        consumeEnabled: false,
      },
      {
        level: '菁英人才卡',
        enabled: true,
        tag: '',
        detail: '',
        storeConsume: true,
        storeConsume: false,
        discount: '',
        governmentSubsidy: true,
        governmentSubsidy: false,
        consumeDesc: '',
        consumeEnabled: true,
        consumeEnabled: false,
      },
    ]
  }
@@ -817,33 +819,33 @@
          enabled: true,
          tag: '',
          detail: '',
          storeConsume: false,
          storeConsume: true,
          discount: '',
          governmentSubsidy: false,
          governmentSubsidy: true,
          consumeDesc: '',
          consumeEnabled: false,
          consumeEnabled: true,
        },
        {
          level: '杰出人才卡',
          enabled: true,
          tag: '',
          detail: '',
          storeConsume: false,
          storeConsume: true,
          discount: '',
          governmentSubsidy: false,
          governmentSubsidy: true,
          consumeDesc: '',
          consumeEnabled: false,
          consumeEnabled: true,
        },
        {
          level: '菁英人才卡',
          enabled: true,
          tag: '',
          detail: '',
          storeConsume: false,
          storeConsume: true,
          discount: '',
          governmentSubsidy: false,
          governmentSubsidy: true,
          consumeDesc: '',
          consumeEnabled: false,
          consumeEnabled: true,
        },
      ]
    }
@@ -1554,6 +1556,7 @@
      ...(saved || {}),
    }
    if (isParkingMerchant.value && merged.parkingType === 'paid') merged.consumeEnabled = true
    if (supportsStoreConsumption.value && merged.level !== '普通游客') merged.consumeEnabled = true
    return merged
  }))
  syncCarWashJumpMeta()
@@ -2075,7 +2078,7 @@
}
async function loadCouponProducts() {
  if (isBookMerchant.value) {
  if (couponWithoutProducts.value) {
    couponProducts.value = []
    selectedCouponProductIds.value = []
    return
@@ -2109,7 +2112,7 @@
  couponEditForm.couponAmount = ''
  couponEditForm.talentLevels = ['卓越人才卡', '杰出人才卡', '菁英人才卡']
  couponEditForm.validTime = []
  couponEditForm.productScope = isBookMerchant.value ? 'all' : 'selected'
  couponEditForm.productScope = couponWithoutProducts.value ? 'all' : 'selected'
  couponEditForm.storeConsume = true
  couponEditForm.governmentSubsidy = true
  couponEditForm.status = 1
@@ -2137,7 +2140,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 = isBookMerchant.value || detail.productScope === 'ALL' ? 'all' : 'selected'
  couponEditForm.productScope = couponWithoutProducts.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
@@ -2209,9 +2212,9 @@
    talentLevelQuantities: Object.fromEntries(couponEditForm.talentLevels.map((level) => [level, Number(couponLevelQuantities[level] || 1)])),
    validStart: couponEditForm.validTime?.[0],
    validEnd: couponEditForm.validTime?.[1],
    productScope: isBookMerchant.value || couponEditForm.productScope === 'all' ? 'ALL' : 'SELECTED',
    productIds: isBookMerchant.value || couponEditForm.productScope === 'all' ? [] : selectedCouponProductIds.value,
    storeConsume: isBookMerchant.value || couponEditForm.storeConsume,
    productScope: couponWithoutProducts.value || couponEditForm.productScope === 'all' ? 'ALL' : 'SELECTED',
    productIds: couponWithoutProducts.value || couponEditForm.productScope === 'all' ? [] : selectedCouponProductIds.value,
    storeConsume: couponWithoutProducts.value || couponEditForm.storeConsume,
    governmentSubsidy: couponEditForm.governmentSubsidy,
    status: couponEditForm.status,
  }
@@ -2857,10 +2860,6 @@
                </div>
              </div>
            </div>
            <div v-if="isChildcareMerchant" class="merchant-rights-detail-row consume-desc-row">
              <label>到店消费使用描述</label>
              <el-input v-model="item.consumeDesc" type="textarea" :rows="4" resize="none" placeholder="请输入" />
            </div>
          </template>
          <div v-if="isParkingMerchant && item.parkingType === 'paid'" class="merchant-rights-detail-row consume-desc-row">
@@ -3471,7 +3470,7 @@
              />
            </el-form-item>
            <div v-if="isBookMerchant" class="coupon-edit-row coupon-switch-row">
            <div v-if="couponWithoutProducts" class="coupon-edit-row coupon-switch-row">
              <label>适用范围</label>
              <span>到店消费</span>
            </div>
@@ -3484,7 +3483,7 @@
              </el-radio-group>
            </el-form-item>
            <div v-if="!isBookMerchant" class="coupon-product-select-table">
            <div v-if="!couponWithoutProducts" 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 }">
@@ -3512,7 +3511,7 @@
              </el-table>
            </div>
            <div v-if="!isBookMerchant" class="coupon-edit-row coupon-switch-row">
            <div v-if="!couponWithoutProducts" class="coupon-edit-row coupon-switch-row">
              <label>是否到店消费可用</label>
              <el-switch v-model="couponEditForm.storeConsume" />
            </div>
talent-card-admin/src/views/MerchantView.vue
@@ -97,6 +97,67 @@
}
const drawerTitle = computed(() => (editingId.value ? '编辑商家' : '新建商家'))
const merchantTypeAliases: Record<string, string> = {
  '餐饮服务': 'food', food: 'food',
  '景区服务': 'scenic', scenic: 'scenic',
  '酒店服务': 'hotel', hotel: 'hotel',
  '公寓租赁': 'apartment', apartment: 'apartment',
  '自助洗车': 'carWash', '自助洗车服务': 'carWash', carWash: 'carWash',
  '免费体检': 'medical', medical: 'medical', healthCheck: 'medical',
  '停车服务': 'parking', parking: 'parking',
  '加油服务': 'fuel', fuel: 'fuel',
  '子女托管': 'children', children: 'children',
  '学生托付': 'children', '学生托管': 'children', '子女托管服务': 'children',
  '金融服务': 'finance', finance: 'finance',
  '配偶推荐就业': 'employment', employment: 'employment',
  '行政审批': 'approval', approval: 'approval',
  '观影服务': 'cinema', cinema: 'cinema', theatre: 'cinema', '电影院': 'cinema', '影院': 'cinema', '影城': 'cinema', '剧院': 'cinema',
  '购房优惠': 'housing', housing: 'housing',
  '购车服务': 'car', car: 'car',
  '家电服务': 'appliance', appliance: 'appliance',
  '数码服务': 'digital', digital: 'digital',
  '文化服务': 'culture', culture: 'culture', book: 'culture',
  '电信服务': 'telecom', telecom: 'telecom',
  '口腔治疗保健': 'dental', dental: 'dental',
}
const productTabTypeCodes = new Set(['food', 'scenic', 'hotel', 'dental', 'medical'])
function resolveMerchantTypeCode(row: Merchant) {
  const raw = String(row.merchantTypeCode || row.merchantType || '').trim()
  return merchantTypeAliases[raw] || raw
}
function parseMerchantRights(config: Merchant['merchantRightsConfig']) {
  if (Array.isArray(config)) return config
  if (typeof config !== 'string' || !config.trim()) return []
  try {
    const parsed = JSON.parse(config)
    return Array.isArray(parsed) ? parsed : []
  } catch {
    return []
  }
}
function isCarWashMiniProgramJump(row: Merchant) {
  const linkType = String(row.carWashExternalLinkType || '').trim()
  if (linkType === 'miniProgram') return true
  if (linkType === 'normal') return false
  const jump = parseMerchantRights(row.merchantRightsConfig).find((entry) => (
    entry?.externalLinkType
    || entry?.externalMiniProgramShortLink
    || entry?.externalMiniProgramPath
    || entry?.externalWebUrl
  ))
  if (!jump) return false
  return jump.externalLinkType === 'miniProgram' || Boolean(String(jump.externalMiniProgramShortLink || '').trim())
}
function supportsGroupBuying(row: Merchant) {
  const code = resolveMerchantTypeCode(row)
  if (code === 'carWash') return !isCarWashMiniProgramJump(row)
  return productTabTypeCodes.has(code)
}
function resetForm() {
  Object.assign(form, emptyForm())
  editingId.value = undefined
@@ -335,7 +396,7 @@
            <el-button v-permission="'admin.merchants.disable'" link :type="row.status === 0 ? 'success' : 'danger'" @click="toggleStatus(row)">
              {{ row.status === 0 ? '启用' : '禁用' }}
            </el-button>
            <el-button link type="primary" :icon="Goods" @click="openProducts(row)">团购管理</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>
          </template>
        </el-table-column>
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -226,9 +226,11 @@
            int size
    ) {
        StringBuilder sql = new StringBuilder("""
                select m.id, m.merchant_name, m.merchant_type, m.contact_name, m.contact_mobile,
                select m.id, m.merchant_name, m.merchant_type, m.merchant_type_code, m.contact_name, m.contact_mobile,
                       m.logo_url, m.cover_url, m.address, m.is_recommend, m.sort_no, m.status,
                       m.created_at,
                       m.created_at, m.merchant_rights_config,
                       (select s.external_link_type from transport_car_wash_service s
                        where s.merchant_id = m.id and s.deleted = 0 order by s.id desc limit 1) as car_wash_external_link_type,
                       (select count(*) from merchant_product p where p.merchant_id = m.id and p.deleted = 0) as product_count,
                       (select count(*) from benefit_order o where o.merchant_id = m.id and o.deleted = 0) as order_count,
                       0 as reservation_count
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalController.java
@@ -103,9 +103,10 @@
    @GetMapping("/topics/travel/merchants")
    public ApiResponse<PageResult<Map<String, Object>>> travelMerchants(
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "4") int size
            @RequestParam(defaultValue = "4") int size,
            @RequestParam(required = false) Long talentId
    ) {
        return ApiResponse.success(portalService.travelMerchants(page, size));
        return ApiResponse.success(portalService.travelMerchants(page, size, talentId));
    }
    @GetMapping("/topics/stay")
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -290,8 +290,11 @@
        List<Map<String, Object>> records = result.records().stream().map(row -> {
            Map<String, Object> item = enrichMerchantSearchRow(row);
            Long merchantId = item.get("id") instanceof Number id ? id.longValue() : null;
            item.put("couponAvailable", merchantId != null && hasActiveMerchantCoupon(merchantId));
            item.put("discountText", merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId));
            boolean couponAvailable = merchantId != null && hasActiveMerchantCoupon(merchantId);
            String discountText = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId);
            item.put("couponAvailable", couponAvailable);
            item.put("discountText", discountText);
            item.put("benefitTags", merchantBenefitTags("food", couponAvailable, discountText, null));
            return item;
        }).toList();
        return new PageResult<>(records, result.page(), result.size(), result.total());
@@ -316,6 +319,11 @@
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> travelMerchants(int page, int size) {
        return travelMerchants(page, size, null);
    }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> travelMerchants(int page, int size, Long talentId) {
        String merchantWhere = " where deleted = 0 and merchant_type_code = 'scenic'";
        PageResult<Map<String, Object>> result = page("""
                select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
@@ -326,9 +334,15 @@
                """ + merchantWhere + " order by sort_no asc, id asc",
                "select count(*) from merchant" + merchantWhere, page, size);
        List<Map<String, Object>> records = result.records().stream().map(row -> {
            Map<String, Object> normalized = new LinkedHashMap<>(row);
            normalized.put("merchantType", "scenic");
            return enrichMerchantSearchRow(normalized);
            Map<String, Object> item = enrichMerchantSearchRow(row);
            item.put("merchantType", "scenic");
            Long merchantId = item.get("id") instanceof Number id ? id.longValue() : null;
            boolean couponAvailable = merchantId != null && hasActiveMerchantCoupon(merchantId);
            String discountText = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId);
            item.put("couponAvailable", couponAvailable);
            item.put("discountText", discountText);
            item.put("benefitTags", merchantBenefitTags("scenic", couponAvailable, discountText, null));
            return item;
        }).toList();
        return new PageResult<>(records, result.page(), result.size(), result.total());
    }
@@ -374,90 +388,89 @@
                """ + merchantWhere + " order by sort_no asc, id asc",
                "select count(*) from merchant" + merchantWhere, page, size);
        List<Map<String, Object>> records = result.records().stream().map(row -> {
            Map<String, Object> normalized = new LinkedHashMap<>(row);
            normalized.put("merchantType", responseMerchantType);
            String cardLevel = activeProductCardLevel(talentId);
            Map<String, Object> right = matchingMerchantRight(merchantRightsConfigs(row.get("merchantRightsConfig")), cardLevel, activeTalentSourceAreaType(talentId), true);
            normalized.put("benefitTags", stayBenefitTags(right, row));
            return enrichMerchantSearchRow(normalized);
            Map<String, Object> item = enrichMerchantSearchRow(row);
            item.put("merchantType", responseMerchantType);
            Long merchantId = item.get("id") instanceof Number id ? id.longValue() : null;
            boolean couponAvailable = merchantId != null && hasActiveMerchantCoupon(merchantId);
            String discountText = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId);
            Map<String, Object> right = matchingMerchantRight(
                    merchantRightsConfigs(row.get("merchantRightsConfig")),
                    activeProductCardLevel(talentId),
                    activeTalentSourceAreaType(talentId),
                    true);
            item.put("couponAvailable", couponAvailable);
            item.put("discountText", discountText);
            item.put("benefitTags", merchantBenefitTags(responseMerchantType, couponAvailable, discountText, right));
            item.remove("merchantRightsConfig");
            return item;
        }).toList();
        return new PageResult<>(records, result.page(), result.size(), result.total());
    }
    private List<String> stayBenefitTags(Map<String, Object> right, Map<String, Object> merchant) {
        Object merchantId = merchant.get("id");
        boolean couponAvailable = merchantId instanceof Number id && hasActiveMerchantCoupon(id.longValue());
        return stayBenefitTags(right, couponAvailable);
    }
    private List<String> stayBenefitTags(Map<String, Object> right, boolean couponAvailable) {
        List<String> tags = new ArrayList<>();
        if (couponAvailable) tags.add("券");
        if (right == null) return tags;
        if (booleanValue(right.get("freeVoucherEnabled"))) {
            String times = textOrDefault(right.get("freeVoucherTimes"), "");
            if (!times.isBlank()) tags.add("免费" + times + "次");
        }
        if (booleanValue(right.get("storeConsume")) && booleanValue(right.get("consumeEnabled"))) {
            String type = textOrDefault(right.get("consumeType"), "");
            String discount = textOrDefault(right.get("consumeDiscount"), "");
            if ("折扣".equals(type) && !discount.isBlank()) tags.add(discount + "折");
        }
        return tags;
    }
    private List<String> merchantListBenefitTags(Map<String, Object> item, Map<String, Object> row, Long talentId) {
        String merchantType = MerchantTypeCatalog.canonicalCode(textOrDefault(item.get("merchantType"), ""));
        boolean couponAvailable = booleanValue(item.get("couponAvailable"));
        String discountText = textOrDefault(item.get("discountText"), "").trim();
        String serviceLabel = textOrDefault(row.get("serviceLabel"), "").trim();
        List<String> tags = new ArrayList<>();
        if ("food".equals(merchantType)) {
            if (couponAvailable) tags.add("券");
            if (!discountText.isBlank()) tags.add(discountText);
            return tags;
        }
        if ("cinema".equals(merchantType)) {
            if (couponAvailable) tags.add("券");
            return tags;
        }
        // The travel module displays the calculated discount, not the merchant's
        // service label, so search results must use the same source.
        if ("scenic".equals(merchantType)) {
            if (!discountText.isBlank()) tags.add(discountText);
            return tags;
        }
        String cardLevel = activeProductCardLevel(talentId);
        String sourceAreaType = activeTalentSourceAreaType(talentId);
        Map<String, Object> right = matchingMerchantRight(
                merchantRightsConfigs(row.get("merchantRightsConfig")), cardLevel, sourceAreaType, true);
        if ("hotel".equals(merchantType) || "apartment".equals(merchantType)) {
            tags.addAll(stayBenefitTags(right, couponAvailable));
            if (tags.isEmpty()) tags.add(!discountText.isBlank() ? discountText : "人才专享");
            return tags;
        if (usesMerchantDetailHeaderTags(merchantType) || "cinema".equals(merchantType)) {
            return merchantBenefitTags(merchantType, couponAvailable, discountText, right);
        }
        boolean shoppingType = MerchantTypeCatalog.find(merchantType)
                .map(type -> type.shoppingCategoryKey() != null)
                .orElse(false);
        if (shoppingType) {
            List<String> tags = new ArrayList<>();
            if (couponAvailable) tags.add("券");
            if ("culture".equals(merchantType)) {
                if (right != null && booleanValue(right.get("storeConsume")) && booleanValue(right.get("consumeEnabled"))) {
                    tags.add("到店消费");
                }
            } else if (right != null) {
            if (right != null) {
                String configuredDiscount = merchantRightDiscountText(right);
                if (!configuredDiscount.isBlank()) tags.add(configuredDiscount);
            }
            return tags;
        }
        String serviceLabel = textOrDefault(row.get("serviceLabel"), "").trim();
        String categoryTag = !serviceLabel.isBlank() ? serviceLabel : discountText;
        if (categoryTag.isBlank()) categoryTag = "人才专享";
        tags.add(categoryTag);
        return List.of(categoryTag);
    }
    private boolean usesMerchantDetailHeaderTags(String merchantType) {
        return List.of("food", "scenic", "hotel", "apartment", "dental", "culture").contains(merchantType);
    }
    private List<String> merchantBenefitTags(
            String merchantType,
            boolean couponAvailable,
            String productDiscountText,
            Map<String, Object> right
    ) {
        String type = MerchantTypeCatalog.canonicalCode(merchantType);
        if ("cinema".equals(type)) {
            return couponAvailable ? List.of("券") : List.of();
        }
        List<String> tags = new ArrayList<>();
        if (couponAvailable) tags.add("券");
        String discount = textOrDefault(productDiscountText, "").trim();
        if ("人才专享".equals(discount)) discount = "";
        boolean hotelLike = "hotel".equals(type) || "apartment".equals(type);
        if (hotelLike && right != null && booleanValue(right.get("freeVoucherEnabled"))) {
            String times = textOrDefault(right.get("freeVoucherTimes"), "").trim();
            discount = times.isBlank() ? "免费" : "免费" + times + "次";
        }
        if (!discount.isBlank() && !tags.contains(discount)) tags.add(discount);
        if (hotelLike && right != null && booleanValue(right.get("storeConsume")) && booleanValue(right.get("consumeEnabled"))) {
            String consumeType = textOrDefault(right.get("consumeType"), "");
            String consumeDiscount = textOrDefault(right.get("consumeDiscount"), "").trim();
            String consumeCoupon = textOrDefault(right.get("consumeCoupon"), "").trim();
            String consumeTag = "";
            if ("折扣".equals(consumeType) && !consumeDiscount.isBlank()) consumeTag = consumeDiscount + "折";
            else if ("优惠".equals(consumeType) && !consumeCoupon.isBlank()) consumeTag = "减" + consumeCoupon;
            if (!consumeTag.isBlank() && !tags.contains(consumeTag)) tags.add(consumeTag);
        }
        return tags;
    }
@@ -506,6 +519,7 @@
            boolean couponAvailable = booleanValue(item.get("couponAvailable"));
            item.put("couponAvailable", couponAvailable);
            item.put("discountText", couponAvailable ? "券" : "");
            item.put("benefitTags", merchantBenefitTags("cinema", couponAvailable, "", null));
            return item;
        }).toList();
        return new PageResult<>(records, result.page(), result.size(), result.total());
@@ -634,6 +648,8 @@
            item.put("storeConsumeAvailable", configuredRight != null
                    && booleanValue(configuredRight.get("storeConsume"))
                    && booleanValue(configuredRight.get("consumeEnabled")));
            String merchantType = MerchantTypeCatalog.canonicalCode(textOrDefault(item.get("merchantType"), ""));
            boolean couponAvailable = booleanValue(item.get("couponAvailable"));
            if (configuredRight != null) {
                item.put("discountText", merchantRightDiscountText(configuredRight));
                item.put("rightsAvailable", true);
@@ -645,6 +661,17 @@
            } else {
                item.put("discountText", textOrDefault(item.get("serviceLabel"), ""));
                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.remove("merchantRightsConfig");
            return item;
@@ -847,6 +874,11 @@
        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("usageInstructions", merchantUsageInstructions(
                id, merchant.get("merchantRightsConfig"), cardLevel, merchant.get("usageInstructions")));
        List<String> heroImages = new ArrayList<>();
talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java
@@ -88,5 +88,34 @@
        assertThat(foodTags).containsExactly("券", "8.5折");
        assertThat(cinemaTags).isEmpty();
        List<String> scenicTags = (List<String>) method.invoke(service,
                Map.of("merchantType", "scenic", "couponAvailable", true, "discountText", "8.8折"),
                Map.of(), null);
        List<String> cultureTags = (List<String>) method.invoke(service,
                Map.of("merchantType", "culture", "couponAvailable", true, "discountText", ""),
                Map.of(), null);
        assertThat(scenicTags).containsExactly("券", "8.8折");
        assertThat(cultureTags).containsExactly("券");
    }
    @Test
    @SuppressWarnings("unchecked")
    void merchantDetailHeaderTagsDriveListTags() throws Exception {
        Method method = PortalService.class.getDeclaredMethod(
                "merchantBenefitTags", String.class, boolean.class, String.class, Map.class);
        method.setAccessible(true);
        List<String> foodTags = (List<String>) method.invoke(service, "food", true, "8.8折", null);
        List<String> hotelTags = (List<String>) method.invoke(service, "hotel", true, "7折", Map.of(
                "freeVoucherEnabled", true,
                "freeVoucherTimes", "2",
                "storeConsume", true,
                "consumeEnabled", true,
                "consumeType", "折扣",
                "consumeDiscount", "8.5"));
        assertThat(foodTags).containsExactly("券", "8.8折");
        assertThat(hotelTags).containsExactly("券", "免费2次", "8.5折");
    }
}