shinnosuke
2026-08-19 52ee6ca100d1fb7aa4108e588d9125f942d1a624
fix: update portal and merchant detail displays
19个文件已修改
396 ■■■■■ 已修改文件
rencaika/pages/finance-merchant/finance-merchant.uvue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/medical-package/medical-package.uvue 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/merchant/merchant.uvue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/spouse-employment-form/spouse-employment-form.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/spouse-employment-success/spouse-employment-success.uvue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/spouse-employment/spouse-employment.uvue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/stay-benefits/stay-benefits.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/services/portal.uts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/ProductAuditDetailView.vue 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/ProductView.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/FinanceMerchantPortalController.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/FinanceMerchantPortalService.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/MedicalPackagePortalService.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/SpouseEmploymentPortalController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/SpouseEmploymentPortalService.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/ServiceApplicationMapper.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/ServiceAppointmentApplicationService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/finance-merchant/finance-merchant.uvue
@@ -45,7 +45,9 @@
<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 { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28)
@@ -53,6 +55,7 @@
    const navWidth = ref(88)
    const navRight = ref(10)
    const merchantId = ref('')
    const talentId = ref('')
    const merchantName = ref('')
    const discount = ref('')
    const applicableCards = ref('')
@@ -81,13 +84,14 @@
        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'
        loadDetail()
    })
    const loadDetail = () => {
        if (merchantId.value == '') { detailLoading.value = false; detailError.value = true; return }
        detailLoading.value = true
        detailError.value = false
        getFinanceMerchantDetail(merchantId.value, (data: any) => {
        getFinanceMerchantDetail(merchantId.value, talentId.value, (data: any) => {
            merchantName.value = data.merchantName || ''
            discount.value = data.benefitSummary || '请咨询服务专员'
            applicableCards.value = data.applicableCards || '请咨询服务专员'
rencaika/pages/medical-package/medical-package.uvue
@@ -5,7 +5,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="loadDetail">重新加载</text></view>
            <view v-else>
                <view class="package-info-card"><view class="package-title-row"><text class="package-title">{{packageName}}</text><text class="free-tag">{{freeLabel}}</text></view><view class="service-tags"><text>{{applicableCards}}</text></view><text class="hospital-name">{{hospitalName}}</text><view class="address-row"><image class="address-icon" src="/static/benefits-assets/map-pin.svg"></image><text class="address-text">{{hospitalAddress}}</text><view class="address-actions"><view :class="canNavigate ? 'round-action' : 'round-action is-disabled'" @tap="openMap"><image src="/static/merchant-assets/navigation.svg"></image></view><view :class="hospitalPhone != '' ? 'round-action' : 'round-action is-disabled'" @tap="callHospital"><image src="/static/merchant-assets/phone.svg"></image></view></view></view><text v-if="!packageAvailable" class="unavailable-text">{{unavailableReason}}</text></view>
                <view class="package-info-card"><view class="package-title-row"><text class="package-title">{{packageName}}</text><text class="free-tag">{{freeLabel}}</text></view><view v-if="applicableCards != ''" class="service-tags"><text>{{applicableCards}}</text></view><text class="hospital-name">{{hospitalName}}</text><view class="address-row"><image class="address-icon" src="/static/benefits-assets/map-pin.svg"></image><text class="address-text">{{hospitalAddress}}</text><view class="address-actions"><view :class="canNavigate ? 'round-action' : 'round-action is-disabled'" @tap="openMap"><image src="/static/merchant-assets/navigation.svg"></image></view><view :class="hospitalPhone != '' ? 'round-action' : 'round-action is-disabled'" @tap="callHospital"><image src="/static/merchant-assets/phone.svg"></image></view></view></view><text v-if="!packageAvailable" class="unavailable-text">{{unavailableReason}}</text></view>
                <view class="tabs"><view class="tab-item active"><text>详情</text><view class="tab-line"></view></view></view>
                <view v-if="itemsLoading" class="list-state"><view class="loading-spinner"></view><text>正在加载检查项目</text></view><view v-else-if="itemsError" class="list-state error-state"><text>检查项目加载失败</text><text class="retry-text" @tap="loadItems">重新加载</text></view><view v-else-if="itemsLoaded && packageItems.length == 0" class="list-state"><text>暂无体检检查项目</text></view>
                <view v-if="itemsLoaded && packageItems.length > 0" class="check-table"><view class="check-head"><view class="check-cell cell-item"><text>检查项目</text></view><view class="check-cell cell-meaning"><text>项目意义</text></view><view class="check-cell cell-gender"><text>男</text></view><view class="check-cell cell-gender cell-gender-end"><text>女</text></view></view><view class="check-row" v-for="item in packageItems" :key="item.id"><view class="check-cell cell-item"><text>{{item.name}}</text></view><view class="check-cell cell-meaning"><text>{{item.meaning}}</text></view><view class="check-cell cell-gender"><text>{{item.male ? '√' : ''}}</text></view><view class="check-cell cell-gender cell-gender-end"><text>{{item.female ? '√' : ''}}</text></view></view></view>
@@ -20,21 +20,25 @@
    import { resolveApiUrl } from '../../services/http.uts'
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10); const isSolidNav = ref(false)
    const packageId = ref(''); const hospitalId = ref(''); const detailLoading = ref(true); const detailError = ref(false); const itemsLoading = ref(false); const itemsLoaded = ref(false); const itemsError = ref(false)
    const packageId = ref(''); const hospitalId = ref(''); const routeApplicableCards = ref(''); const detailLoading = ref(true); const detailError = ref(false); const itemsLoading = ref(false); const itemsLoaded = ref(false); const itemsError = ref(false)
    const packageName = ref(''); const heroImage = ref(''); const freeLabel = ref('免费'); const applicableCards = ref(''); const packageAvailable = ref(false); const unavailableReason = ref('该套餐暂不可预约')
    const hospitalName = ref(''); const hospitalAddress = ref(''); const hospitalPhone = ref(''); const latitude = ref(0); const longitude = ref(0); const packageItems = ref<any[]>([])
    const canNavigate = computed(() => latitude.value > 0 && longitude.value > 0)
    const canBook = computed(() => !detailLoading.value && !detailError.value && packageAvailable.value)
    const decodeRouteText = (value: any): string => {
        const text = value == null ? '' : String(value)
        try { return decodeURIComponent(text) } catch (error) { return text }
    }
    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
        packageId.value = options != null && options['packageId'] != null ? options['packageId'] as string : ''; hospitalId.value = options != null && options['hospitalId'] != null ? options['hospitalId'] as string : ''; loadDetail()
        packageId.value = options != null && options['packageId'] != null ? options['packageId'] as string : ''; hospitalId.value = options != null && options['hospitalId'] != null ? options['hospitalId'] as string : ''; routeApplicableCards.value = options != null ? decodeRouteText(options['applicableCards']) : ''; applicableCards.value = routeApplicableCards.value; loadDetail()
    })
    const loadDetail = () => {
        if (packageId.value == '') { detailLoading.value = false; detailError.value = true; return }
        detailLoading.value = true; detailError.value = false
        getMedicalPackage(packageId.value, (data: any) => { const hospital: any = data.hospital || {}; packageName.value = data.name || ''; heroImage.value = data.heroImage != null && data.heroImage != '' ? resolveApiUrl(data.heroImage as string) : ''; freeLabel.value = data.freeLabel || '免费'; applicableCards.value = data.applicableCards || '以医院说明为准'; packageAvailable.value = data.available === true; unavailableReason.value = data.unavailableReason || unavailableReason.value; hospitalId.value = String(hospital.hospitalId || hospitalId.value); hospitalName.value = hospital.name || ''; hospitalAddress.value = hospital.address || '医院暂未配置地址'; hospitalPhone.value = hospital.mobile || ''; latitude.value = Number(hospital.latitude); longitude.value = Number(hospital.longitude); detailLoading.value = false; loadItems() }, () => { detailLoading.value = false; detailError.value = true })
        getMedicalPackage(packageId.value, (data: any) => { const hospital: any = data.hospital || {}; packageName.value = data.name || ''; heroImage.value = data.heroImage != null && data.heroImage != '' ? resolveApiUrl(data.heroImage as string) : ''; freeLabel.value = data.freeLabel || '免费'; const apiApplicableCards = data.applicableCards != null ? String(data.applicableCards).trim() : ''; applicableCards.value = apiApplicableCards != '' ? apiApplicableCards : routeApplicableCards.value; packageAvailable.value = data.available === true; unavailableReason.value = data.unavailableReason || unavailableReason.value; hospitalId.value = String(hospital.hospitalId || hospitalId.value); hospitalName.value = hospital.name || ''; hospitalAddress.value = hospital.address || '医院暂未配置地址'; hospitalPhone.value = hospital.mobile || ''; latitude.value = Number(hospital.latitude); longitude.value = Number(hospital.longitude); detailLoading.value = false; loadItems() }, () => { detailLoading.value = false; detailError.value = true })
    }
    const loadItems = () => { if (packageId.value == '') return; itemsLoading.value = true; itemsLoaded.value = false; itemsError.value = false; getMedicalPackageItems(packageId.value, (data: any) => { packageItems.value = data instanceof Array ? data as any[] : []; itemsLoading.value = false; itemsLoaded.value = true }, () => { itemsLoading.value = false; itemsError.value = true }) }
    const goBack = () => safeBack('/pages/premium-benefits/premium-benefits')
@@ -47,4 +51,36 @@
<style>
    .page { width:100%; height:100vh; background-color:#F6F6F6; }.content { height:calc(100% - 150rpx); height:calc(100% - 150rpx - env(safe-area-inset-bottom)); background-color:#F6F6F6; }.hero { height:445rpx; position:relative; overflow:hidden; background-color:#3B3028; }.hero-image { position:absolute; left:0; top:0; width:100%; height:445rpx; }.hero-shade { position:absolute; left:0; right:0; top:0; height:190rpx; background-image:linear-gradient(180deg,rgba(0,0,0,.58),rgba(0,0,0,0)); }.custom-nav { position:fixed; z-index:20; left:0; right:0; top:0; padding-bottom:20rpx; }.custom-nav-bg { position:absolute; z-index:0; left:0; right:0; top:0; bottom:0; opacity:0; background-image:linear-gradient(180deg,#F26A38,#EE542B); transition:opacity 280ms ease-out; }.custom-nav.is-solid .custom-nav-bg { opacity:1; }.safe-space { height:28px; position:relative; z-index:2; }.nav-row { display:flex; box-sizing:border-box; padding-left:24rpx; position:relative; z-index:2; 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,.52); justify-content:center; align-items:center; }.custom-nav.is-solid .back-button { background-color:rgba(45,45,45,0); }.back-button image { width:34rpx; height:34rpx; }.nav-title { position:absolute; left:105rpx; right:105rpx; opacity:0; color:#FFF; font-size:29rpx; font-weight:600; text-align:center; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.custom-nav.is-solid .nav-title { opacity:1; }.capsule-space { height:100%; flex-shrink:0; }.package-panel { position:relative; z-index:3; min-height:600rpx; margin-top:-22rpx; border-radius:27rpx 27rpx 0 0; overflow:hidden; background-color:#F6F6F6; }.package-info-card { padding:25rpx 38rpx 20rpx; }.package-title-row { display:flex; min-height:50rpx; align-items:center; justify-content:space-between; }.package-title { min-width:0; flex:1; color:#292929; font-size:35rpx; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.free-tag { margin-left:15rpx; color:#FF7C2B; font-size:26rpx; }.service-tags { display:flex; margin-top:16rpx; }.service-tags text { padding:6rpx 11rpx; border:1rpx solid #FF9B70; border-radius:5rpx; color:#EF7046; font-size:24rpx; }.hospital-name { display:block; margin-top:20rpx; color:#292929; font-size:30rpx; font-weight:700; }.address-row { display:flex; min-height:72rpx; margin-top:12rpx; align-items:flex-start; }.address-icon { width:27rpx; height:27rpx; margin-top:4rpx; margin-right:8rpx; flex-shrink:0; }.address-text { width:420rpx; min-width:0; color:#7B8491; font-size:24rpx; line-height:34rpx; }.address-actions { display:flex; margin-left:auto; flex-shrink:0; }.round-action { display:flex; width:52rpx; height:52rpx; border-radius:50%; background-color:#F2F3F5; justify-content:center; align-items:center; }.round-action + .round-action { margin-left:30rpx; }.round-action image { width:27rpx; height:27rpx; }.is-disabled { opacity:.42; }.unavailable-text { display:block; margin-top:10rpx; color:#D96B3B; font-size:23rpx; }.tabs { display:flex; height:85rpx; padding:0 38rpx; align-items:center; }.tab-item { display:flex; height:85rpx; position:relative; color:#303030; font-size:32rpx; font-weight:600; align-items:center; }.tab-line { position:absolute; left:50%; bottom:0; width:59rpx; height:7rpx; margin-left:-30rpx; border-radius:4rpx; background-image:linear-gradient(90deg,#FF4D36,#FFBF3A); }.detail-state,.list-state { display:flex; height:330rpx; margin:16rpx 25rpx 0; color:#969CA5; font-size:24rpx; align-items:center; justify-content:center; }.detail-state { margin-top:0; }.error-state { flex-direction:column; }.retry-text { margin-top:20rpx; color:#F26A38; }.loading-spinner { width:28rpx; height:28rpx; margin-right:13rpx; box-sizing:border-box; border:3rpx solid #FFD6C4; border-top-color:#FF6C2B; border-radius:50%; animation:medical-loading 720ms linear infinite; } @keyframes medical-loading { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }.check-table { margin:16rpx 25rpx 0; border:1rpx solid #E3E6EB; border-radius:8rpx; overflow:hidden; background-color:#FFF; }.check-head,.check-row { display:flex; }.check-head { background-color:#F5F6F8; }.check-row { border-top:1rpx solid #E3E6EB; }.check-cell { display:flex; box-sizing:border-box; padding:18rpx 14rpx; border-left:1rpx solid #E3E6EB; align-items:center; justify-content:center; }.cell-item { width:180rpx; border-left:0; flex-shrink:0; }.cell-meaning { min-width:0; flex:1; justify-content:flex-start; }.cell-gender { width:84rpx; flex-shrink:0; }.check-head .check-cell text { color:#30343A; font-size:24rpx; font-weight:700; }.check-cell text { color:#4A5159; font-size:22rpx; line-height:32rpx; }.cell-gender text { color:#30343A; font-size:26rpx; }.content-bottom { height:35rpx; }.action-bar { display:flex; height:150rpx; height:calc(150rpx + env(safe-area-inset-bottom)); box-sizing:border-box; padding:20rpx 34rpx 2rpx; padding-bottom:calc(2rpx + env(safe-area-inset-bottom)); background:#FFF; box-shadow:0 -5rpx 15rpx rgba(0,0,0,.06); align-items:flex-end; }.booking-button { display:flex; width:100%; height:90rpx; border-radius:46rpx; background-image:linear-gradient(90deg,#FF4D32,#FF8A20); justify-content:center; align-items:center; }.booking-button text { color:#FFF; font-size:31rpx; }.booking-disabled { background-image:none; background-color:#C6CBD2; }.booking-button-active { opacity:.82; transform:scale(.99); }
    .package-title-row,
    .service-tags,
    .address-row,
    .address-actions,
    .tabs,
    .tab-item,
    .detail-state,
    .list-state,
    .check-head,
    .check-row {
        display: flex;
        flex-direction: row;
    }
    .service-tags {
        align-items: flex-start;
    }
    .service-tags text {
        flex-shrink: 0;
        white-space: nowrap;
    }
    .error-state {
        flex-direction: column;
    }
    .check-head,
    .check-row {
        width: 100%;
        align-items: stretch;
    }
    .check-cell {
        display: flex;
        flex-direction: row;
    }
</style>
rencaika/pages/merchant/merchant.uvue
@@ -36,7 +36,7 @@
                        <view class="merchant-tags"><text v-if="merchantHasCoupon" class="coupon-tag">券</text><text v-if="merchantDiscount != ''" class="discount-tag">{{merchantDiscount}}</text></view>
                    </view>
                    <view class="service-tags"><text v-if="!isParkingMerchant && !isMedicalMerchant">{{merchantServiceLabel}}</text><text>{{applicableCards}}</text></view>
                    <view v-if="!isParkingMerchant" class="merchant-address-row">
                    <view v-if="!isParkingMerchant && merchantType != 'apartment'" class="merchant-address-row">
                        <image class="address-icon" src="/static/benefits-assets/map-pin.svg"></image>
                        <text>{{merchantAddress}}</text>
                        <view class="merchant-actions"><view :class="merchantLocation.latitude > 0 && merchantLocation.longitude > 0 ? 'round-action' : 'round-action is-disabled'" @tap="openMap"><image src="/static/merchant-assets/navigation.svg"></image></view><view :class="merchantPhone != '' ? 'round-action' : 'round-action is-disabled'" @tap="callMerchant"><image src="/static/merchant-assets/phone.svg"></image></view></view>
@@ -56,11 +56,11 @@
                        <view class="medical-package-item" v-for="item in visibleMedicalPackages" :key="item.id" hover-class="package-item-active" @tap="openMedicalPackage(item)">
                            <image class="medical-package-image" :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image>
                            <view class="medical-package-info">
                                <text class="medical-package-name">{{item.name}}</text>
                                <view class="medical-package-bottom">
                                    <view class="medical-open-row"><text class="medical-open-label">开放时间</text><text class="medical-open-value">{{item.openTime}}</text></view>
                                <view class="medical-package-top">
                                    <text class="medical-package-name">{{item.name}}</text>
                                    <view class="medical-view-button"><text>查看</text></view>
                                </view>
                                <view class="medical-open-row"><text class="medical-open-label">开放时间</text><text class="medical-open-value">{{item.openTime}}</text></view>
                            </view>
                        </view>
                    </view>
@@ -575,7 +575,7 @@
            merchantHeroImages.value = nextHeroes
            merchantAddress.value = data != null && data.address != null ? data.address as string : merchantAddress.value
            const rawType = data != null && data.merchantType != null ? data.merchantType as string : merchantType.value
            merchantType.value = rawType == '餐饮服务' ? 'food' : (rawType == '酒店服务' ? 'hotel' : rawType)
            merchantType.value = rawType == '餐饮服务' ? 'food' : (rawType == '酒店服务' ? 'hotel' : ((rawType == '公寓租赁' || rawType == '公寓租赁服务') ? 'apartment' : rawType))
            merchantAvailable.value = data == null || data.available !== false
            merchantPhone.value = data != null && data.contactMobile != null ? data.contactMobile as string : ''
            merchantHasCoupon.value = data != null && (data.couponAvailable === true || Number(data.couponAvailable) == 1)
@@ -748,7 +748,7 @@
    }
    const openMedicalPackage = (item: any) => {
        if (item.available === false) { uni.showToast({ title: item.unavailableReason || '该套餐暂不可预约', icon: 'none' }); return }
        uni.navigateTo({ url: '/pages/medical-package/medical-package?packageId=' + encodeURIComponent(item.id) + '&hospitalId=' + encodeURIComponent(merchantId.value) + '&name=' + encodeURIComponent(item.name) + '&hospital=' + encodeURIComponent(merchantName.value) })
        uni.navigateTo({ url: '/pages/medical-package/medical-package?packageId=' + encodeURIComponent(item.id) + '&hospitalId=' + encodeURIComponent(merchantId.value) + '&name=' + encodeURIComponent(item.name) + '&hospital=' + encodeURIComponent(merchantName.value) + '&applicableCards=' + encodeURIComponent(applicableCards.value) })
    }
    const showBottomVoucher = () => {
        if (isHotelMerchant.value && hasFreeVoucher.value) {
@@ -928,12 +928,12 @@
    .medical-package-item:last-child { margin-bottom: 0; }
    .medical-package-image { width: 168rpx; height: 168rpx; border-radius: 14rpx; flex-shrink: 0; }
    .medical-package-info { display: flex; min-width: 0; flex: 1; padding-left: 23rpx; flex-direction: column; }
    .medical-package-name { color: #292929; font-size: 31rpx; font-weight: 700; }
    .medical-package-bottom { display: flex; margin-top: auto; flex-direction: row; align-items: center; justify-content: space-between; }
    .medical-open-row { display: flex; flex-direction: row; align-items: center; }
    .medical-open-label { color: #8A919C; font-size: 24rpx; }
    .medical-open-value { margin-left: 14rpx; color: #FF7C2B; font-size: 26rpx; font-weight: 600; }
    .medical-view-button { display: flex; width: 132rpx; height: 57rpx; border-radius: 29rpx; background-image: linear-gradient(90deg, #FF5733 0%, #FF8A20 100%); justify-content: center; align-items: center; }
    .medical-package-top { display: flex; min-width: 0; flex-direction: row; align-items: center; justify-content: space-between; }
    .medical-package-name { min-width: 0; flex: 1; margin-right: 16rpx; color: #292929; font-size: 31rpx; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .medical-open-row { display: flex; min-width: 0; margin-top: auto; flex-direction: row; align-items: center; }
    .medical-open-label { color: #8A919C; font-size: 24rpx; flex-shrink: 0; }
    .medical-open-value { min-width: 0; flex: 1; margin-left: 14rpx; color: #FF7C2B; font-size: 26rpx; font-weight: 600; }
    .medical-view-button { display: flex; width: 132rpx; height: 57rpx; border-radius: 29rpx; background-image: linear-gradient(90deg, #FF5733 0%, #FF8A20 100%); flex-shrink: 0; justify-content: center; align-items: center; }
    .medical-view-button text { color: #FFFFFF; font-size: 27rpx; }
    .medical-usage-panel { min-height: 548rpx; margin: 16rpx 25rpx 0; box-sizing: border-box; padding: 31rpx 27rpx; border-radius: 15rpx; background-color: #EFF1F4; }
    .medical-usage-panel text { color: #777F8B; font-size: 27rpx; line-height: 43rpx; white-space: pre-wrap; }
rencaika/pages/spouse-employment-form/spouse-employment-form.uvue
@@ -33,7 +33,7 @@
        // #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'; submitKey.value = createIdempotencyKey('spouse-employment'); loadBenefit()
    })
    const loadBenefit = () => { if (serviceId.value == '') { loading.value = false; loadError.value = true; return }; loading.value = true; loadError.value = false; getSpouseEmploymentBenefit(serviceId.value, (data: any) => { available.value = data.available === true; unavailableReason.value = data.unavailableReason || unavailableReason.value; const config = data.formConfig || {}; educationOptions.value = config.educationOptions instanceof Array && config.educationOptions.length > 0 ? config.educationOptions : ['高中及以下', '大专', '本科', '硕士研究生', '博士研究生']; institutionOptions.value = config.institutionOptions instanceof Array && config.institutionOptions.length > 0 ? config.institutionOptions : ['是', '否']; loading.value = false }, () => { loading.value = false; loadError.value = true }) }
    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) => { available.value = data.available === true; unavailableReason.value = data.unavailableReason || unavailableReason.value; const config = data.formConfig || {}; educationOptions.value = config.educationOptions instanceof Array && config.educationOptions.length > 0 ? config.educationOptions : ['高中及以下', '大专', '本科', '硕士研究生', '博士研究生']; institutionOptions.value = config.institutionOptions instanceof Array && config.institutionOptions.length > 0 ? config.institutionOptions : ['是', '否']; loading.value = false }, () => { loading.value = false; loadError.value = true }) }
    const goBack = () => safeBack('/pages/spouse-employment/spouse-employment?serviceId=' + encodeURIComponent(serviceId.value))
    const changeEducation = (event: any) => { const index = parseInt(event.detail.value as string); educationIndex.value = index; const next = form.value; next.education = educationOptions.value[index]; form.value = next }
    const changeInstitution = (event: any) => { const index = parseInt(event.detail.value as string); institutionIndex.value = index; const next = form.value; next.inInstitution = institutionOptions.value[index]; form.value = next }
@@ -46,7 +46,7 @@
        if (!(/^(\d{15}|\d{17}[0-9Xx])$/).test(form.value.idCard.trim())) { uni.showToast({ title: '请填写正确的身份证号', icon: 'none' }); return }
        if (!required(form.value.politicalStatus, '政治面貌') || !required(form.value.education, '学历') || !required(form.value.schoolMajor, '毕业院校及专业') || !required(form.value.workUnit, '现工作单位及职务') || !required(form.value.inInstitution, '是否在编') || !required(form.value.workExperience, '工作经历')) return
        isSubmitting.value = true
        submitSpouseEmploymentApplication(serviceId.value, { talentId: Number(talentId.value), name: form.value.name.trim(), mobile: form.value.mobile.trim(), idCard: form.value.idCard.trim(), politicalStatus: form.value.politicalStatus.trim(), education: form.value.education, schoolMajor: form.value.schoolMajor.trim(), workUnit: form.value.workUnit.trim(), inInstitution: form.value.inInstitution, workExperience: form.value.workExperience.trim() }, submitKey.value, (data: any) => { isSubmitting.value = false; uni.redirectTo({ url: '/pages/spouse-employment-success/spouse-employment-success?applicationId=' + encodeURIComponent(String(data.applicationId)) + '&applicationNo=' + encodeURIComponent(data.applicationNo || '') + '&estimatedProcessingTime=' + encodeURIComponent(data.estimatedProcessingTime || '') }) }, (error) => { isSubmitting.value = false; uni.showToast({ title: error.message, icon: 'none' }) })
        submitSpouseEmploymentApplication(serviceId.value, { talentId: Number(talentId.value), name: form.value.name.trim(), mobile: form.value.mobile.trim(), idCard: form.value.idCard.trim(), politicalStatus: form.value.politicalStatus.trim(), education: form.value.education, schoolMajor: form.value.schoolMajor.trim(), workUnit: form.value.workUnit.trim(), inInstitution: form.value.inInstitution, workExperience: form.value.workExperience.trim() }, submitKey.value, (data: any) => { isSubmitting.value = false; uni.redirectTo({ url: '/pages/spouse-employment-success/spouse-employment-success?applicationId=' + encodeURIComponent(String(data.applicationId)) + '&applicationNo=' + encodeURIComponent(data.applicationNo || '') }) }, (error) => { isSubmitting.value = false; uni.showToast({ title: error.message, icon: 'none' }) })
    }
</script>
rencaika/pages/spouse-employment-success/spouse-employment-success.uvue
@@ -1,14 +1,13 @@
<template>
    <view class="page"><view class="top-glow"></view><view class="custom-nav"><view class="safe-space" :style="'height:' + navTop + 'px'"></view><view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'"><view class="back-button" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view><view class="capsule-space" :style="'width:' + navWidth + 'px'"></view></view></view><view class="result-content"><image class="result-image" src="/static/premium-assets/employment-success.svg" mode="aspectFit"></image><text class="result-title">已提交</text><text class="result-desc">服务专员将联系确认</text><view class="status-row"><text>当前状态:</text><text class="status-pill">待处理</text></view><text v-if="estimatedProcessingTime != ''" class="estimate">{{estimatedProcessingTime}}</text><view class="record-button" hover-class="record-button-active" @tap="viewRecords"><text>返回使用记录</text></view></view></view>
    <view class="page"><view class="top-glow"></view><view class="custom-nav"><view class="safe-space" :style="'height:' + navTop + 'px'"></view><view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'"><view class="back-button" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view><view class="capsule-space" :style="'width:' + navWidth + 'px'"></view></view></view><view class="result-content"><image class="result-image" src="/static/premium-assets/employment-success.svg" mode="aspectFit"></image><text class="result-title">已提交</text><text class="result-desc">服务专员将联系确认</text><view class="status-row"><text>当前状态:</text><text class="status-pill">待处理</text></view><view class="record-button" hover-class="record-button-active" @tap="viewRecords"><text>返回使用记录</text></view></view></view>
</template>
<script setup lang="uts">
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10); const estimatedProcessingTime = ref('')
    const navTop = ref(28); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10)
    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
        estimatedProcessingTime.value = options != null && options['estimatedProcessingTime'] != null ? decodeURIComponent(options['estimatedProcessingTime'] as string) : ''
    })
    const goBack = () => safeBack('/pages/order/order?status=' + encodeURIComponent('待处理'))
    const viewRecords = () => uni.reLaunch({ url: '/pages/order/order?status=' + encodeURIComponent('待处理') })
@@ -16,4 +15,5 @@
<style>
    .page { width:100%; height:100vh; position:relative; overflow:hidden; background-color:#FFFFFF; }.top-glow { position:absolute; left:0; right:0; top:0; height:500rpx; background-image:radial-gradient(circle at 52% 0%,rgba(255,220,119,.72),rgba(255,240,197,.36) 34%,rgba(255,255,255,0) 75%); }.custom-nav { position:relative; z-index:3; }.safe-space { height:28px; }.nav-row { display:flex; box-sizing:border-box; padding-left:26rpx; flex-direction:row; align-items:center; justify-content:space-between; }.back-button { display:flex; width:60rpx; height:60rpx; margin-left:-8rpx; justify-content:center; align-items:center; }.back-button image { width:38rpx; height:38rpx; }.capsule-space { height:100%; flex-shrink:0; }.result-content { display:flex; position:relative; z-index:2; margin-top:80rpx; flex-direction:column; align-items:center; }.result-image { width:250rpx; height:250rpx; }.result-title { margin-top:30rpx; color:#23252A; font-size:31rpx; font-weight:500; }.result-desc { margin-top:24rpx; color:#8D96A5; font-size:24rpx; }.status-row { display:flex; margin-top:14rpx; color:#8D96A5; font-size:24rpx; align-items:center; }.status-pill { margin-left:10rpx; padding:5rpx 12rpx; border-radius:7rpx; background-color:#FFF4E6; color:#FF8A1E; }.estimate { max-width:560rpx; margin-top:16rpx; color:#8D96A5; font-size:22rpx; line-height:32rpx; text-align:center; }.record-button { display:flex; width:352rpx; height:78rpx; margin-top:44rpx; box-sizing:border-box; border:2rpx solid #DCE1E8; border-radius:40rpx; background-color:rgba(255,255,255,.72); justify-content:center; align-items:center; }.record-button text { color:#7B8495; font-size:27rpx; }.record-button-active { background-color:#F5F6F8; }
    .status-row { flex-direction: row; }
</style>
rencaika/pages/spouse-employment/spouse-employment.uvue
@@ -1,6 +1,6 @@
<template>
    <view class="page">
        <image class="hero-image" src="/static/stay-assets/hotel-vienna.jpg" mode="aspectFill"></image>
        <image class="hero-image" :src="heroImage" mode="aspectFill" @error="replaceHeroImage"></image>
        <view class="hero-shade"></view>
        <view class="custom-nav"><view class="safe-space" :style="'height:' + navTop + 'px'"></view><view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'"><view class="back-button" @tap="goBack"><image src="/static/food-assets/arrow-left-white.svg" mode="aspectFit"></image></view><view class="capsule-space" :style="'width:' + navWidth + 'px'"></view></view></view>
@@ -19,26 +19,30 @@
</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 { 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 loading = ref(true); const loadError = ref(false); const available = ref(false)
    const title = ref('配偶推荐就业'); const freeLabel = ref('免费'); const rulesText = ref(''); const consultationPhone = ref(''); const unavailableReason = ref('该权益暂未开放')
    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 consultationPhone = 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 : ''
        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'
        loadBenefit()
    })
    const loadBenefit = () => {
        if (serviceId.value == '') { loading.value = false; loadError.value = true; return }
        loading.value = true; loadError.value = false
        getSpouseEmploymentBenefit(serviceId.value, (data: any) => {
            title.value = data.title || title.value; freeLabel.value = data.freeLabel || freeLabel.value; rulesText.value = data.rulesText || ''; consultationPhone.value = data.consultationPhone || ''; available.value = data.available === true; unavailableReason.value = data.unavailableReason || unavailableReason.value; loading.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 || ''; consultationPhone.value = data.consultationPhone || ''; 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 }
    const goBack = () => safeBack('/pages/premium-benefits/premium-benefits')
    const callConsultation = () => { if (consultationPhone.value != '') uni.makePhoneCall({ phoneNumber: consultationPhone.value }) }
    const fillInformation = () => { if (!loading.value && !loadError.value && available.value) uni.navigateTo({ url: '/pages/spouse-employment-form/spouse-employment-form?serviceId=' + encodeURIComponent(serviceId.value) }) }
rencaika/pages/stay-benefits/stay-benefits.uvue
@@ -54,7 +54,7 @@
                            @error="replaceServiceImage(item)"></image>
                        <view class="service-info"><text class="service-name">{{item.name}}</text><text
                                class="discount-tag">{{item.available ? item.discount : '已下架'}}</text>
                            <view class="address">
                            <view v-if="item.merchantType != 'apartment'" class="address">
                                <image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text>
                            </view>
                        </view>
rencaika/services/portal.uts
@@ -48,10 +48,18 @@
export const getShoppingTopicMerchants = (categoryId: string, page: number, size: number, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appShoppingTopicMerchants'), method: 'GET', skipAuth: true, data: { categoryId: categoryId, page: page, size: size } }, onSuccess, onError)
export const getPremiumTopic = (onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appPremiumTopic'), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getPremiumTopicServices = (type: string, page: number, size: number, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appPremiumTopicServices'), method: 'GET', skipAuth: true, data: { type: type, page: page, size: size } }, onSuccess, onError)
export const getSpouseEmploymentBenefit = (serviceId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appSpouseEmploymentBenefit') + encodeURIComponent(serviceId), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getSpouseEmploymentBenefit = (serviceId: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = {}
    if (talentId != '') data.talentId = Number(talentId)
    return request<any>({ path: getApiEndpoint('appSpouseEmploymentBenefit') + encodeURIComponent(serviceId), method: 'GET', skipAuth: true, data: data }, onSuccess, onError)
}
export const submitSpouseEmploymentApplication = (serviceId: string, payload: any, idempotencyKey: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appSpouseEmploymentBenefit') + encodeURIComponent(serviceId) + '/applications', method: 'POST', skipAuth: true, data: payload, idempotencyKey: idempotencyKey }, onSuccess, onError)
export const getAdminApprovalBenefit = (serviceId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appAdminApprovalBenefit') + encodeURIComponent(serviceId), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getFinanceMerchantDetail = (merchantId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appFinanceMerchantDetail') + encodeURIComponent(merchantId), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getFinanceMerchantDetail = (merchantId: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = {}
    if (talentId != '') data.talentId = Number(talentId)
    return request<any>({ path: getApiEndpoint('appFinanceMerchantDetail') + encodeURIComponent(merchantId), method: 'GET', skipAuth: true, data: data }, onSuccess, onError)
}
export const getChildrenEntrustmentBenefit = (serviceId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appChildrenEntrustmentBenefit') + encodeURIComponent(serviceId), method: 'GET', skipAuth: true }, onSuccess, onError)
export const getChildrenEntrustmentSchools = (serviceId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appChildrenEntrustmentBenefit') + encodeURIComponent(serviceId) + '/schools', method: 'GET', skipAuth: true }, onSuccess, onError)
export const getChildrenEntrustmentSchool = (schoolId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('appChildrenEntrustmentSchool') + encodeURIComponent(schoolId), method: 'GET', skipAuth: true }, onSuccess, onError)
talent-card-admin/src/views/MerchantCreateView.vue
@@ -980,6 +980,7 @@
const rightExampleImage =
  'https://images.unsplash.com/photo-1559847844-5315695dadae?auto=format&fit=crop&w=640&q=80'
const showRightsExamples = false
const rightExampleLines = [
  '营业时间10:00-22:00',
@@ -2386,7 +2387,7 @@
                </el-radio-group>
              </div>
            </div>
            <div class="merchant-rights-example" v-if="isMovieMerchant">
            <div class="merchant-rights-example" v-if="showRightsExamples && isMovieMerchant">
                <span>示例:</span>
                <div class="merchant-rights-phone-preview">
                  <div class="phone-preview-hero">
@@ -2418,7 +2419,7 @@
                  </div>
                </div>
              </div>
            <div class="merchant-rights-example" v-else-if="isFinancialMerchant || isSpouseEmploymentMerchant">
            <div class="merchant-rights-example" v-else-if="showRightsExamples && (isFinancialMerchant || isSpouseEmploymentMerchant)">
                <span>示例:</span>
                <div class="merchant-rights-phone-preview">
                  <div class="phone-preview-hero">
@@ -2436,7 +2437,7 @@
                  </div>
                </div>
              </div>
            <div class="merchant-rights-example" v-else-if="!isParkingMerchant && (!isCarWashMerchant || form.miniProgramJumpType === 'normal')">
            <div class="merchant-rights-example" v-else-if="showRightsExamples && !isParkingMerchant && (!isCarWashMerchant || form.miniProgramJumpType === 'normal')">
                <span>示例:</span>
                <div class="merchant-rights-phone-preview">
                  <div class="phone-preview-hero">
@@ -2526,7 +2527,7 @@
            <div v-if="!isParkingMerchant" class="merchant-rights-detail-row consume-desc-row">
              <label>到店消费使用描述</label>
              <el-input v-model="item.consumeDesc" type="textarea" :rows="4" resize="none" placeholder="请输入" />
              <div class="merchant-rights-example">
              <div v-if="showRightsExamples" class="merchant-rights-example">
                <span>示例:</span>
                <div class="merchant-rights-phone-preview">
                  <div class="phone-preview-hero">
talent-card-admin/src/views/ProductAuditDetailView.vue
@@ -49,7 +49,18 @@
const productImage = computed(() => product.value?.coverUrl || fallbackImage)
const imageList = computed(() => (galleryImages.value.length ? galleryImages.value : [productImage.value]))
const productNameText = computed(() => product.value?.productName || '百杏厨房(万象城店)')
const productAmountText = computed(() => amountText(product.value?.salePrice ?? product.value?.originalPrice) || '158')
const isHotelProduct = computed(() => {
  const merchantType = product.value?.merchantType || ''
  return merchantType === '酒店服务' || merchantType === '酒店'
})
const productAmountText = computed(() => {
  const salePrice = product.value?.salePrice
  const originalPrice = product.value?.originalPrice
  if (isHotelProduct.value && salePrice !== undefined && originalPrice !== undefined) {
    return `${amountText(salePrice)} - ${amountText(originalPrice)}`
  }
  return amountText(salePrice ?? originalPrice) || '158'
})
const formatDateTime = (value?: string) => value ? value.replace('T', ' ') : ''
const createdAtText = computed(() => formatDateTime(product.value?.createdAt) || '2026-06-18 11:34:25')
const submitTimeText = computed(() => formatDateTime(product.value?.submitTime || product.value?.createdAt) || '2026-06-18 11:34:25')
@@ -65,19 +76,30 @@
  return Number(value).toFixed(2).replace(/\.00$/, '')
}
function parseNotice(value?: string | Record<string, unknown>) {
function parseNotice(value?: string | Record<string, unknown>, source?: Product) {
  notice.validPeriod = ''
  notice.useMethod = ''
  notice.availableTime = ''
  notice.otherRules = ''
  if (!value) return
  const fallback = source as (Product & Record<string, unknown>) | undefined
  if (!value) {
    notice.validPeriod = [fallback?.validStartTime, fallback?.validEndTime].filter(Boolean).join(' 至 ')
    notice.useMethod = String(fallback?.usageInstructions || fallback?.usage || '')
    notice.availableTime = String(fallback?.availableHours || fallback?.availableTime || '')
    notice.otherRules = String(fallback?.otherRules || fallback?.rules || '')
    return
  }
  try {
    const parsed = typeof value === 'string' ? JSON.parse(value) : value
    notice.validPeriod = parsed.validPeriod || parsed.valid_period || parsed.validity || ''
    notice.useMethod = parsed.useMethod || parsed.use_method || parsed.usage || ''
    notice.availableTime = parsed.availableTime || parsed.available_time || ''
    notice.otherRules = parsed.otherRules || parsed.other_rules || parsed.otherRule || parsed.other_rule || ''
    notice.useMethod = parsed.useMethod || parsed.use_method || parsed.usage || parsed.usageInstructions || parsed.usage_instructions || ''
    notice.availableTime = parsed.availableTime || parsed.available_time || parsed.availableHours || parsed.available_hours || ''
    notice.otherRules = parsed.otherRules || parsed.other_rules || parsed.otherRule || parsed.other_rule || parsed.rules || ''
    notice.validPeriod ||= [fallback?.validStartTime, fallback?.validEndTime].filter(Boolean).join(' 至 ')
    notice.useMethod ||= String(fallback?.usageInstructions || fallback?.usage || '')
    notice.availableTime ||= String(fallback?.availableHours || fallback?.availableTime || '')
    notice.otherRules ||= String(fallback?.otherRules || fallback?.rules || '')
  } catch {
    notice.otherRules = typeof value === 'string' ? value : ''
  }
@@ -142,7 +164,7 @@
    return
  } finally {
    galleryImages.value = parseProductImages(product.value?.productImages, product.value?.coverUrl)
    parseNotice(product.value?.purchaseNotice)
    parseNotice(product.value?.purchaseNotice, product.value)
    parseBenefits(product.value?.rightsConfig, product.value?.benefitDesc)
    loading.value = false
  }
talent-card-admin/src/views/ProductView.vue
@@ -50,7 +50,14 @@
}
function amountText(row: Product) {
  const amount = row.salePrice ?? row.originalPrice
  const isHotel = row.merchantType === '酒店服务' || row.merchantType === '酒店'
  if (isHotel && row.salePrice !== undefined && row.originalPrice !== undefined) {
    return `${formatAmount(row.salePrice)} - ${formatAmount(row.originalPrice)}`
  }
  return formatAmount(row.salePrice ?? row.originalPrice)
}
function formatAmount(amount?: number) {
  if (amount === undefined || amount === null) return ''
  return Number(amount).toFixed(2).replace(/\.00$/, '')
}
talent-card-server/src/main/java/com/talentcard/server/business/portal/FinanceMerchantPortalController.java
@@ -6,6 +6,7 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@@ -19,7 +20,7 @@
    }
    @GetMapping("/{merchantId}")
    public ApiResponse<Map<String, Object>> detail(@PathVariable Long merchantId) {
        return ApiResponse.success(service.detail(merchantId));
    public ApiResponse<Map<String, Object>> detail(@PathVariable Long merchantId, @RequestParam(required = false) Long talentId) {
        return ApiResponse.success(service.detail(merchantId, talentId));
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/portal/FinanceMerchantPortalService.java
@@ -4,6 +4,7 @@
import java.util.List;
import java.util.Map;
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
@@ -21,45 +22,80 @@
    }
    @Transactional(readOnly = true)
    public Map<String, Object> detail(Long merchantId) {
    public Map<String, Object> detail(Long merchantId, Long talentId) {
        if (merchantId == null || merchantId <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "金融机构信息不能为空");
        Map<String, Object> row = one("""
                select m.id, m.merchant_name, m.cover_url, m.service_label, m.address, m.contact_mobile,
                select m.id, m.merchant_name, m.cover_url, m.service_label, m.address, m.contact_mobile, m.contact_name,
                       m.rights_text, m.merchant_rights_config,
                       m.longitude, m.latitude, m.status as merchant_status, detail_config.talent_levels,
                       detail_config.benefit_detail, detail_config.advisor_id, detail_config.advisor_name,
                       detail_config.advisor_department, detail_config.advisor_mobile, detail_config.advisor_service_time,
                       detail_config.advisor_available, detail_config.unavailable_reason, detail_config.status as detail_status
                from merchant m
                join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.category_key = 'finance' and mapping.deleted = 0
                join finance_service_detail detail_config on detail_config.merchant_id = m.id and detail_config.deleted = 0
                where m.id = ? and m.merchant_type = 'finance' and m.deleted = 0
                left join finance_service_detail detail_config on detail_config.merchant_id = m.id and detail_config.deleted = 0
                where m.id = ? and m.merchant_type in ('finance', '金融服务') and m.deleted = 0
                order by detail_config.id asc limit 1
                """, merchantId);
        boolean available = number(row.get("merchantStatus")) == 1 && number(row.get("detailStatus")) == 1;
        boolean available = number(row.get("merchantStatus")) == 1 && (row.get("detailStatus") == null || number(row.get("detailStatus")) == 1);
        Map<String, Object> matchedRight = matchedRight(row.get("merchantRightsConfig"), activeCardLevel(talentId));
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("merchantId", row.get("id"));
        result.put("merchantName", row.get("merchantName"));
        result.put("coverUrl", row.get("coverUrl"));
        result.put("benefitSummary", text(row.get("serviceLabel"), "请咨询服务专员"));
        result.put("applicableCards", text(row.get("talentLevels"), "请咨询服务专员"));
        result.put("applicableCards", text(matchedRight.get("tag"), text(row.get("talentLevels"), "请咨询服务专员")));
        result.put("address", text(row.get("address"), "暂未配置机构地址"));
        result.put("contactMobile", text(row.get("contactMobile"), ""));
        result.put("longitude", row.get("longitude"));
        result.put("latitude", row.get("latitude"));
        result.put("benefitDetail", text(row.get("benefitDetail"), "以服务专员说明为准"));
        result.put("benefitDetail", text(matchedRight.get("detail"), text(row.get("benefitDetail"), text(row.get("rightsText"), "以服务专员说明为准"))));
        result.put("available", available);
        result.put("unavailableReason", available ? "" : text(row.get("unavailableReason"), "该金融服务当前不可用"));
        Map<String, Object> advisor = new LinkedHashMap<>();
        advisor.put("advisorId", row.get("advisorId"));
        advisor.put("name", text(row.get("advisorName"), ""));
        advisor.put("name", text(row.get("advisorName"), text(row.get("contactName"), "金融服务专员")));
        advisor.put("department", text(row.get("advisorDepartment"), ""));
        advisor.put("mobile", text(row.get("advisorMobile"), ""));
        advisor.put("mobile", text(row.get("advisorMobile"), text(row.get("contactMobile"), "")));
        advisor.put("serviceTime", text(row.get("advisorServiceTime"), ""));
        advisor.put("available", number(row.get("advisorAvailable")) == 1 && !text(row.get("advisorMobile"), "").isEmpty());
        advisor.put("available", (row.get("advisorAvailable") == null ? !text(row.get("contactMobile"), "").isEmpty() : number(row.get("advisorAvailable")) == 1) && !text(row.get("advisorMobile"), text(row.get("contactMobile"), "")).isEmpty());
        result.put("advisor", advisor);
        return result;
    }
    private Map<String, Object> matchedRight(Object value, String cardLevel) {
        if (cardLevel.isBlank() || value == null) return Map.of();
        try {
            List<Map<String, Object>> rights = JSON.parseObject(String.valueOf(value), 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"), ""))) return right;
                }
            }
        } catch (RuntimeException ignored) {
            // Use the legacy finance detail fields when merchant rights are unavailable.
        }
        return Map.of();
    }
    private String activeCardLevel(Long talentId) {
        if (talentId == null || talentId <= 0) return "";
        List<Map<String, Object>> cards = jdbc.queryForList("""
                select card_level from talent_card
                where talent_id = ? and deleted = 0 and card_type = 'PERSONAL' and status = 1
                  and (valid_end is null or valid_end >= current_date)
                order by id desc limit 1
                """, talentId).stream().map(DatabaseRows::normalize).toList();
        return cards.isEmpty() ? "" : text(cards.get(0).get("cardLevel"), "");
    }
    private boolean booleanValue(Object value) {
        if (value instanceof Boolean bool) return bool;
        if (value instanceof Number number) return number.intValue() != 0;
        String result = text(value, "");
        return "true".equalsIgnoreCase(result) || "1".equals(result) || "yes".equalsIgnoreCase(result);
    }
    private Map<String, Object> one(String sql, Object... args) {
        List<Map<String, Object>> rows = jdbc.queryForList(sql, args).stream().map(DatabaseRows::normalize).toList();
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "金融服务详情不存在");
talent-card-server/src/main/java/com/talentcard/server/business/portal/MedicalPackagePortalService.java
@@ -4,6 +4,7 @@
import java.util.List;
import java.util.Map;
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
@@ -24,8 +25,18 @@
    public List<Map<String, Object>> packages(Long merchantId) {
        medicalMerchant(merchantId);
        return query("""
                select id, package_name, hero_image, open_time, free_status, applicable_cards, stock, status, unavailable_reason
                from medical_package where merchant_id = ? and deleted = 0 order by sort_no asc, id asc
                select p.id, p.product_name as package_name, p.cover_url as hero_image,
                       case when coalesce(p.sale_price, 0) = 0 then 1 else 0 end as free_status,
                       '' as applicable_cards,
                       coalesce(
                           nullif(json_unquote(json_extract(p.purchase_notice, '$.availableTime')), ''),
                           nullif(json_unquote(json_extract(p.purchase_notice, '$.validPeriod')), ''),
                           ''
                       ) as open_time,
                       p.stock, p.status, '' as unavailable_reason
                from merchant_product p
                where p.merchant_id = ? and p.deleted = 0 and p.audit_status = 2 and p.status = 1
                order by p.sort_no desc, p.id desc
                """, merchantId).stream().map(this::packageListView).toList();
    }
@@ -50,21 +61,35 @@
    @Transactional(readOnly = true)
    public List<Map<String, Object>> items(Long packageId) {
        packageRow(packageId);
        return query("""
                select id, item_name, item_meaning, male_applicable, female_applicable
                from medical_package_item where package_id = ? and deleted = 0 order by sort_no asc, id asc
                """, packageId).stream().map(row -> Map.<String, Object>of(
                "id", row.get("id"), "name", row.get("itemName"), "meaning", row.get("itemMeaning"),
                "male", number(row.get("maleApplicable")) == 1, "female", number(row.get("femaleApplicable")) == 1
        )).toList();
        Map<String, Object> row = packageRow(packageId);
        String detailText = text(row.get("detailText")).trim();
        if (detailText.isBlank()) return List.of();
        try {
            List<Map<String, Object>> records = JSON.parseObject(detailText, new com.alibaba.fastjson.TypeReference<>() {});
            if (records == null) return List.of();
            java.util.ArrayList<Map<String, Object>> result = new java.util.ArrayList<>();
            for (int index = 0; index < records.size(); index++) {
                Map<String, Object> item = records.get(index);
                List<String> genders = JSON.parseArray(JSON.toJSONString(item.get("gender")), String.class);
                Map<String, Object> normalized = new LinkedHashMap<>();
                normalized.put("id", index + 1);
                normalized.put("name", text(item.get("name")));
                normalized.put("meaning", text(item.get("meaning")));
                normalized.put("male", genders != null && genders.contains("男"));
                normalized.put("female", genders != null && genders.contains("女"));
                result.add(normalized);
            }
            return result;
        } catch (RuntimeException ignored) {
            return List.of();
        }
    }
    private Map<String, Object> medicalMerchant(Long merchantId) {
        if (merchantId == null || merchantId <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "体检医院信息不能为空");
        List<Map<String, Object>> rows = query("""
                select m.id from merchant m join premium_topic_merchant mapping on mapping.merchant_id = m.id
                where m.id = ? and m.merchant_type = 'medical' and m.deleted = 0
                where m.id = ? and m.merchant_type in ('medical', '免费体检', 'healthCheck') and m.deleted = 0
                  and mapping.category_key = 'medical' and mapping.deleted = 0
                """, merchantId);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "体检医院不存在");
@@ -74,13 +99,21 @@
    private Map<String, Object> packageRow(Long packageId) {
        if (packageId == null || packageId <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "体检套餐信息不能为空");
        List<Map<String, Object>> rows = query("""
                select p.id as package_id, p.merchant_id, p.package_name, p.hero_image, p.free_status, p.applicable_cards,
                       p.open_time, p.stock, p.status, p.unavailable_reason, m.merchant_name as hospital_name,
                select p.id as package_id, p.merchant_id, p.product_name as package_name, p.cover_url as hero_image,
                       case when coalesce(p.sale_price, 0) = 0 then 1 else 0 end as free_status,
                       '' as applicable_cards,
                       coalesce(
                           nullif(json_unquote(json_extract(p.purchase_notice, '$.availableTime')), ''),
                           nullif(json_unquote(json_extract(p.purchase_notice, '$.validPeriod')), ''),
                           ''
                       ) as open_time,
                       p.stock, p.status, '' as unavailable_reason, p.detail_text,
                       m.merchant_name as hospital_name,
                       m.address as hospital_address, m.contact_mobile as hospital_mobile, m.latitude as hospital_latitude,
                       m.longitude as hospital_longitude
                from medical_package p join merchant m on m.id = p.merchant_id and m.deleted = 0
                from merchant_product p join merchant m on m.id = p.merchant_id and m.deleted = 0
                join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.category_key = 'medical' and mapping.deleted = 0
                where p.id = ? and p.deleted = 0
                where p.id = ? and p.deleted = 0 and p.audit_status = 2
                """, packageId);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "体检套餐不存在");
        return rows.get(0);
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -330,13 +330,15 @@
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> stayMerchants(String type, int page, int size) {
        String requestedType = type == null ? "" : type.trim();
        String merchantType = switch (requestedType) {
            case "hotel", "酒店服务" -> "酒店服务";
            case "apartment", "公寓租赁服务" -> "公寓租赁服务";
        boolean apartment = switch (requestedType) {
            case "hotel", "酒店服务" -> false;
            case "apartment", "公寓租赁", "公寓租赁服务" -> true;
            default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的住宿服务类型");
        };
        String responseMerchantType = merchantType.equals("公寓租赁服务") ? "apartment" : "hotel";
        String merchantWhere = " where deleted = 0 and merchant_type = ?";
        String responseMerchantType = apartment ? "apartment" : "hotel";
        String merchantWhere = apartment
                ? " where deleted = 0 and merchant_type in ('apartment', '公寓租赁', '公寓租赁服务')"
                : " where deleted = 0 and merchant_type in ('hotel', '酒店服务')";
        PageResult<Map<String, Object>> result = page("""
                select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                       status, sort_no, longitude, latitude, business_status,
@@ -344,7 +346,7 @@
                       (select min(p.original_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as original_price
                from merchant
                """ + merchantWhere + " order by sort_no asc, id asc",
                "select count(*) from merchant" + merchantWhere, page, size, merchantType);
                "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);
talent-card-server/src/main/java/com/talentcard/server/business/portal/SpouseEmploymentPortalController.java
@@ -10,6 +10,7 @@
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@@ -23,8 +24,11 @@
    }
    @GetMapping("/{merchantId}")
    public ApiResponse<Map<String, Object>> detail(@PathVariable Long merchantId) {
        return ApiResponse.success(service.detail(merchantId));
    public ApiResponse<Map<String, Object>> detail(
            @PathVariable Long merchantId,
            @RequestParam(required = false) Long talentId
    ) {
        return ApiResponse.success(service.detail(merchantId, talentId));
    }
    @PostMapping("/{merchantId}/applications")
talent-card-server/src/main/java/com/talentcard/server/business/portal/SpouseEmploymentPortalService.java
@@ -5,6 +5,7 @@
import java.util.List;
import java.util.Map;
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.common.BusinessException;
@@ -27,14 +28,15 @@
    }
    @Transactional(readOnly = true)
    public Map<String, Object> detail(Long merchantId) {
    public Map<String, Object> detail(Long merchantId, Long talentId) {
        Map<String, Object> benefit = benefit(merchantId);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("benefitId", benefit.get("id"));
        result.put("benefitId", benefit.get("id") == null ? merchantId : benefit.get("id"));
        result.put("serviceId", benefit.get("merchantId"));
        result.put("title", benefit.get("title"));
        result.put("heroImage", benefit.get("coverUrl"));
        result.put("freeLabel", benefit.get("freeLabel"));
        result.put("rulesText", benefit.get("rulesText"));
        result.put("rulesText", configuredRulesText(benefit, talentId));
        result.put("consultationPhone", benefit.get("consultationPhone"));
        result.put("available", number(benefit.get("enabled")) == 1);
        result.put("unavailableReason", text(benefit.get("unavailableReason"), "该权益暂未开放"));
@@ -99,16 +101,64 @@
    private Map<String, Object> benefit(Long merchantId) {
        if (merchantId == null || merchantId <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "权益信息不能为空");
        List<Map<String, Object>> rows = query("""
                select b.*, m.merchant_name
                from spouse_employment_benefit b
                join merchant m on m.id = b.merchant_id and m.deleted = 0
                select b.id, m.id as merchant_id, m.merchant_name, m.cover_url,
                       coalesce(nullif(b.title, ''), nullif(m.merchant_name, ''), '配偶推荐就业') as title,
                       coalesce(nullif(b.free_label, ''), '免费') as free_label,
                       coalesce(nullif(b.rules_text, ''), nullif(m.rights_text, ''), '请咨询服务单位了解配偶推荐就业权益详情') as rules_text,
                       coalesce(nullif(b.consultation_phone, ''), m.contact_mobile, '') as consultation_phone,
                       coalesce(nullif(b.expected_time, ''), '提交后 5 个工作日内由服务专员联系确认') as expected_time,
                       coalesce(nullif(b.education_options, ''), '高中及以下,大专,本科,硕士研究生,博士研究生') as education_options,
                       coalesce(nullif(b.institution_options, ''), '是,否') as institution_options,
                       coalesce(b.enabled, m.status, 0) as enabled,
                       coalesce(nullif(b.unavailable_reason, ''), '该权益暂未开放') as unavailable_reason,
                       m.merchant_rights_config
                from merchant m
                left join spouse_employment_benefit b on b.merchant_id = m.id and b.deleted = 0
                join premium_topic_merchant mapping on mapping.merchant_id = m.id and mapping.category_key = 'employment' and mapping.deleted = 0
                where b.merchant_id = ? and b.deleted = 0
                where m.id = ? and m.deleted = 0
                """, merchantId);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "配偶推荐就业权益不存在");
        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.
            }
        }
        return text(benefit.get("rulesText"), "请咨询服务单位了解配偶推荐就业权益详情");
    }
    private String activeCardLevel(Long talentId) {
        if (talentId == null || talentId <= 0) return "";
        List<Map<String, Object>> cards = query("""
                select card_level from talent_card
                where talent_id = ? and deleted = 0 and card_type = 'PERSONAL' and status = 1
                  and (valid_end is null or valid_end >= current_date)
                order by id desc limit 1
                """, talentId);
        return cards.isEmpty() ? "" : text(cards.get(0).get("cardLevel"), "");
    }
    private boolean booleanValue(Object value) {
        if (value instanceof Boolean bool) return bool;
        if (value instanceof Number number) return number.intValue() != 0;
        String result = text(value, "");
        return "true".equalsIgnoreCase(result) || "1".equals(result) || "yes".equalsIgnoreCase(result);
    }
    private Map<String, Object> activeCard(Long talentId) {
        if (talentId == null || talentId <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "请先登录后提交");
        List<Map<String, Object>> cards = query("""
talent-card-server/src/main/java/com/talentcard/server/mapper/ServiceApplicationMapper.java
@@ -69,14 +69,19 @@
    @Select("""
            select p.id as packageId,
                   p.merchant_id as merchantId,
                   p.package_name as packageName,
                   p.product_name as packageName,
                   p.stock,
                   p.status,
                   p.unavailable_reason as unavailableReason
            from medical_package p
                   case
                       when p.status != 1 then '套餐已停用'
                       when p.stock = 0 then '当前库存不足'
                       else ''
                   end as unavailableReason
            from merchant_product p
            where p.id = #{packageId}
              and (#{merchantId} is null or p.merchant_id = #{merchantId})
              and p.deleted = 0
              and p.audit_status = 2
            limit 1
            """)
    Map<String, Object> selectMedicalPackage(@Param("packageId") Long packageId, @Param("merchantId") Long merchantId);
@@ -117,4 +122,4 @@
            for update
            """)
    ServiceApplication selectByIdForUpdate(@Param("id") Long id);
}
}
talent-card-server/src/main/java/com/talentcard/server/service/ServiceAppointmentApplicationService.java
@@ -357,7 +357,10 @@
        if (merchant == null || merchant.isEmpty()) {
            throw new BusinessException(ErrorCode.NOT_FOUND, message);
        }
        if (expectedType != null && !expectedType.equalsIgnoreCase(text(merchant, "merchantType", "merchant_type"))) {
        String actualType = text(merchant, "merchantType", "merchant_type");
        boolean medicalType = "medical".equalsIgnoreCase(expectedType)
                && ("medical".equalsIgnoreCase(actualType) || "healthCheck".equalsIgnoreCase(actualType) || "免费体检".equals(actualType));
        if (expectedType != null && !medicalType && !expectedType.equalsIgnoreCase(actualType)) {
            throw new BusinessException(ErrorCode.CONFLICT, message);
        }
        return merchant;