fix: align miniapp services and admin dashboard
| | |
| | | <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> |
| | | |
| | |
| | | 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) |
| | | |
| | |
| | | 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 }) |
| | | } |
| | |
| | | .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> |
| | |
| | | <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> |
| | |
| | | .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> |
| | |
| | | <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> |
| | |
| | | const userName = ref('') |
| | | const userMobile = ref('') |
| | | const userIdCard = ref('') |
| | | const hospitalName = ref('攀枝花市中心医院') |
| | | const hospitalName = ref('') |
| | | const hospitalId = ref('') |
| | | const packageId = ref('') |
| | | const talentId = ref('') |
| | |
| | | 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 }> |
| | |
| | | <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' |
| | |
| | | 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(() => [ |
| | | { |
| | |
| | | value: stats.value.cardholderTotal ?? stats.value.talentCount ?? 0, |
| | | compare: stats.value.talentCompare ?? 0, |
| | | tone: 'blue', |
| | | chartType: 'line', |
| | | spark: sparkValues(stats.value.talentSpark), |
| | | unit: '较昨天', |
| | | }, |
| | |
| | | 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) { |
| | |
| | | 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) { |
| | |
| | | } |
| | | |
| | | 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() { |
| | |
| | | |
| | | <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"> |
| | |
| | | <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> |
| | |
| | | </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> |
| | |
| | | <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> |
| | |
| | | <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" /> |
| | |
| | | |
| | | <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; } |
| | |
| | | .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%; } |
| | |
| | | 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) |
| | |
| | | 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 |
| | |
| | | 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")); |
| | |
| | | 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, "购物服务详情不存在"); |
| | |
| | | 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) |
| | |
| | | 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 |
| | |
| | | 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%"); |
| | |
| | | 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; |
| | | } |
| | |
| | | private BigDecimal money(long cents) { |
| | | return BigDecimal.valueOf(cents, 2).setScale(2, RoundingMode.HALF_UP); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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", "电影票包使用说明"); |
| | | } |
| | | } |
| | |
| | | @Mock |
| | | private PortalService portalService; |
| | | |
| | | @Mock |
| | | private TalentCardPortalService talentCardPortalService; |
| | | |
| | | @InjectMocks |
| | | private ShoppingMerchantPortalService service; |
| | | |
| | |
| | | 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)); |
| | | } |
| | | } |