shinnosuke
2026-08-27 b0edd6c4bd7912e706ef3af5a221caf0d6c78fac
fix: align miniapp services and admin dashboard
1个文件已添加
9个文件已修改
374 ■■■■■ 已修改文件
rencaika/pages/cinema-merchant/cinema-merchant.uvue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/coupon-received/coupon-received.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/medical-booking/medical-booking.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/types/admin.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/DashboardView.vue 142 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/ShoppingMerchantPortalService.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/AdminWorkbenchStatisticsMapper.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/AdminWorkbenchStatisticsService.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/portal/CinemaPortalServiceTests.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/portal/ShoppingMerchantPortalServiceTests.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/cinema-merchant/cinema-merchant.uvue
@@ -10,12 +10,12 @@
                    <view class="merchant-info"><view class="title-row"><text class="merchant-name">{{cinemaName}}</text><view class="coupon-badge"><text>券</text></view></view><view class="service-tag"><text>{{talentCardTag}}</text></view><view class="address-row"><image class="address-icon" src="/static/benefits-assets/map-pin.svg" mode="aspectFit"></image><text class="address-text">{{cinemaAddress}}</text><view class="merchant-actions"><view class="action-button" @tap="openMap"><image src="/static/merchant-assets/navigation.svg" mode="aspectFit"></image></view><view v-if="cinemaMobile != ''" class="action-button" @tap="callCinema"><image src="/static/merchant-assets/phone.svg" mode="aspectFit"></image></view></view></view></view>
                    <view class="tabs"><view class="tab-item" :class="currentTab == 'purchase' ? 'active' : ''" @tap="changeTab('purchase')"><text>消费购买</text><view></view></view><view class="tab-item" :class="currentTab == 'rights' ? 'active' : ''" @tap="changeTab('rights')"><text>权益详情</text><view></view></view></view>
                    <view v-if="currentTab == 'purchase'" class="coupon-list"><view v-if="listLoading" class="list-state"><view class="loading-spinner"></view><text>正在加载可领电影券</text></view><view v-else-if="listError" class="list-state error-state"><text>电影券加载失败</text><text class="retry-text" @tap="loadCoupons">重新加载</text></view><view v-else-if="listLoaded && coupons.length == 0" class="list-state"><text>暂无可领取电影券</text></view><view class="coupon-item" v-for="item in coupons" :key="item.id"><image class="coupon-image" :src="item.image" mode="aspectFill" @error="replaceCouponImage(item)"></image><view class="coupon-info"><text class="coupon-name">{{item.name}}</text><view class="stock-row"><text>剩余可领</text><text>{{item.remainingCount}}</text><text>张</text></view><view class="coupon-bottom"><view class="coupon-price"><text>¥</text><text>{{item.price}}</text></view><view class="claim-button" :class="item.claimed ? 'claimed' : (item.soldOut ? 'sold-out' : '')" @tap="requestClaimCoupon(item)"><text>{{item.buttonText}}</text></view></view></view></view></view>
                    <view v-else class="rights-panel"><text class="rights-title">{{rightsTitle}}</text><text class="rights-description">{{rightsDescription}}</text><view class="rule-row"><text>适用卡种</text><text>{{applicableCards}}</text></view><view class="rule-row"><text>领取价格</text><text>{{claimPrice}}</text></view><view class="rule-row"><text>使用方式</text><text>{{usage}}</text></view><view class="rule-row"><text>使用范围</text><text>{{scope}}</text></view></view>
                    <view v-else class="rights-panel"><text class="rights-title">{{rightsTitle}}</text><text class="rights-description">{{rightsDescription}}</text></view>
                </view>
            </view>
            <view class="content-bottom"></view>
        </scroll-view>
        <view v-if="showClaimDialog" class="dialog-mask" @tap="closeClaimDialog"><view class="claim-dialog" @tap.stop><view class="dialog-close" @tap="closeClaimDialog"><text>×</text></view><text class="dialog-title">是否确认领取</text><view class="dialog-message"><text>当前剩余可领取次数</text><text>{{pendingCouponStock}}</text><text>次</text></view><view class="dialog-actions"><view class="dialog-cancel" @tap="closeClaimDialog"><text>取消</text></view><view class="dialog-confirm" @tap="confirmClaimCoupon"><text>{{isSubmitting ? '领取中' : '确认领取'}}</text></view></view></view></view>
        <view v-if="showClaimDialog" class="dialog-mask" @tap="closeClaimDialog"><view class="claim-dialog" @tap.stop><view class="dialog-close" @tap="closeClaimDialog"><text>×</text></view><text class="dialog-title">是否确认领取</text><view class="dialog-message"><text>当前剩余可领取次数</text><text>{{pendingCouponStock}}</text><text>次</text></view><view class="dialog-actions"><view class="dialog-cancel" hover-class="dialog-button-active" @tap="closeClaimDialog"><text>取消</text></view><view class="dialog-confirm" hover-class="dialog-button-active" @tap="confirmClaimCoupon"><text>{{isSubmitting ? '领取中' : '确认领取'}}</text></view></view></view></view>
    </view>
</template>
@@ -30,7 +30,7 @@
    const navTop = ref(28); const navHeight = ref(32); const navWidth = ref(88); const navRight = ref(10)
    const isSolidNav = ref(false); const currentTab = ref('purchase'); const merchantId = ref(''); const talentId = ref(''); const cardId = ref('')
    const cinemaName = ref('影城服务'); const cinemaAddress = ref(''); const cinemaHero = ref(fallbackImage); const cinemaMobile = ref(''); const cinemaLatitude = ref(0); const cinemaLongitude = ref(0)
    const talentCardTag = ref('人才卡专享'); const rightsTitle = ref('人才观影专属权益'); const rightsDescription = ref(''); const applicableCards = ref('以商户说明为准'); const claimPrice = ref('0元'); const usage = ref('到店出示券码'); const scope = ref('普通厅指定场次')
    const talentCardTag = ref('人才卡专享'); const rightsTitle = ref('人才观影专属权益'); const rightsDescription = ref('')
    const detailLoading = ref(true); const detailError = ref(false); const quotaRemaining = ref(0); const coupons = ref<any[]>([]); const listLoading = ref(false); const listLoaded = ref(false); const listError = ref(false)
    const showClaimDialog = ref(false); const isSubmitting = ref(false); const pendingCouponId = ref(''); const pendingCouponStock = ref(0)
