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