From b0edd6c4bd7912e706ef3af5a221caf0d6c78fac Mon Sep 17 00:00:00 2001
From: shinnosuke <tantao@1hoper.com>
Date: 星期四, 27 八月 2026 09:52:44 +0800
Subject: [PATCH] fix: align miniapp services and admin dashboard

---
 talent-card-admin/src/views/DashboardView.vue                                                                  |  142 +++++++++++++++----
 talent-card-server/src/main/java/com/talentcard/server/mapper/AdminWorkbenchStatisticsMapper.java              |   52 ++++++
 talent-card-admin/src/types/admin.ts                                                                           |    3 
 talent-card-server/src/test/java/com/talentcard/server/business/portal/CinemaPortalServiceTests.java           |   67 +++++++++
 rencaika/pages/medical-booking/medical-booking.uvue                                                            |    4 
 talent-card-server/src/main/java/com/talentcard/server/service/AdminWorkbenchStatisticsService.java            |   25 +++
 talent-card-server/src/test/java/com/talentcard/server/business/portal/ShoppingMerchantPortalServiceTests.java |   37 +++++
 rencaika/pages/coupon-received/coupon-received.uvue                                                            |    4 
 talent-card-server/src/main/java/com/talentcard/server/business/portal/ShoppingMerchantPortalService.java      |   30 ++-
 rencaika/pages/cinema-merchant/cinema-merchant.uvue                                                            |   12 
 10 files changed, 315 insertions(+), 61 deletions(-)

diff --git a/rencaika/pages/cinema-merchant/cinema-merchant.uvue b/rencaika/pages/cinema-merchant/cinema-merchant.uvue
index d75615b..9d1efad 100644
--- a/rencaika/pages/cinema-merchant/cinema-merchant.uvue
+++ b/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>
diff --git a/rencaika/pages/coupon-received/coupon-received.uvue b/rencaika/pages/coupon-received/coupon-received.uvue
index 15592ed..59f014e 100644
--- a/rencaika/pages/coupon-received/coupon-received.uvue
+++ b/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>
diff --git a/rencaika/pages/medical-booking/medical-booking.uvue b/rencaika/pages/medical-booking/medical-booking.uvue
index bbd97ec..e17e64e 100644
--- a/rencaika/pages/medical-booking/medical-booking.uvue
+++ b/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('')
diff --git a/talent-card-admin/src/types/admin.ts b/talent-card-admin/src/types/admin.ts
index 05c98a4..d809e0c 100644
--- a/talent-card-admin/src/types/admin.ts
+++ b/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 }>
diff --git a/talent-card-admin/src/views/DashboardView.vue b/talent-card-admin/src/views/DashboardView.vue
index 008a607..7c20baa 100644
--- a/talent-card-admin/src/views/DashboardView.vue
+++ b/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%; }
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/ShoppingMerchantPortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/ShoppingMerchantPortalService.java
index 945c86c..805ac9a 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/ShoppingMerchantPortalService.java
+++ b/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, "购物服务详情不存在");
diff --git a/talent-card-server/src/main/java/com/talentcard/server/mapper/AdminWorkbenchStatisticsMapper.java b/talent-card-server/src/main/java/com/talentcard/server/mapper/AdminWorkbenchStatisticsMapper.java
index 1cb702f..6c960bb 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/mapper/AdminWorkbenchStatisticsMapper.java
+++ b/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
diff --git a/talent-card-server/src/main/java/com/talentcard/server/service/AdminWorkbenchStatisticsService.java b/talent-card-server/src/main/java/com/talentcard/server/service/AdminWorkbenchStatisticsService.java
index ddf2cb0..3c847f6 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/service/AdminWorkbenchStatisticsService.java
+++ b/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;
     }
@@ -102,4 +125,4 @@
     private BigDecimal money(long cents) {
         return BigDecimal.valueOf(cents, 2).setScale(2, RoundingMode.HALF_UP);
     }
-}
\ No newline at end of file
+}
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/portal/CinemaPortalServiceTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/portal/CinemaPortalServiceTests.java
new file mode 100644
index 0000000..a1ea969
--- /dev/null
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/portal/CinemaPortalServiceTests.java
@@ -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", "电影票包使用说明");
+	}
+}
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/portal/ShoppingMerchantPortalServiceTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/portal/ShoppingMerchantPortalServiceTests.java
index 049f11e..45c48b2 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/portal/ShoppingMerchantPortalServiceTests.java
+++ b/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));
 	}
 }

--
Gitblit v1.7.1