@@ -63,7 +63,7 @@
            cinemaHero.value = data.heroImage != null && data.heroImage != '' ? resolveApiUrl(data.heroImage as string) : fallbackImage
            cinemaMobile.value = data.mobile != null ? data.mobile as string : ''
            cinemaLatitude.value = data.latitude != null ? Number(data.latitude) : 0; cinemaLongitude.value = data.longitude != null ? Number(data.longitude) : 0
            talentCardTag.value = data.talentCardTag != null ? data.talentCardTag as string : talentCardTag.value; rightsTitle.value = data.rightsTitle != null ? data.rightsTitle as string : rightsTitle.value; rightsDescription.value = data.rightsDescription != null ? data.rightsDescription as string : '以商户说明为准'; applicableCards.value = data.applicableCards != null ? data.applicableCards as string : applicableCards.value; claimPrice.value = data.claimPrice != null ? data.claimPrice as string : claimPrice.value; usage.value = data.usage != null ? data.usage as string : usage.value; scope.value = data.scope != null ? data.scope as string : scope.value
            talentCardTag.value = data.talentCardTag != null ? data.talentCardTag as string : talentCardTag.value; rightsTitle.value = data.rightsTitle != null ? data.rightsTitle as string : rightsTitle.value; rightsDescription.value = data.rightsDescription != null ? data.rightsDescription as string : '以商户说明为准'
            loadCoupons(); loadQuota()
        }, () => { detailLoading.value = false; detailError.value = true })
    }
@@ -94,6 +94,6 @@
    .merchant-info { padding: 27rpx 31rpx 19rpx; background-color: #FFFFFF; }.title-row { display: flex; min-height: 48rpx; flex-direction: row; align-items: center; justify-content: space-between; }.merchant-name { min-width: 0; flex: 1; color: #282828; font-size: 32rpx; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.coupon-badge { display: flex; width: 42rpx; height: 39rpx; margin-left: 18rpx; border-radius: 7rpx; background-color: #FFF0EA; justify-content: center; align-items: center; }.coupon-badge text { color: #FF5B36; font-size: 22rpx; }.service-tag { display: flex; width: 180rpx; height: 42rpx; margin-top: 17rpx; box-sizing: border-box; border: 1rpx solid #FFB897; border-radius: 7rpx; justify-content: center; align-items: center; }.service-tag text { color: #E56843; font-size: 23rpx; }.address-row { display: flex; min-height: 58rpx; margin-top: 8rpx; flex-direction: row; align-items: center; }.address-icon { width: 27rpx; height: 27rpx; margin-right: 10rpx; flex-shrink: 0; }.address-text { min-width: 0; flex: 1; color: #858A92; font-size: 22rpx; line-height: 32rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.merchant-actions { display: flex; width: 112rpx; margin-left: 18rpx; flex-direction: row; justify-content: space-between; flex-shrink: 0; }.action-button { display: flex; width: 48rpx; height: 48rpx; border-radius: 50%; background-color: #F4F7FA; justify-content: center; align-items: center; }.action-button image { width: 27rpx; height: 27rpx; }
    .tabs { display: flex; height: 92rpx; box-sizing: border-box; padding: 0 31rpx; background-color: #FFFFFF; flex-direction: row; align-items: flex-end; }.tab-item { display: flex; width: 164rpx; height: 92rpx; position: relative; color: #55585D; flex-direction: column; justify-content: center; align-items: flex-start; }.tab-item text { font-size: 30rpx; }.tab-item view { position: absolute; left: 28rpx; bottom: 4rpx; width: 54rpx; height: 7rpx; opacity: 0; border-radius: 4rpx; background-image: linear-gradient(90deg, #FF4434 0%, #FFB631 100%); }.tab-item.active { color: #333333; font-weight: 700; }.tab-item.active view { opacity: 1; }
    .coupon-list { padding: 21rpx 24rpx 8rpx; }.coupon-item { display: flex; height: 176rpx; margin-bottom: 15rpx; box-sizing: border-box; padding: 14rpx 15rpx; border-radius: 15rpx; background-color: #FFFFFF; flex-direction: row; }.coupon-image { width: 145rpx; height: 148rpx; border-radius: 13rpx; flex-shrink: 0; background-color: #E8E8E8; }.coupon-info { display: flex; min-width: 0; flex: 1; padding-left: 22rpx; flex-direction: column; }.coupon-name { color: #242424; font-size: 29rpx; line-height: 40rpx; font-weight: 700; }.stock-row { display: flex; margin-top: 10rpx; color: #8A8A8A; font-size: 21rpx; flex-direction: row; align-items: center; }.stock-row text:nth-child(2) { margin: 0 7rpx; color: #FF6037; }.coupon-bottom { display: flex; height: 56rpx; margin-top: auto; border-radius: 29rpx; background-image: linear-gradient(90deg, rgba(255,247,240,.12) 0%, rgba(255,235,218,.76) 58%, rgba(255,222,195,.96) 100%); flex-direction: row; align-items: center; justify-content: space-between; }.coupon-price { display: flex; height: 56rpx; padding-left: 5rpx; color: #FF4F2C; flex-direction: row; align-items: center; }.coupon-price text:first-child { font-size: 27rpx; }.coupon-price text:last-child { margin-left: 3rpx; font-size: 38rpx; font-weight: 700; }.claim-button { display: flex; width: 126rpx; height: 56rpx; border-radius: 29rpx; background-image: linear-gradient(90deg, #FF4433 0%, #FF8B20 100%); justify-content: center; align-items: center; }.claim-button text { color: #FFFFFF; font-size: 25rpx; }.claim-button.claimed, .claim-button.sold-out { background-image: none; background-color: #C8C8C8; }
    .rights-panel { margin: 21rpx 24rpx 8rpx; padding: 28rpx 27rpx 18rpx; border-radius: 15rpx; background-color: #FFFFFF; }.rights-title { display: block; color: #2F2F2F; font-size: 31rpx; font-weight: 700; }.rights-description { display: block; margin-top: 19rpx; color: #737984; font-size: 26rpx; line-height: 43rpx; }.rule-row { display: flex; min-height: 72rpx; border-top: 1rpx solid #EEEEEE; color: #777D86; font-size: 25rpx; flex-direction: row; align-items: center; justify-content: space-between; }.rule-row:first-of-type { margin-top: 20rpx; }.rule-row text:last-child { max-width: 390rpx; color: #34383E; text-align: right; }.content-bottom { height: calc(30rpx + env(safe-area-inset-bottom)); }
    .dialog-mask { display: flex; position: fixed; z-index: 100; left: 0; right: 0; top: 0; bottom: 0; background-color: rgba(24,29,38,.48); justify-content: center; align-items: center; }.claim-dialog { display: flex; width: 664rpx; height: 376rpx; position: relative; box-sizing: border-box; padding: 70rpx 48rpx 0; border-radius: 34rpx; background-color: #FFFFFF; flex-direction: column; align-items: center; }.dialog-close { display: flex; width: 64rpx; height: 64rpx; position: absolute; right: 28rpx; top: 22rpx; justify-content: center; align-items: center; }.dialog-close text { color: #AAB2BF; font-size: 43rpx; line-height: 48rpx; font-weight: 300; }.dialog-title { color: #212633; font-size: 36rpx; line-height: 50rpx; font-weight: 700; }.dialog-message { display: flex; margin-top: 24rpx; color: #A9AFBA; font-size: 25rpx; line-height: 36rpx; flex-direction: row; align-items: center; }.dialog-message text:nth-child(2) { margin: 0 7rpx; color: #FF7046; }.dialog-actions { display: flex; width: 100%; margin-top: 66rpx; padding: 0 5rpx; box-sizing: border-box; flex-direction: row; align-items: center; justify-content: space-between; }.dialog-cancel { display: flex; width: 150rpx; height: 70rpx; justify-content: center; align-items: center; }.dialog-cancel text { color: #FF752B; font-size: 27rpx; font-weight: 500; }.dialog-confirm { display: flex; width: 178rpx; height: 70rpx; border-radius: 36rpx; background-image: linear-gradient(90deg, #FF4434 0%, #FF8C21 100%); justify-content: center; align-items: center; }.dialog-confirm text { color: #FFFFFF; font-size: 27rpx; font-weight: 500; }
    .rights-panel { margin: 21rpx 24rpx 8rpx; padding: 28rpx 27rpx 18rpx; border-radius: 15rpx; background-color: #FFFFFF; }.rights-title { display: block; color: #2F2F2F; font-size: 31rpx; font-weight: 700; }.rights-description { display: block; margin-top: 19rpx; color: #737984; font-size: 26rpx; line-height: 43rpx; white-space: pre-wrap; word-break: break-word; }.content-bottom { height: calc(30rpx + env(safe-area-inset-bottom)); }
    .dialog-mask { display: flex; position: fixed; z-index: 100; left: 0; right: 0; top: 0; bottom: 0; background-color: rgba(24,29,38,.48); justify-content: center; align-items: center; }.claim-dialog { display: flex; width: 664rpx; height: 376rpx; position: relative; box-sizing: border-box; padding: 70rpx 48rpx 0; border-radius: 34rpx; background-color: #FFFFFF; flex-direction: column; align-items: center; }.dialog-close { display: flex; width: 64rpx; height: 64rpx; position: absolute; right: 28rpx; top: 22rpx; justify-content: center; align-items: center; }.dialog-close text { color: #AAB2BF; font-size: 43rpx; line-height: 48rpx; font-weight: 300; }.dialog-title { color: #212633; font-size: 36rpx; line-height: 50rpx; font-weight: 700; }.dialog-message { display: flex; margin-top: 24rpx; color: #A9AFBA; font-size: 25rpx; line-height: 36rpx; flex-direction: row; align-items: center; }.dialog-message text:nth-child(2) { margin: 0 7rpx; color: #FF7046; }.dialog-actions { display: flex; width: 100%; margin-top: 66rpx; padding: 0 5rpx; box-sizing: border-box; flex-direction: row; align-items: center; justify-content: space-between; }.dialog-cancel, .dialog-confirm { display: flex; width: 220rpx; height: 72rpx; box-sizing: border-box; border-radius: 36rpx; justify-content: center; align-items: center; }.dialog-cancel { border: 2rpx solid #FF752B; background-color: #FFF4ED; }.dialog-cancel text { color: #F2652D; font-size: 27rpx; font-weight: 500; }.dialog-confirm { background-image: linear-gradient(90deg, #FF4434 0%, #FF8C21 100%); }.dialog-confirm text { color: #FFFFFF; font-size: 27rpx; font-weight: 500; }.dialog-button-active { opacity: .76; }
</style>
rencaika/pages/coupon-received/coupon-received.uvue
@@ -6,7 +6,7 @@
        <view v-else class="result-card">
            <view class="coupon-heading"><view class="heading-icon"><image src="/static/merchant-assets/coupon-orange.svg" mode="aspectFit"></image></view><text>{{couponName}}</text></view><view class="heading-divider"></view>
            <view v-if="hasAnnual" class="annual-panel"><view class="annual-title-row"><view class="annual-title"><image src="/static/merchant-assets/coupon-orange.svg" mode="aspectFit"></image><text>可领取额度</text></view><view class="remaining-pill"><text>✓</text><text>剩余 {{annualRemaining}} 张</text></view></view><view class="progress-track"><view class="progress-value" :style="'width:' + progressPercent + '%'"></view></view><view class="annual-stats"><text>◉ 已领 {{annualClaimed}} 张</text><text>▣ 剩余 {{annualRemaining}} 张</text><text>总计 {{annualTotal}} 张</text></view><text class="annual-caption">可领取额度{{annualTotal}}张 / 已领{{annualClaimed}}张 / 剩余{{annualRemaining}}张</text></view>
            <view class="voucher-section"><view class="section-title"><image src="/static/merchant-assets/coupon-orange.svg" mode="aspectFit"></image><text>兑换凭证</text></view><view v-if="canCopy" class="voucher-code"><text>{{exchangeCode}}</text></view><view v-else class="voucher-unavailable"><text>{{codeStatusText}}</text></view><view class="voucher-tip"><view class="mini-copy-icon"><view></view><view></view></view><text>{{voucherTip}}</text></view></view>
            <view class="voucher-section"><view class="section-title"><image src="/static/merchant-assets/coupon-orange.svg" mode="aspectFit"></image><text>兑换凭证</text></view><view v-if="canCopy" class="voucher-code"><text selectable="true">{{exchangeCode}}</text></view><view v-else class="voucher-unavailable"><text>{{codeStatusText}}</text></view><view class="voucher-tip"><view class="mini-copy-icon"><view></view><view></view></view><text>{{voucherTip}}</text></view></view>
            <view v-if="canCopy" class="copy-button" hover-class="copy-button-active" @tap="copyExchangeCode"><view class="copy-icon"><view></view><view></view></view><text>复制兑换码</text></view>
        </view>
    </view>
@@ -40,6 +40,6 @@
    .page-state { display: flex; min-height: 560rpx; color: #6F7885; font-size: 27rpx; flex-direction: column; align-items: center; justify-content: center; }.loading-spinner { width: 42rpx; height: 42rpx; margin-bottom: 22rpx; box-sizing: border-box; border: 5rpx solid #FFDCC8; border-top-color: #F36F31; border-radius: 50%; animation: coupon-spin 750ms linear infinite; }.error-state { color: #7D8490; }.retry-text { margin-top: 20rpx; color: #F26A38; }@keyframes coupon-spin { to { transform: rotate(360deg); } }
    .result-card { display: flex; min-height: 0; flex: 1; margin: 18rpx 20rpx calc(22rpx + env(safe-area-inset-bottom)); box-sizing: border-box; padding: 25rpx 27rpx 0; border-radius: 42rpx; background-color: #FFFFFF; flex-direction: column; }.coupon-heading { display: flex; height: 72rpx; flex-direction: row; align-items: center; }.heading-icon { display: flex; width: 58rpx; height: 58rpx; margin-right: 16rpx; border-radius: 50%; background-color: #FFF3E8; justify-content: center; align-items: center; }.heading-icon image { width: 33rpx; height: 33rpx; }.coupon-heading text { min-width: 0; flex: 1; color: #252A32; font-size: 29rpx; line-height: 42rpx; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.heading-divider { height: 1rpx; margin: 3rpx 0 20rpx; background-color: #EEF0F3; }
    .annual-stats { display: flex; margin-top: 24rpx; color: #66717F; font-size: 21rpx; flex-direction: row; justify-content: space-between; }.annual-caption { display: block; margin-top: 16rpx; color: #A0A8B3; font-size: 20rpx; text-align: center; }.annual-panel { padding: 24rpx 27rpx 29rpx; border-radius: 28rpx; background-color: #F7F9FC; }.annual-title-row { display: flex; height: 48rpx; flex-direction: row; align-items: center; justify-content: space-between; }.annual-title { display: flex; flex-direction: row; align-items: center; }.annual-title image { width: 27rpx; height: 27rpx; margin-right: 12rpx; }.annual-title text { color: #4F5662; font-size: 27rpx; font-weight: 700; }.remaining-pill { display: flex; height: 43rpx; padding: 0 17rpx; border-radius: 22rpx; background-color: #FFF1E4; color: #FF7C35; font-size: 21rpx; flex-direction: row; align-items: center; }.remaining-pill text:first-child { margin-right: 8rpx; }.progress-track { height: 15rpx; margin-top: 28rpx; overflow: hidden; border-radius: 8rpx; background-color: #E4E8ED; }.progress-value { height: 15rpx; border-radius: 8rpx; background-image: linear-gradient(90deg, #FF4634 0%, #FF8D23 100%); }
    .voucher-section { margin-top: 62rpx; padding: 0 22rpx; }.section-title { display: flex; height: 45rpx; flex-direction: row; align-items: center; }.section-title image { width: 27rpx; height: 27rpx; margin-right: 11rpx; }.section-title text { color: #505762; font-size: 27rpx; font-weight: 700; }.voucher-code, .voucher-unavailable { display: flex; height: 82rpx; margin-top: 10rpx; padding: 0 18rpx; border-radius: 24rpx; background-color: #F6F8FB; justify-content: center; align-items: center; }voucher-code text { color: #1D2531; font-size: 35rpx; line-height: 52rpx; font-weight: 700; letter-spacing: 3rpx; }.voucher-unavailable text { color: #8C95A1; font-size: 24rpx; text-align: center; }.voucher-tip { display: flex; margin-top: 17rpx; color: #7F8998; font-size: 20rpx; line-height: 29rpx; flex-direction: row; align-items: flex-start; }.voucher-tip text { min-width: 0; flex: 1; }.mini-copy-icon { width: 23rpx; height: 25rpx; margin: 2rpx 12rpx 0 3rpx; position: relative; flex-shrink: 0; }.mini-copy-icon view, .copy-icon view { position: absolute; box-sizing: border-box; border: 2rpx solid currentColor; border-radius: 3rpx; }.mini-copy-icon view:first-child { left: 6rpx; top: 0; width: 14rpx; height: 17rpx; }.mini-copy-icon view:last-child { left: 1rpx; top: 6rpx; width: 14rpx; height: 17rpx; }
    .voucher-section { margin-top: 62rpx; padding: 0 22rpx; }.section-title { display: flex; height: 45rpx; flex-direction: row; align-items: center; }.section-title image { width: 27rpx; height: 27rpx; margin-right: 11rpx; }.section-title text { color: #505762; font-size: 27rpx; font-weight: 700; }.voucher-code, .voucher-unavailable { display: flex; min-height: 82rpx; margin-top: 10rpx; box-sizing: border-box; padding: 16rpx 18rpx; border-radius: 24rpx; background-color: #F6F8FB; justify-content: center; align-items: center; }.voucher-code text { width: 100%; color: #1D2531; font-size: 30rpx; line-height: 42rpx; font-weight: 700; letter-spacing: 2rpx; text-align: center; word-break: break-all; overflow-wrap: anywhere; }.voucher-unavailable text { color: #8C95A1; font-size: 24rpx; text-align: center; }.voucher-tip { display: flex; margin-top: 17rpx; color: #7F8998; font-size: 20rpx; line-height: 29rpx; flex-direction: row; align-items: flex-start; }.voucher-tip text { min-width: 0; flex: 1; }.mini-copy-icon { width: 23rpx; height: 25rpx; margin: 2rpx 12rpx 0 3rpx; position: relative; flex-shrink: 0; }.mini-copy-icon view, .copy-icon view { position: absolute; box-sizing: border-box; border: 2rpx solid currentColor; border-radius: 3rpx; }.mini-copy-icon view:first-child { left: 6rpx; top: 0; width: 14rpx; height: 17rpx; }.mini-copy-icon view:last-child { left: 1rpx; top: 6rpx; width: 14rpx; height: 17rpx; }
    .copy-button { display: flex; width: 100%; height: 88rpx; margin-top: auto; margin-bottom: 150rpx; flex-shrink: 0; border-radius: 46rpx; color: #FFFFFF; background-image: linear-gradient(90deg, #FF4434 0%, #FF8D21 100%); box-shadow: 0 15rpx 28rpx rgba(255,103,39,.21); flex-direction: row; justify-content: center; align-items: center; }.copy-button-active { opacity: .78; }.copy-button text { font-size: 30rpx; font-weight: 600; }.copy-icon { width: 31rpx; height: 31rpx; margin-right: 16rpx; position: relative; }.copy-icon view:first-child { left: 9rpx; top: 1rpx; width: 18rpx; height: 21rpx; }.copy-icon view:last-child { left: 2rpx; top: 8rpx; width: 18rpx; height: 21rpx; }
</style>
rencaika/pages/medical-booking/medical-booking.uvue
@@ -6,7 +6,7 @@
                <view class="back-button" hover-class="back-button-active" @tap="goBack">
                    <image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image>
                </view>
                <text class="nav-title">市中心医院</text>
                <text class="nav-title">{{hospitalName || '体检预约'}}</text>
                <view class="capsule-space" :style="'width:' + navWidth + 'px'"></view>
            </view>
        </view>
@@ -59,7 +59,7 @@
    const userName = ref('')
    const userMobile = ref('')
    const userIdCard = ref('')
    const hospitalName = ref('攀枝花市中心医院')
    const hospitalName = ref('')
    const hospitalId = ref('')
    const packageId = ref('')
    const talentId = ref('')
talent-card-admin/src/types/admin.ts
@@ -16,6 +16,9 @@
  talentCompare?: number
  monthlyVerificationCompare?: number
  subsidyCompare?: number
  talentSpark?: number[]
  verificationSpark?: number[]
  subsidySpark?: number[]
  verificationPeriod?: 'week' | 'month' | 'year'
  verificationTrend?: Array<{ time: string; order: number; amount: number }>
  pendingItems?: Array<{ label: string; value: number; color?: string }>
talent-card-admin/src/views/DashboardView.vue
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getDashboard } from '../api/admin'
type Period = 'week' | 'month' | 'year'
@@ -26,7 +27,15 @@
const loading = ref(false)
const stats = ref<Record<string, any>>({})
const activePeriod = ref<Period>('month')
const merchantExpanded = ref(false)
const router = useRouter()
const pendingRoutes: Record<string, string> = {
  体检预约: '/medical-reservations',
  停车车牌: '/parking-plates',
  酒店预约: '/hotel-reservations',
  家属卡申请: '/family-card-applications',
  配偶就业申请: '/spouse-employment',
}
const summaryCards = computed(() => [
  {
@@ -34,6 +43,7 @@
    value: stats.value.cardholderTotal ?? stats.value.talentCount ?? 0,
    compare: stats.value.talentCompare ?? 0,
    tone: 'blue',
    chartType: 'line',
    spark: sparkValues(stats.value.talentSpark),
    unit: '较昨天',
  },
@@ -42,30 +52,80 @@
    value: stats.value.monthlyVerificationOrders ?? stats.value.monthlyVerificationCount ?? 0,
    compare: stats.value.monthlyVerificationCompare ?? 0,
    tone: 'green',
    chartType: 'bar',
    spark: sparkValues(stats.value.verificationSpark),
    unit: '较昨天',
    unit: '较上月',
  },
  {
    label: '补贴总额',
    value: stats.value.cumulativeSubsidyTotal ?? stats.value.totalSubsidyAmount ?? 0,
    compare: stats.value.subsidyCompare ?? 0,
    tone: 'blue',
    chartType: 'line',
    spark: sparkValues(stats.value.subsidySpark),
    unit: '较昨天',
  },
])
const verificationBars = computed<TrendPoint[]>(() => Array.isArray(stats.value.verificationTrend) ? stats.value.verificationTrend : [])
const maxBarValue = computed(() => Math.max(1, ...verificationBars.value.map(item => Number(item.order || 0) + Number(item.amount || 0))))
const yAxisLabels = computed(() => Array.from({ length: 6 }, (_, index) => formatAxis(maxBarValue.value * (5 - index) / 5)))
const pendingItems = computed(() => Array.isArray(stats.value.pendingItems) ? stats.value.pendingItems : [])
const allHotMerchants = computed<HotMerchant[]>(() => Array.isArray(stats.value.hotMerchants) ? stats.value.hotMerchants : [])
const hotMerchants = computed(() => merchantExpanded.value ? allHotMerchants.value : allHotMerchants.value.slice(0, 7))
const maxOrderValue = computed(() => Math.max(0, ...verificationBars.value.map(item => Number(item.order || 0))))
const orderAxisMax = computed(() => Math.max(1, maxOrderValue.value))
const yAxisTicks = computed(() => {
  const maximum = maxOrderValue.value
  if (maximum <= 0) return [{ value: 0, top: 192 }]
  const step = axisStep(maximum)
  const values = [maximum]
  for (let value = maximum - step; value > 0; value -= step) values.push(value)
  values.push(0)
  return values.map(value => ({ value, top: 12 + (1 - value / maximum) * 180 }))
})
const pendingItems = computed(() => Array.isArray(stats.value.pendingItems)
  ? stats.value.pendingItems
    .filter((item: any) => String(item?.label ?? '').trim() !== '就医申请')
    .map((item: any) => ({ ...item, path: pendingRoutes[String(item?.label ?? '').trim()] ?? '/dashboard' }))
  : [])
const hotMerchants = computed<HotMerchant[]>(() => Array.isArray(stats.value.hotMerchants) ? stats.value.hotMerchants.slice(0, 7) : [])
const categoryShares = computed(() => Array.isArray(stats.value.orderCategoryShares) ? stats.value.orderCategoryShares : [])
const canToggleMerchants = computed(() => allHotMerchants.value.length > 7)
function sparkValues(values: unknown) {
  return Array.isArray(values) && values.length ? values : [0, 0, 0, 0, 0, 0, 0, 0, 0]
}
function normalizedSpark(values: unknown[]) {
  const numbers = values.map(value => Number(value) || 0)
  const minimum = Math.min(...numbers)
  const maximum = Math.max(...numbers)
  if (maximum === minimum) return numbers.map(() => 0.5)
  return numbers.map(value => (value - minimum) / (maximum - minimum))
}
function sparkCoordinates(values: unknown[]) {
  const normalized = normalizedSpark(values)
  const step = normalized.length > 1 ? 96 / (normalized.length - 1) : 0
  return normalized.map((value, index) => ({ x: 2 + index * step, y: 43 - value * 36 }))
}
function sparkPath(values: unknown[]) {
  const points = sparkCoordinates(values)
  if (!points.length) return ''
  let path = `M ${points[0].x} ${points[0].y}`
  for (let index = 1; index < points.length; index += 1) {
    const previous = points[index - 1]
    const current = points[index]
    const controlX = (previous.x + current.x) / 2
    path += ` C ${controlX} ${previous.y}, ${controlX} ${current.y}, ${current.x} ${current.y}`
  }
  return path
}
function sparkAreaPath(values: unknown[]) {
  const points = sparkCoordinates(values)
  if (!points.length) return ''
  return `${sparkPath(values)} L ${points[points.length - 1].x} 48 L ${points[0].x} 48 Z`
}
function sparkBarHeight(values: unknown[], index: number) {
  return `${16 + normalizedSpark(values)[index] * 78}%`
}
function formatNumber(value: unknown) {
@@ -76,6 +136,15 @@
function formatAxis(value: number) {
  if (value >= 1000) return `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 1)}k`
  return String(Math.round(value))
}
function axisStep(maximum: number) {
  if (maximum <= 5) return 1
  const rawStep = maximum / 5
  const magnitude = 10 ** Math.floor(Math.log10(rawStep))
  const normalized = rawStep / magnitude
  const niceStep = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10
  return niceStep * magnitude
}
function compareTrend(value: unknown) {
@@ -90,7 +159,9 @@
}
function barHeight(value: unknown) {
  return `${Math.max(4, (Number(value || 0) / maxBarValue.value) * 168)}px`
  const number = Number(value || 0)
  if (number <= 0) return '0px'
  return `${Math.max(4, (number / orderAxisMax.value) * 180)}px`
}
function donutStyle() {
@@ -126,8 +197,6 @@
<template>
  <div v-loading="loading" class="dashboard-workbench">
    <div class="dashboard-breadcrumb">首页 / 工作台</div>
    <section class="dashboard-panel subsidy-panel">
      <div class="dashboard-panel-title">补贴信息</div>
      <div class="subsidy-cards">
@@ -137,8 +206,14 @@
          <div class="subsidy-compare" :class="`trend-${compareTrend(card.compare)}`">
            {{ card.unit }} <span>{{ formatNumber(card.compare) }}</span><b>{{ trendIcon(card.compare) }}</b>
          </div>
          <div class="sparkline" :class="card.tone">
            <i v-for="(point, index) in card.spark" :key="index" :style="{ height: `${point}%` }" />
          <div class="summary-chart" :class="card.tone" aria-hidden="true">
            <svg v-if="card.chartType === 'line'" class="spark-line" viewBox="0 0 100 48" preserveAspectRatio="none">
              <path class="spark-area" :d="sparkAreaPath(card.spark)" />
              <path class="spark-stroke" :d="sparkPath(card.spark)" />
            </svg>
            <div v-else class="spark-bars">
              <i v-for="(_point, index) in card.spark" :key="index" :style="{ height: sparkBarHeight(card.spark, index) }" />
            </div>
          </div>
        </div>
      </div>
@@ -153,13 +228,12 @@
          </div>
        </div>
        <div class="bar-chart">
          <div class="y-axis"><span v-for="label in yAxisLabels" :key="label">{{ label }}</span></div>
          <div class="y-axis"><span v-for="tick in yAxisTicks" :key="tick.value" :style="{ top: `${tick.top}px` }">{{ formatAxis(tick.value) }}</span></div>
          <div class="bar-plot" :style="{ gridTemplateColumns: `repeat(${Math.max(verificationBars.length, 1)}, 1fr)` }">
            <div class="grid-line" v-for="line in 6" :key="line" />
            <div class="grid-line" v-for="tick in yAxisTicks" :key="tick.value" :style="{ top: `${tick.top}px` }" />
            <div v-for="item in verificationBars" :key="item.time" class="bar-item">
              <div class="bar-stack">
                <span class="bar-order" :style="{ height: barHeight(item.order) }" />
                <span class="bar-amount" :style="{ height: barHeight(item.amount) }" />
              </div>
              <em>{{ item.time }}</em>
            </div>
@@ -171,10 +245,10 @@
      <section class="dashboard-panel pending-panel">
        <div class="dashboard-panel-title">待处理</div>
        <div class="pending-grid">
          <div v-for="item in pendingItems" :key="item.label" class="pending-card">
          <button v-for="item in pendingItems" :key="item.label" type="button" class="pending-card" @click="router.push(item.path)">
            <span>{{ item.label }}</span>
            <strong>{{ item.value }}</strong>
          </div>
          </button>
        </div>
      </section>
    </div>
@@ -183,9 +257,6 @@
      <section class="dashboard-panel merchant-panel">
        <div class="panel-heading">
          <h3>热销商家</h3>
          <button class="link-button" :disabled="!canToggleMerchants" @click="merchantExpanded = !merchantExpanded">
            {{ merchantExpanded ? '收起' : '查看更多' }}
          </button>
        </div>
        <el-table :data="hotMerchants" class="hot-table" stripe>
          <el-table-column prop="rank" label="排名" width="80" align="center" />
@@ -214,7 +285,6 @@
<style scoped>
.dashboard-workbench { min-height: calc(100vh - 90px); padding: 14px 18px 28px; background: #f4f6fa; color: #1f2d3d; }
.dashboard-breadcrumb { margin: 0 0 14px; color: #9aa4b2; font-size: 13px; }
.dashboard-panel { background: #fff; border-radius: 2px; box-shadow: 0 1px 3px rgb(20 35 60 / 4%); }
.subsidy-panel { padding: 20px 24px 24px; margin-bottom: 20px; }
.dashboard-panel-title, .panel-heading h3 { margin: 0; font-size: 15px; font-weight: 700; color: #111827; }
@@ -230,34 +300,38 @@
.subsidy-compare.trend-down { color: #f04444; }
.subsidy-compare.trend-flat { color: #8d98a7; }
.subsidy-compare span { color: #ff3b30; }
.sparkline { position: absolute; right: 14px; bottom: 16px; display: flex; align-items: end; width: 80px; height: 44px; gap: 4px; }
.sparkline i { flex: 1; min-height: 5px; border-radius: 999px 999px 0 0; background: #1677ff; }
.sparkline.green i { background: #59d45f; }
.summary-chart { position: absolute; right: 14px; bottom: 14px; width: 104px; height: 50px; pointer-events: none; }
.spark-line { display: block; width: 100%; height: 100%; overflow: visible; }
.spark-area { fill: rgb(22 119 255 / 9%); stroke: none; }
.spark-stroke { fill: none; stroke: #1677ff; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.spark-bars { display: flex; width: 100%; height: 100%; gap: 5px; align-items: end; }
.spark-bars i { flex: 1; min-height: 6px; border-radius: 2px 2px 0 0; background: #46c552; }
.dashboard-grid { display: grid; gap: 20px; }
.top-grid { grid-template-columns: minmax(0, 1fr) 250px; margin-bottom: 20px; }
.bottom-grid { grid-template-columns: minmax(0, 1fr) 250px; }
.chart-panel, .pending-panel, .merchant-panel, .donut-panel { padding: 20px; }
.panel-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.period-tabs { display: inline-flex; overflow: hidden; border: 1px solid #dbe4f0; border-radius: 999px; }
.period-tabs button, .link-button { border: 0; background: transparent; color: #246bfe; cursor: pointer; font-size: 12px; }
.period-tabs button { border: 0; background: transparent; color: #246bfe; cursor: pointer; font-size: 12px; }
.period-tabs button { padding: 4px 11px; color: #738198; }
.period-tabs button.active { background: #246bfe; color: #fff; }
.link-button:disabled { color: #a7b2c3; cursor: not-allowed; }
.bar-chart { display: grid; grid-template-columns: 34px minmax(0, 1fr); height: 230px; }
.y-axis { display: flex; flex-direction: column; justify-content: space-between; padding: 13px 0 26px; color: #8da0b8; font-size: 12px; }
.y-axis { position: relative; height: 230px; color: #8da0b8; font-size: 12px; }
.y-axis span { position: absolute; right: 6px; line-height: 16px; transform: translateY(-50%); }
.bar-plot { position: relative; display: grid; align-items: end; gap: 18px; padding: 12px 8px 0; }
.grid-line { position: absolute; left: 0; right: 0; height: 1px; background: #edf1f7; }
.grid-line:nth-child(1) { top: 12px; } .grid-line:nth-child(2) { top: 48px; } .grid-line:nth-child(3) { top: 84px; } .grid-line:nth-child(4) { top: 120px; } .grid-line:nth-child(5) { top: 156px; } .grid-line:nth-child(6) { top: 192px; }
.bar-item { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: 24px; }
.bar-stack { display: flex; flex-direction: column-reverse; justify-content: flex-start; width: 13px; height: 180px; }
.bar-order { display: block; background: #2e73ff; }
.bar-amount { display: block; background: #76d9f5; }
.bar-item em { color: #7588a3; font-size: 12px; font-style: normal; }
.empty-chart { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #9aa4b2; font-size: 13px; }
.pending-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px 10px; margin-top: 18px; }
.pending-card { display: flex; min-height: 48px; flex-direction: column; align-items: center; justify-content: center; border-radius: 6px; background: #fafafa; }
.pending-card span { color: #596273; font-size: 12px; }
.pending-card strong { margin-top: 7px; color: #ff2d2d; font-size: 13px; font-weight: 500; }
.pending-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 12px; margin-top: 20px; }
.pending-card { display: flex; min-height: 70px; box-sizing: border-box; padding: 10px 6px; border: 1px solid transparent; flex-direction: column; align-items: center; justify-content: center; border-radius: 6px; background: #fafafa; font-family: inherit; cursor: pointer; transition: border-color .16s ease, background-color .16s ease, transform .16s ease; }
.pending-card:hover { border-color: #c9dcff; background-color: #f3f7ff; transform: translateY(-1px); }
.pending-card:focus-visible { outline: 2px solid #409eff; outline-offset: 2px; }
.pending-card:active { transform: translateY(0); }
.pending-card span { color: #4f596a; font-size: 14px; line-height: 20px; text-align: center; }
.pending-card strong { margin-top: 8px; color: #ff2d2d; font-size: 17px; line-height: 20px; font-weight: 500; }
.hot-table { margin-top: 8px; }
.donut-wrap { display: flex; min-height: 190px; flex-direction: column; align-items: center; justify-content: center; }
.donut { position: relative; width: 116px; height: 116px; border-radius: 50%; }
talent-card-server/src/main/java/com/talentcard/server/business/portal/ShoppingMerchantPortalService.java
@@ -15,10 +15,16 @@
public class ShoppingMerchantPortalService {
    private final JdbcTemplate jdbc;
    private final PortalService portalService;
    private final TalentCardPortalService talentCardPortalService;
    public ShoppingMerchantPortalService(JdbcTemplate jdbc, PortalService portalService) {
    public ShoppingMerchantPortalService(
            JdbcTemplate jdbc,
            PortalService portalService,
            TalentCardPortalService talentCardPortalService
    ) {
        this.jdbc = jdbc;
        this.portalService = portalService;
        this.talentCardPortalService = talentCardPortalService;
    }
    @Transactional(readOnly = true)
@@ -35,7 +41,7 @@
                           when 'housing' then 'housing' when 'car' then 'car'
                           when 'appliance' then 'appliance' when 'digital' then 'digital'
                           when 'culture' then 'book' when 'telecom' then 'telecom' end as category_key,
                       detail_config.talent_levels, detail_config.benefit_detail, detail_config.advisor_id,
                       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
@@ -56,7 +62,7 @@
        result.put("merchantName", row.get("merchantName"));
        result.put("coverUrl", row.get("coverUrl"));
        result.put("discountText", text(row.get("serviceLabel"), ""));
        result.put("applicableCards", text(configuredMerchant.get("rightsTag"), text(row.get("talentLevels"), "")));
        result.put("applicableCards", text(configuredMerchant.get("applicableCards"), text(configuredMerchant.get("rightsTag"), "")));
        result.put("address", text(row.get("address"), ""));
        result.put("contactMobile", text(row.get("contactMobile"), ""));
        result.put("longitude", row.get("longitude"));
@@ -70,17 +76,21 @@
            default -> text(row.get("unavailableReason"), "该购物服务当前不可用");
        };
        result.put("unavailableReason", available ? "" : unavailableReason);
        Map<String, Object> advisor = new LinkedHashMap<>();
        advisor.put("advisorId", row.get("advisorId"));
        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"), text(row.get("contactMobile"), "")));
        advisor.put("serviceTime", text(row.get("advisorServiceTime"), ""));
        advisor.put("available", (row.get("advisorAvailable") == null || number(row.get("advisorAvailable")) == 1) && !text(row.get("advisorMobile"), text(row.get("contactMobile"), "")).isEmpty());
        Map<String, Object> advisor = accountAdvisor(talentId);
        result.put("advisor", advisor);
        return result;
    }
    private Map<String, Object> accountAdvisor(Long talentId) {
        if (talentId == null) return Map.of("available", false);
        Object source = talentCardPortalService.profile(talentId).get("advisor");
        if (!(source instanceof Map<?, ?> sourceAdvisor)) return Map.of("available", false);
        Map<String, Object> advisor = new LinkedHashMap<>();
        sourceAdvisor.forEach((key, value) -> advisor.put(String.valueOf(key), value));
        advisor.put("available", !text(advisor.get("mobile"), "").isEmpty());
        return advisor;
    }
    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/mapper/AdminWorkbenchStatisticsMapper.java
@@ -37,10 +37,14 @@
              coalesce((select count(*) from talent_user u
                         where u.deleted = 0
                           and date(u.created_at) = date_sub(current_date, interval 1 day)), 0) as talentCompare,
              coalesce((select count(*) from verify_record v
                         where v.status = 1
                           and v.verify_time >= date_sub(current_date, interval 1 day)
                           and v.verify_time < current_date), 0) as monthlyVerificationCompare,
              (coalesce((select count(*) from verify_record current_month_verify
                          where current_month_verify.status = 1
                            and current_month_verify.verify_time >= date_format(current_date, '%Y-%m-01')
                            and current_month_verify.verify_time < date_add(last_day(current_date), interval 1 day)), 0)
               - coalesce((select count(*) from verify_record previous_month_verify
                            where previous_month_verify.status = 1
                              and previous_month_verify.verify_time >= date_sub(date_format(current_date, '%Y-%m-01'), interval 1 month)
                              and previous_month_verify.verify_time < date_format(current_date, '%Y-%m-01')), 0)) as monthlyVerificationCompare,
              coalesce(sum(case
                when coalesce(o.subsidy_amount, 0) > 0
                     and (o.product_id is null or coalesce(p.is_government_subsidy, 0) = 1)
@@ -61,6 +65,46 @@
    Map<String, Object> selectCompareSummary();
    @Select("""
            select date_format(days.bucket_day, '%c/%e') as time,
                   (select count(*)
                      from talent_user u
                     where u.deleted = 0
                       and u.created_at < date_add(days.bucket_day, interval 1 day)) as talent,
                   (select count(*)
                      from verify_record v
                     where v.status = 1
                       and v.verify_time >= days.bucket_day
                       and v.verify_time < date_add(days.bucket_day, interval 1 day)) as verification,
                   round(coalesce((select sum(case
                       when coalesce(o.subsidy_amount, 0) > 0
                            and (o.product_id is null or coalesce(p.is_government_subsidy, 0) = 1)
                         then o.subsidy_amount else 0 end)
                      from benefit_order o
                      left join merchant_product p on p.id = o.product_id and p.deleted = 0
                     where o.deleted = 0
                       and o.order_status = 1
                       and o.verify_status = 1
                       and exists (
                         select 1
                           from verify_record subsidy_verify
                          where subsidy_verify.order_id = o.id
                            and subsidy_verify.status = 1
                            and subsidy_verify.verify_time < date_add(days.bucket_day, interval 1 day)
                       )), 0) / 100, 2) as subsidy
              from (
                select date_sub(current_date, interval 6 day) as bucket_day union all
                select date_sub(current_date, interval 5 day) union all
                select date_sub(current_date, interval 4 day) union all
                select date_sub(current_date, interval 3 day) union all
                select date_sub(current_date, interval 2 day) union all
                select date_sub(current_date, interval 1 day) union all
                select current_date
              ) days
             order by days.bucket_day
            """)
    List<Map<String, Object>> selectSummaryTrend();
    @Select("""
            select buckets.bucket_time as time,
                   coalesce(count(v.id), 0) as `order`,
                   round(coalesce(sum(v.verify_amount), 0) / 100, 2) as amount
talent-card-server/src/main/java/com/talentcard/server/service/AdminWorkbenchStatisticsService.java
@@ -44,6 +44,10 @@
        result.put("talentCompare", number(compareRow.get("talentCompare")));
        result.put("monthlyVerificationCompare", number(compareRow.get("monthlyVerificationCompare")));
        result.put("subsidyCompare", money(number(compareRow.get("subsidyCompareCents"))));
        List<Map<String, Object>> summaryTrend = mapper.selectSummaryTrend();
        result.put("talentSpark", trendValues(summaryTrend, "talent"));
        result.put("verificationSpark", trendValues(summaryTrend, "verification"));
        result.put("subsidySpark", trendValues(summaryTrend, "subsidy"));
        result.put("budgetConfigured", false);
        result.put("budgetUsageRate", BigDecimal.ZERO.setScale(2));
        result.put("budgetUsageRateText", "0.00%");
@@ -95,6 +99,25 @@
        alerts.add(alert);
    }
    private List<BigDecimal> trendValues(List<Map<String, Object>> rows, String key) {
        if (rows == null) {
            return List.of();
        }
        return rows.stream()
                .map(row -> decimal(row.get(key)))
                .toList();
    }
    private BigDecimal decimal(Object value) {
        if (value instanceof BigDecimal decimal) {
            return decimal;
        }
        if (value instanceof Number number) {
            return BigDecimal.valueOf(number.doubleValue());
        }
        return BigDecimal.ZERO;
    }
    private long number(Object value) {
        return value instanceof Number n ? n.longValue() : 0L;
    }
talent-card-server/src/test/java/com/talentcard/server/business/portal/CinemaPortalServiceTests.java
New file
@@ -0,0 +1,67 @@
package com.talentcard.server.business.portal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import com.talentcard.server.service.TicketPackagePortalService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
@ExtendWith(MockitoExtension.class)
class CinemaPortalServiceTests {
    @Mock
    private JdbcTemplate jdbc;
    @Mock
    private TicketPackagePortalService ticketPackageService;
    @InjectMocks
    private CinemaPortalService service;
    @Test
    void usesConfiguredRightsDescriptionWhenCinemaHasTicketPackage() {
        when(jdbc.queryForList(anyString(), any(Object[].class))).thenAnswer(invocation -> {
            String sql = invocation.getArgument(0);
            if (sql.contains("from merchant where")) {
                return List.of(Map.ofEntries(
                        Map.entry("id", 87L),
                        Map.entry("merchant_name", "电影院1"),
                        Map.entry("merchant_type", "cinema"),
                        Map.entry("status", 1),
                        Map.entry("merchant_rights_config", "[{\"level\":\"卓越人才卡\",\"tag\":\"人才专享\",\"detail\":\"后台配置的权益详情描述\",\"enabled\":true}]")));
            }
            if (sql.contains("from talent_card c join talent_user")) {
                return List.of(Map.of(
                        "card_id", 15L,
                        "card_level", "卓越人才卡",
                        "card_type", "PERSONAL"));
            }
            if (sql.contains("from ticket_package p")) {
                return List.of(Map.ofEntries(
                        Map.entry("id", 23L),
                        Map.entry("package_name", "电影票包"),
                        Map.entry("claim_price", BigDecimal.ZERO),
                        Map.entry("talent_levels", "卓越人才卡"),
                        Map.entry("usage_text", "电影票包使用说明"),
                        Map.entry("merchant_names", "电影院1")));
            }
            return List.of();
        });
        Map<String, Object> detail = service.detail(87L, 5L, 15L);
        assertThat(detail)
                .containsEntry("rightsDescription", "后台配置的权益详情描述")
                .containsEntry("usage", "电影票包使用说明");
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/portal/ShoppingMerchantPortalServiceTests.java
@@ -23,6 +23,9 @@
    @Mock
    private PortalService portalService;
    @Mock
    private TalentCardPortalService talentCardPortalService;
    @InjectMocks
    private ShoppingMerchantPortalService service;
@@ -36,21 +39,51 @@
                Map.entry("merchant_status", 1),
                Map.entry("category_key", "digital"),
                Map.entry("service_label", ""),
                Map.entry("talent_levels", "请咨询服务专员"),
                Map.entry("address", "成都市天府新谷"),
                Map.entry("contact_mobile", "13540211247"),
                Map.entry("rights_text", "旧权益说明"))));
        when(portalService.merchantDetail(merchantId, talentId)).thenReturn(Map.of(
                "rightsStatus", "AVAILABLE",
                "rightsTag", "卓越人才卡",
                "rightsTag", "后台配置的购车权益标签",
                "applicableCards", "后台配置的购车权益标签",
                "rightsText", "后台配置的数码服务权益详情"));
        when(talentCardPortalService.profile(talentId)).thenReturn(Map.of(
                "advisor", Map.of(
                        "name", "账号服务专员",
                        "mobile", "13911112222")));
        Map<String, Object> detail = service.detail(merchantId, talentId);
        assertThat(detail)
                .containsEntry("applicableCards", "卓越人才卡")
                .containsEntry("applicableCards", "后台配置的购车权益标签")
                .containsEntry("benefitDetail", "后台配置的数码服务权益详情")
                .containsEntry("discountText", "")
                .containsEntry("address", "成都市天府新谷")
                .containsEntry("available", true);
        assertThat(detail.get("advisor")).isInstanceOfSatisfying(Map.class, advisor -> assertThat(advisor)
                .containsEntry("name", "账号服务专员")
                .containsEntry("mobile", "13911112222")
                .containsEntry("available", true)
                .doesNotContainValue("13540211247"));
    }
    @Test
    void doesNotFallbackToMerchantPhoneWhenTalentHasNoAdvisor() {
        Long merchantId = 102L;
        Long talentId = 1015L;
        when(jdbc.queryForList(anyString(), eq(merchantId))).thenReturn(List.of(Map.ofEntries(
                Map.entry("id", merchantId),
                Map.entry("merchant_name", "购房优惠商家"),
                Map.entry("merchant_status", 1),
                Map.entry("category_key", "housing"),
                Map.entry("contact_mobile", "13540211247"))));
        when(portalService.merchantDetail(merchantId, talentId)).thenReturn(Map.of(
                "rightsStatus", "AVAILABLE"));
        when(talentCardPortalService.profile(talentId)).thenReturn(Map.of());
        Map<String, Object> detail = service.detail(merchantId, talentId);
        assertThat(detail.get("advisor")).isEqualTo(Map.of("available", false));
    }
}