shinnosuke
2026-08-12 f40c2939c6f784fc6721554794154a336d12bc3a
fix(ui): 移除返回图标旋转样式并优化商户页面功能

- 移除多个页面中的 back-icon transform: rotate(180deg) 样式
- 将开发环境API基础URL从 192.168.110.13 改为 127.0.0.1
- 为商户详情页添加轮播图支持多张封面图片
- 优化商户页面 tabs 显示逻辑,增加使用说明选项
- 修复营业时间页面可编辑时间段功能,支持修改已有时间段
- 优化申请条件页面条件文本展示格式
- 修复后端人才码验证逻辑,移除过期检查
- 更新商户仪表板数据显示逻辑,始终显示统计数据
- 优化商户信用代码输入验证,自动过滤非字母数字字符
1 文件已重命名
22个文件已添加
39个文件已修改
815 ■■■■■ 已修改文件
rencaika/config/api.uts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/card-application-conditions/card-application-conditions.uvue 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/card/card.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/food-benefits/food-benefits.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/merchant/merchant.uvue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/payment-confirm/payment-confirm.uvue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/pending-payment/pending-payment.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/policy-detail/policy-detail.uvue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/policy/policy.uvue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/product-detail/product-detail.uvue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/profile/profile.uvue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/benefits-assets/benefits-hero.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/card-assets/card-elite.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/card-assets/card-excellence.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/card-assets/card-outstanding.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/card-assets/talent-card-background.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/common/image-fallback.svg 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/entertainment-assets/entertainment-hero.webp 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/entertainment-assets/wanda-cinema.webp 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/food-assets/food-1.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/food-assets/food-2.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/home-assets/city-golden.webp 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/home-assets/city-industry.webp 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/home-assets/city-intro.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/home-assets/city-livable.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/home-assets/city-potential.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/home-assets/home-banner-v2.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/home-assets/top.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/news-assets/meeting-thumb.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/policy-assets/policy-explanation.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/policy-assets/policy-hero.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/stay-assets/hotel-hampton.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/stay-assets/hotel-vienna.jpg 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/agreement-detail/agreement-detail.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/agreement/agreement.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/bank-branch-select/bank-branch-select.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/bank-card-add/bank-card-add.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/benefits/benefits.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/business-hours/business-hours.uvue 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/consume-input/consume-input.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/index/index.uvue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/merchant-onboarding-pending/merchant-onboarding-pending.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/order-detail/order-detail.uvue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/orders/orders.uvue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/product-detail/product-detail.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/products/products.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/scan/scan.uvue 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/settings/settings.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/verification-confirm/verification-confirm.uvue 164 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/verification-detail/verification-detail.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/verification-success/verification-success.uvue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/withdrawal-detail/withdrawal-detail.uvue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/withdrawal-success/withdrawal-success.uvue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/orders.uts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/ManualConsumptionService.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/ProductPriceCalculator.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/TalentCardPortalService.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V108__sync_admin_protocol_settings_to_agreements.sql 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/TalentCardServerApplicationTests.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/config/api.uts
@@ -5,7 +5,8 @@
export const developmentLoginBypassEnabled: boolean = false 
// 后台提供合法域名后仅在此处填写,业务页面不得硬编码域名。
const developmentBaseUrl = 'http://192.168.110.13:8080'
// 微信开发者工具运行在本机,使用回环地址可避免公共网络防火墙拦截局域网转发请求。
const developmentBaseUrl = 'http://127.0.0.1:8080'
const stagingBaseUrl = ''
const productionBaseUrl = ''
rencaika/pages/card-application-conditions/card-application-conditions.uvue
@@ -4,7 +4,12 @@
        <scroll-view class="content" scroll-y="true" show-scrollbar="false">
            <view class="hero"><text class="hero-title">{{cardType}}</text><text class="hero-subtitle">东区人才卡申请条件</text></view>
            <view class="summary-card"><text class="summary-title">发放对象</text><text class="summary-text">{{recipient}}</text></view>
            <view class="conditions-card"><text class="conditions-title">具体申请条件</text><text class="conditions-text">{{conditions}}</text></view>
            <view class="conditions-card">
                <text class="conditions-title">具体申请条件</text>
                <view class="conditions-content">
                    <text v-for="(line, index) in conditionLines" :key="index" :class="conditionLineClass(line)">{{line}}</text>
                </view>
            </view>
            <view class="content-bottom"></view>
        </scroll-view>
    </view>
@@ -20,6 +25,7 @@
    const cardType = ref('东区人才卡')
    const recipient = ref('')
    const conditions = ref('')
    const conditionLines = ref([] as string[])
    const excellentConditions = `一类人才(国内外顶尖人才)
1. 国家最高科学技术奖获得者。
@@ -91,7 +97,18 @@
        // #endif
        recipient.value = '经区人才服务专窗认定的高层次人才,根据人才层次发放卓越人才卡、杰出人才卡或菁英人才卡。'
        conditions.value = '卓越人才卡\n发放对象:经区人才服务专窗认定的一至三类高层次人才。\n\n' + excellentConditions + '\n\n杰出人才卡\n发放对象:经区人才服务专窗认定的四至五类高层次人才。\n\n' + outstandingConditions + '\n\n菁英人才卡\n发放对象:经区人才服务专窗认定的六至七类高层次人才。\n\n' + eliteConditions
        conditionLines.value = conditions.value
            .replace(/(([0-9]+))/g, '\n($1)')
            .split('\n')
            .map((line: string): string => line.trim())
            .filter((line: string): boolean => line != '')
    })
    const conditionLineClass = (line: string): string => {
        if (line == '卓越人才卡' || line == '杰出人才卡' || line == '菁英人才卡') return 'condition-line card-heading'
        if (line.indexOf('发放对象:') == 0) return 'condition-line recipient-heading'
        if (/^[一二三四五六七]类人才/.test(line)) return 'condition-line category-heading'
        return 'condition-line'
    }
    const goBack = () => safeBack('/pages/card/card')
</script>
@@ -103,5 +120,11 @@
    .nav-title { position: absolute; left: 50%; top: 50%; color: #222326; font-size: 36rpx; font-weight: 700; transform: translate(-50%, -50%); white-space: nowrap; } .capsule-space { width: 88px; height: 100%; flex-shrink: 0; }
    .content { min-height: 0; flex: 1; } .hero { padding: 58rpx 58rpx 42rpx; background-image: linear-gradient(135deg, #F26B38 0%, #FA9534 100%); } .hero-title { display: block; color: #FFFFFF; font-size: 43rpx; font-weight: 700; } .hero-subtitle { display: block; margin-top: 15rpx; color: rgba(255,255,255,.87); font-size: 26rpx; }
    .summary-card, .conditions-card { margin: 30rpx 30rpx 0; padding: 34rpx; border-radius: 22rpx; background-color: #FFFFFF; box-shadow: 0 9rpx 28rpx rgba(116,76,46,.08); } .summary-title, .conditions-title { display: block; color: #24262A; font-size: 31rpx; font-weight: 700; } .summary-text { display: block; margin-top: 20rpx; color: #6F7782; font-size: 28rpx; line-height: 46rpx; }
    .conditions-text { display: block; margin-top: 24rpx; color: #535B66; font-size: 27rpx; line-height: 48rpx; white-space: pre-wrap; } .content-bottom { height: 52rpx; height: calc(52rpx + env(safe-area-inset-bottom)); }
    .conditions-content { margin-top: 24rpx; }
    .condition-line { display: block; color: #535B66; font-size: 27rpx; line-height: 48rpx; }
    .card-heading { margin-top: 34rpx; color: #E84B35; font-size: 32rpx; font-weight: 700; }
    .conditions-content .card-heading:first-child { margin-top: 0; }
    .recipient-heading { margin: 15rpx 0 22rpx; color: #34383E; font-weight: 700; text-align: center; }
    .category-heading { margin-top: 19rpx; color: #34383E; font-weight: 700; }
    .content-bottom { height: 52rpx; height: calc(52rpx + env(safe-area-inset-bottom)); }
</style>
rencaika/pages/card/card.uvue
@@ -294,7 +294,7 @@
    .dialog-title { display: block; color: #386BB0; font-size: 43rpx; line-height: 60rpx; font-weight: 700; text-align: center; }
    .dialog-copy { display: block; margin-top: 44rpx; color: #737F91; font-size: 27rpx; line-height: 42rpx; font-weight: 600; }
    .dialog-phone { display: block; margin-top: 16rpx; color: #FF8957; font-size: 27rpx; line-height: 39rpx; font-weight: 700; }
    .dialog-confirm { display: flex; height: 80rpx; margin: 39rpx 69rpx 0; border-radius: 41rpx; background-image: linear-gradient(90deg, #FF4A32 0%, #FF8A1B 100%); justify-content: center; align-items: center; }
    .dialog-confirm { display: flex; width: 240rpx; height: 68rpx; margin: 39rpx auto 0; border-radius: 35rpx; background-image: linear-gradient(90deg, #FF4A32 0%, #FF8A1B 100%); justify-content: center; align-items: center; }
    .dialog-confirm text { color: #FFFFFF; font-size: 28rpx; font-weight: 700; }
    .dialog-confirm-active { opacity: .78; }
    @keyframes card-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
rencaika/pages/food-benefits/food-benefits.uvue
@@ -194,7 +194,7 @@
                    id: '' + item.id,
                    name: item.merchantName != null ? item.merchantName as string : '餐饮商户',
                    merchantType: item.merchantType != null ? item.merchantType as string : 'food',
                    discount: item.discountText != null ? item.discountText as string : '',
                    discount: item.discountText != null && item.discountText != '人才专享' ? item.discountText as string : '',
                    hasCoupon: item.couponAvailable === true || Number(item.couponAvailable) == 1,
                    address: item.address != null ? item.address as string : '',
                    image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackFoodImage,
rencaika/pages/merchant/merchant.uvue
@@ -12,7 +12,9 @@
        <scroll-view :class="'content' + (isMedicalMerchant ? ' content-full' : '')" scroll-y @scroll="handleScroll">
            <view class="hero">
                <image class="hero-image" :src="merchantHero" mode="aspectFill" @error="replaceHeroImage"></image>
                <swiper class="hero-swiper" :indicator-dots="displayedHeroImages.length > 1" indicator-color="rgba(255,255,255,.55)" indicator-active-color="#FFFFFF" circular="true">
                    <swiper-item v-for="(imageUrl, index) in displayedHeroImages" :key="index"><image class="hero-image" :src="imageUrl" mode="aspectFill" @error="replaceHeroImage(index)"></image></swiper-item>
                </swiper>
                <view class="hero-shade"></view>
            </view>
@@ -42,7 +44,7 @@
                </view>
                <view v-if="!merchantError" class="tabs">
                    <view class="tab-item" :class="currentTab == 'primary' ? 'active' : ''" @tap="changeTab('primary')"><text>{{isMedicalMerchant ? '预约' : (groupBuyingEnabled ? '消费购买' : '使用说明')}}</text><view class="tab-line"></view></view>
                    <view class="tab-item" :class="currentTab == 'primary' ? 'active' : ''" @tap="changeTab('primary')"><text>{{isMedicalMerchant ? '预约' : (showUsageInstructions ? '使用说明' : '消费购买')}}</text><view class="tab-line"></view></view>
                    <view class="tab-item" :class="currentTab == 'rights' ? 'active' : ''" @tap="changeTab('rights')"><text>{{isParkingMerchant ? '停车场地址' : (isTransportMerchant ? '适用门店' : '权益详情')}}</text><view class="tab-line"></view></view>
                </view>
@@ -62,6 +64,7 @@
                            </view>
                        </view>
                    </view>
                    <view v-else-if="showUsageInstructions" class="usage-panel"><text>{{usageInstructions}}</text></view>
                    <view v-else-if="groupBuyingEnabled" class="package-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="loadMerchantLists">重新加载</text></view>
@@ -157,8 +160,13 @@
    const merchantType = ref('food')
    const merchantName = ref('百杏厨房(中环天地店)')
    const merchantHero = ref('')
    const merchantHeroImages = ref<string[]>([])
    const displayedHeroImages = computed((): string[] => merchantHeroImages.value.length > 0 ? merchantHeroImages.value : [merchantHero.value])
    const fallbackImage = ''
    const replaceHeroImage = () => { merchantHero.value = '' }
    const replaceHeroImage = (index: number) => {
        if (merchantHeroImages.value.length == 0) { merchantHero.value = ''; return }
        if (index >= 0 && index < merchantHeroImages.value.length) merchantHeroImages.value[index] = ''
    }
    const replaceImage = (item: any) => { if (item.image != '') item.image = '' }
    // 非团购服务仍沿用各自的静态标签;餐饮团购标签由接口的券与最低折扣决定。
    const merchantPrimaryTag = ref('')
@@ -227,6 +235,8 @@
    const listLoading = ref(true)
    const listLoaded = ref(false)
    const listError = ref(false)
    // 餐饮商家没有上架商品时,改为展示当前人才卡等级可用的到店消费说明。
    const showUsageInstructions = computed(() => !isMedicalMerchant.value && (!groupBuyingEnabled.value || (merchantType.value == 'food' && listLoaded.value && visiblePackages.value.length == 0)))
    const failOnce = ref(false)
    const forceEmpty = ref(false)
    let parkingDetailLoaded = false
@@ -476,14 +486,24 @@
        getMerchantDetail(merchantId.value, talentId.value, (data: any) => {
            merchantName.value = data != null && data.merchantName != null ? data.merchantName as string : merchantName.value
            merchantHero.value = data != null && data.coverUrl != null && data.coverUrl != '' ? resolveApiUrl(data.coverUrl as string) : merchantHero.value
            const heroRecords: any[] = data != null && data.heroImages instanceof Array ? data.heroImages as any[] : []
            const nextHeroes: string[] = []
            for (let index = 0; index < heroRecords.length; index++) {
                const value = '' + heroRecords[index]
                if (value != '') nextHeroes.push(resolveApiUrl(value))
            }
            if (nextHeroes.length == 0) nextHeroes.push(merchantHero.value)
            merchantHeroImages.value = nextHeroes
            merchantAddress.value = data != null && data.address != null ? data.address as string : merchantAddress.value
            const rawType = data != null && data.merchantType != null ? data.merchantType as string : merchantType.value
            merchantType.value = rawType == '餐饮服务' ? 'food' : rawType
            merchantAvailable.value = data == null || data.available !== false
            merchantPhone.value = data != null && data.contactMobile != null ? data.contactMobile as string : ''
            merchantHasCoupon.value = data != null && (data.couponAvailable === true || Number(data.couponAvailable) == 1)
            merchantDiscount.value = data != null && data.discountText != null ? data.discountText as string : merchantDiscount.value
            const discountText = data != null && data.discountText != null ? data.discountText as string : ''
            merchantDiscount.value = discountText == '人才专享' ? '' : discountText
            merchantServiceLabel.value = data != null && data.serviceLabel != null && data.serviceLabel != '' ? data.serviceLabel as string : merchantServiceLabel.value
            applicableCards.value = data != null && data.applicableCards != null && data.applicableCards != '' ? data.applicableCards as string : applicableCards.value
            merchantRightsText.value = data != null && data.rightsText != null && data.rightsText != '' ? data.rightsText as string : merchantRightsText.value
            usageInstructions.value = data != null && data.usageInstructions != null && data.usageInstructions != '' ? data.usageInstructions as string : usageInstructions.value
            groupBuyingEnabled.value = data == null || data.groupBuyingEnabled == null ? groupBuyingEnabled.value : data.groupBuyingEnabled === true || Number(data.groupBuyingEnabled) == 1
@@ -710,6 +730,7 @@
    .page { width: 100%; height: 100vh; background-color: #F6F6F6; }
    .content { height: calc(100% - 150rpx); height: calc(100% - 150rpx - env(safe-area-inset-bottom)); background-color: #F6F6F6; }
    .hero { height: 445rpx; position: relative; overflow: hidden; background-color: #3B3028; }
    .hero-swiper { width: 100%; height: 445rpx; }
    .hero-image { position: absolute; left: 0; top: 0; width: 100%; height: 445rpx; }
    .hero-shade { position: absolute; left: 0; right: 0; top: 0; height: 190rpx; background-image: linear-gradient(180deg, rgba(0,0,0,.58) 0%, rgba(0,0,0,0) 100%); }
    .custom-nav { position: fixed; z-index: 20; left: 0; right: 0; top: 0; padding-bottom: 20rpx; }
rencaika/pages/payment-confirm/payment-confirm.uvue
@@ -30,7 +30,7 @@
                        <view class="coupon-value"><text>{{couponText}}</text><text class="chevron">›</text></view>
                    </view>
                    <view class="price-item"><text class="price-label">原价</text><text class="price-value">¥ {{originalAmountText}}</text></view>
                    <view class="price-item"><text class="price-label">总优惠金额</text><text class="price-value">-¥ {{discountAmountText}}</text></view>
                    <view class="price-item"><text class="price-label">优惠金额</text><text class="price-value">-¥ {{couponAmountText}}</text></view>
                    <view class="price-item"><text class="price-label">用户实付</text><text class="price-value strong">¥ {{actualPayAmountText}}</text></view>
                    <view class="price-item"><text class="price-label">补贴金额</text><text class="price-value">¥ {{subsidyAmountText}}</text></view>
                </view>
@@ -73,7 +73,7 @@
    const productDiscount = ref('6折')
    const salePriceText = computed(() => salePrice.value.toFixed(2))
    const originalAmountText = computed(() => originalAmount.value.toFixed(2))
    const discountAmountText = ref('0.00')
    const couponAmountText = computed(() => couponAmount.value.toFixed(2))
    const subsidyAmountText = ref('0.00')
    const actualPayAmountText = ref('0.00')
    const createOrderIdempotencyKey = ref('')
@@ -145,7 +145,6 @@
            couponAmount.value = toAmount(data.couponAmount)
            couponText.value = data.couponLabel != null ? data.couponLabel as string : '无'
            productDiscount.value = data.discountText != null ? data.discountText as string : productDiscount.value
            discountAmountText.value = toAmount(data.discountAmount).toFixed(2)
            subsidyAmountText.value = toAmount(data.subsidyAmount).toFixed(2)
            actualPayAmountText.value = toAmount(data.payAmount).toFixed(2)
        }, (error) => {
rencaika/pages/pending-payment/pending-payment.uvue
@@ -33,7 +33,7 @@
            <view class="amount-card">
                <text class="amount-title">金额明细</text>
                <view class="amount-row"><text>原价</text><text>¥ {{originalAmountText}}</text></view>
                <view class="amount-row"><text>总优惠金额</text><text>- ¥ {{discountAmountText}}</text></view>
                <view class="amount-row"><text>优惠金额</text><text>- ¥ {{discountAmountText}}</text></view>
                <view class="amount-row"><text>用户实付</text><text class="amount-strong">¥ {{actualPayAmountText}}</text></view>
                <view class="amount-row"><text>补贴金额</text><text>¥ {{subsidyAmountText}}</text></view>
            </view>
rencaika/pages/policy-detail/policy-detail.uvue
@@ -34,6 +34,12 @@
    const initialLoading = ref(true)
    const initialError = ref(false)
    const policyUnavailable = ref(false)
    const policyContentText = (data: any): string => {
        const extracted = data != null && data.contentText != null ? (data.contentText as string).trim() : ''
        if (extracted != '') return extracted
        const raw = data != null && data.content != null ? data.content as string : ''
        return raw.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim()
    }
    onLoad((options: any) => {
        if (options != null && options.id != null) policyId.value = decodeURIComponent(options.id as string)
@@ -55,8 +61,9 @@
        getPolicyDetail(policyId.value, (data: any) => {
            policyTitle.value = data != null && data.title != null ? data.title as string : '人才政策'
            policySummary.value = data != null && data.subtitle != null ? data.subtitle as string : ''
            policyCategory.value = data != null && data.categoryName != null ? data.categoryName as string : '东区政策'
            policyContent.value = data != null && data.contentText != null ? data.contentText as string : ''
            policyCategory.value = data != null && data.subtitle != null && (data.subtitle as string).trim() != '' ? data.subtitle as string : '人才政策'
            // 正文直接取后台政策内容;contentText 为空时兼容后台仅返回富文本 content 的情况。
            policyContent.value = policyContentText(data)
            policyCover.value = '/static/home-assets/home-banner-v2.jpg'
            const heroImage = data != null && data.heroImage != null ? data.heroImage : (data != null ? data.coverUrl : null)
            if (heroImage != null && (heroImage as string).length > 0) policyCover.value = resolveApiUrl(heroImage as string)
rencaika/pages/policy/policy.uvue
@@ -18,7 +18,7 @@
                <view v-else-if="hasLoaded" class="policy-list">
                    <view class="policy-card" v-for="item in visiblePolicies" :key="item.id" hover-class="policy-card-active" @tap="openPolicy(item)">
                        <text class="policy-title">{{item.title}}</text><text class="policy-desc">{{item.description}}</text>
                        <view class="policy-foot"><text class="policy-tag">东区政策</text><text class="policy-detail">查看详情</text></view>
                        <view class="policy-foot"><text class="policy-tag">{{item.tag}}</text><text class="policy-detail">查看详情</text></view>
                    </view>
                </view>
                <view v-if="loadingMore" class="loading-row"><view class="loading-spinner"></view><text>正在加载</text></view>
@@ -74,7 +74,8 @@
            mapped.push({
                id: '' + item.id,
                title: item.title != null ? item.title as string : '人才政策',
                description: item.subtitle != null ? item.subtitle as string : ''
                description: item.contentPreview != null ? item.contentPreview as string : '',
                tag: item.subtitle != null && (item.subtitle as string).trim() != '' ? item.subtitle as string : '人才政策'
            })
        }
        return mapped
rencaika/pages/product-detail/product-detail.uvue
@@ -20,7 +20,7 @@
            </view>
            <view class="product-summary">
                <view class="price-line"><text class="discount-label">折扣价</text><text class="price-symbol">¥</text><text class="price">{{productPrice}}</text><text class="old-price">¥{{productOriginalPrice}}</text><view class="summary-tags"><text class="coupon-tag">券</text><text class="discount-tag">{{productDiscount}}</text></view></view>
                <view class="price-line"><text class="discount-label">折扣价</text><text class="price-symbol">¥</text><text class="price">{{productPrice}}</text><text class="old-price">¥{{productOriginalPrice}}</text><view class="summary-tags"><text v-if="productHasCoupon" class="coupon-tag">券</text><text v-if="productDiscount != ''" class="discount-tag">{{productDiscount}}</text></view></view>
                <text class="product-name">{{productName}}</text>
                <text v-if="!productAvailable" class="product-status">商品已下架</text>
                <text v-else-if="productSoldOut" class="product-status">已售罄</text>
@@ -70,6 +70,7 @@
    const productPrice = ref('138')
    const productOriginalPrice = ref('266')
    const productDiscount = ref('6折')
    const productHasCoupon = ref(false)
    const productAvailable = ref(true)
    const productSoldOut = ref(false)
@@ -128,7 +129,8 @@
            const original = hasOriginal ? Number(data.originalPrice) : 0
            if (hasSale && !isNaN(sale)) productPrice.value = sale.toFixed(2).replace(/\.00$/, '')
            if (hasOriginal && !isNaN(original) && original > 0) productOriginalPrice.value = original.toFixed(2).replace(/\.00$/, '')
            if (data != null && data.discountText != null && data.discountText != '') productDiscount.value = data.discountText as string
            productHasCoupon.value = data != null && (data.couponAvailable === true || Number(data.couponAvailable) == 1)
            if (data != null && data.discountText != null) productDiscount.value = data.discountText as string
            else if (original > 0 && sale < original) productDiscount.value = '' + Math.round(sale / original * 100) / 10 + '折'
            productAvailable.value = data == null || data.available !== false
            productSoldOut.value = data != null && (data.soldOut === true || Number(data.stock) == 0)
rencaika/pages/profile/profile.uvue
@@ -77,7 +77,7 @@
    const hasTalentCard = ref(false)
    const familyAccessLoading = ref(false)
    const userName = ref('')
    const avatarText = ref('人')
    const avatarText = ref('普')
    const cardLevel = ref('')
    const cardNo = ref('')
    const applicationMessage = ref('请完成认证后开通人才卡。')
@@ -116,7 +116,11 @@
            profileLoading.value = false
            hasTalentCard.value = data != null && data.hasTalentCard === true && data.card != null
            applicationMessage.value = data != null && data.applicationMessage != null ? data.applicationMessage as string : '请完成认证后开通人才卡。'
            if (!hasTalentCard.value) { avatarText.value = '人'; userName.value = '普通游客'; return }
            if (!hasTalentCard.value) {
                userName.value = '普通游客'
                avatarText.value = userName.value.substring(0, 1)
                return
            }
            const card: any = data.card
            userName.value = card.name != null ? card.name as string : '人才用户'
            avatarText.value = userName.value.length > 0 ? userName.value.substring(0, 1) : '人'
rencaika/static/benefits-assets/benefits-hero.jpg
rencaika/static/card-assets/card-elite.jpg
rencaika/static/card-assets/card-excellence.jpg
rencaika/static/card-assets/card-outstanding.jpg
rencaika/static/card-assets/talent-card-background.jpg
rencaika/static/common/image-fallback.svg
New file
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200" viewBox="0 0 320 200">
  <rect width="320" height="200" fill="#eef1f5"/>
  <path d="M92 132l38-42 28 29 21-24 49 37H92z" fill="#c7d0dc"/>
  <circle cx="119" cy="69" r="15" fill="#c7d0dc"/>
  <text x="160" y="174" text-anchor="middle" fill="#8d98a8" font-family="sans-serif" font-size="14">图片加载失败</text>
</svg>
rencaika/static/entertainment-assets/entertainment-hero.webp
Binary files differ
rencaika/static/entertainment-assets/wanda-cinema.webp
Binary files differ
rencaika/static/food-assets/food-1.jpg
rencaika/static/food-assets/food-2.jpg
rencaika/static/home-assets/city-golden.webp
Binary files differ
rencaika/static/home-assets/city-industry.webp
Binary files differ
rencaika/static/home-assets/city-intro.jpg
rencaika/static/home-assets/city-livable.jpg
rencaika/static/home-assets/city-potential.jpg
rencaika/static/home-assets/home-banner-v2.jpg
rencaika/static/home-assets/top.jpg
rencaika/static/news-assets/meeting-thumb.jpg
rencaika/static/policy-assets/policy-explanation.jpg
rencaika/static/policy-assets/policy-hero.jpg
rencaika/static/stay-assets/hotel-hampton.jpg
rencaika/static/stay-assets/hotel-vienna.jpg
rencaika_merchant/pages/agreement-detail/agreement-detail.uvue
@@ -54,7 +54,7 @@
    .page { display: flex; flex: 1; flex-direction: column; width: 100%; height: 100vh; overflow: hidden; background-color: #F4F6F9; }
    .navigation-bar { display: flex; height: 174rpx; padding: 68rpx 38rpx 0; flex-direction: row; align-items: center; background-color: #F4F6F9; box-sizing: border-box; position: relative; flex: none; }
    .back-button { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; position: absolute; left: 38rpx; top: 89rpx; z-index: 2; }
    .back-icon { width: 38rpx; height: 38rpx; transform: rotate(180deg); }
    .back-icon { width: 38rpx; height: 38rpx; }
    .page-title { width: 100%; color: #202B3E; font-size: 34rpx; line-height: 48rpx; text-align: center; font-weight: 700; }
    .content-scroll { flex: 1; height: 0; padding: 0 34rpx; background-color: #F4F6F9; box-sizing: border-box; }
    .rich-card { padding: 36rpx 32rpx; border-radius: 32rpx; background-color: #FFFFFF; box-shadow: 0 14rpx 34rpx rgba(42, 53, 69, 0.05); box-sizing: border-box; }
rencaika_merchant/pages/agreement/agreement.uvue
@@ -36,7 +36,7 @@
    .page { display: flex; flex: 1; flex-direction: column; width: 100%; height: 100vh; overflow: hidden; background-color: #F4F6F9; }
    .navigation-bar { display: flex; height: 174rpx; padding: 68rpx 38rpx 0; flex-direction: row; align-items: center; background-color: #F4F6F9; box-sizing: border-box; position: relative; flex: none; }
    .back-button { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; position: absolute; left: 38rpx; top: 89rpx; z-index: 2; }
    .back-icon { width: 38rpx; height: 38rpx; transform: rotate(180deg); }
    .back-icon { width: 38rpx; height: 38rpx; }
    .page-title { width: 100%; color: #202B3E; font-size: 34rpx; line-height: 48rpx; text-align: center; font-weight: 700; }
    .content { flex: 1; padding: 0 34rpx; background-color: #F4F6F9; box-sizing: border-box; }
    .agreement-card { overflow: hidden; border-radius: 32rpx; background-color: #FFFFFF; box-shadow: 0 14rpx 34rpx rgba(42, 53, 69, 0.05); }
rencaika_merchant/pages/bank-branch-select/bank-branch-select.uvue
@@ -76,7 +76,7 @@
    .page { display: flex; flex: 1; width: 100%; height: 100vh; flex-direction: column; overflow: hidden; background-color: #F4F6F9; }
    .navigation-bar { display: flex; height: 174rpx; padding: 68rpx 38rpx 0; flex: none; flex-direction: row; align-items: center; position: relative; background-color: #F4F6F9; box-sizing: border-box; }
    .back-button { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; position: absolute; left: 38rpx; top: 89rpx; z-index: 2; }
    .back-icon { width: 38rpx; height: 38rpx; transform: rotate(180deg); }
    .back-icon { width: 38rpx; height: 38rpx; }
    .page-title { width: 100%; color: #202B3E; font-size: 34rpx; line-height: 48rpx; text-align: center; font-weight: 700; }
    .search-section { padding: 0 34rpx 24rpx; flex: none; box-sizing: border-box; }
    .search-box { display: flex; height: 88rpx; padding: 0 24rpx; flex-direction: row; align-items: center; border-width: 1rpx; border-style: solid; border-color: #E0E5EC; border-radius: 24rpx; background-color: #FFFFFF; box-sizing: border-box; }
rencaika_merchant/pages/bank-card-add/bank-card-add.uvue
@@ -201,7 +201,7 @@
    .page { display: flex; flex: 1; width: 100%; height: 100vh; flex-direction: column; overflow: hidden; background-color: #F4F6F9; }
    .navigation-bar { display: flex; height: 174rpx; padding: 68rpx 38rpx 0; flex: none; flex-direction: row; align-items: center; position: relative; background-color: #F4F6F9; box-sizing: border-box; }
    .back-button { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; position: absolute; left: 38rpx; top: 89rpx; z-index: 2; }
    .back-icon { width: 38rpx; height: 38rpx; transform: rotate(180deg); }
    .back-icon { width: 38rpx; height: 38rpx; }
    .page-title { width: 100%; color: #202B3E; font-size: 34rpx; line-height: 48rpx; text-align: center; font-weight: 700; }
    .form-scroll { flex: 1; height: 0; padding: 0 34rpx; box-sizing: border-box; }
    .form-card { padding: 8rpx 30rpx; border-radius: 32rpx; background-color: #FFFFFF; box-shadow: 0 14rpx 34rpx rgba(49, 55, 66, 0.05); box-sizing: border-box; }
rencaika_merchant/pages/benefits/benefits.uvue
@@ -80,7 +80,7 @@
    .hero-mask { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-image: linear-gradient(180deg, rgba(27, 25, 23, 0.22) 0%, rgba(27, 25, 23, 0.58) 100%); }
    .hero-nav { display: flex; height: 154rpx; padding: 82rpx 38rpx 0; position: relative; box-sizing: border-box; }
    .hero-back { display: flex; width: 58rpx; height: 58rpx; align-items: center; justify-content: center; }
    .hero-back-icon { width: 34rpx; height: 34rpx; transform: rotate(180deg); }
    .hero-back-icon { width: 34rpx; height: 34rpx; }
    .hero-title-wrap { display: flex; flex-direction: column; align-items: center; position: relative; margin-top: 50rpx; }
    .hero-title { color: #FFFFFF; font-size: 42rpx; line-height: 59rpx; font-weight: 700; }
    .hero-subtitle { color: rgba(255, 255, 255, 0.90); font-size: 25rpx; line-height: 35rpx; margin-top: 4rpx; }
rencaika_merchant/pages/business-hours/business-hours.uvue
@@ -20,7 +20,7 @@
                    <view class="hours-list">
                        <view v-for="(item, index) in businessHours" :key="index" class="hours-row">
                            <view class="minus-button" @tap="removeHour(index)"><text>−</text></view>
                            <text class="hours-text">{{item.start}}–{{item.end}}</text>
                            <text class="hours-text" @tap="showEditSheet(index)">{{item.start}}-{{item.end}}</text>
                            <view class="period-tag"><text>时段 {{index + 1}}</text></view>
                        </view>
                        <view class="add-hour" @tap="showAddSheet"><text class="add-plus">+</text><text>添加营业时间段</text></view>
@@ -36,7 +36,7 @@
            <view class="sheet-mask" @tap="closeAddSheet"></view>
            <view class="time-sheet">
                <view class="sheet-handle"></view>
                <view class="sheet-header"><text class="sheet-title">添加营业时间段</text><view class="close-sheet" @tap="closeAddSheet"><text>×</text></view></view>
                <view class="sheet-header"><text class="sheet-title">{{editingHourIndex >= 0 ? '编辑营业时间段' : '添加营业时间段'}}</text><view class="close-sheet" @tap="closeAddSheet"><text>×</text></view></view>
                <view class="time-fields">
                    <view class="time-field" :class="activeTimeField == 'start' ? 'active' : ''" @tap="selectTimeField('start')"><text class="time-label">开始时间</text><text class="time-value">{{startTime}}</text></view>
                    <view class="time-field" :class="activeTimeField == 'end' ? 'active' : ''" @tap="selectTimeField('end')"><text class="time-label">结束时间</text><text class="time-value">{{endTime}}</text></view>
@@ -45,7 +45,7 @@
                    <picker-view-column><view v-for="(hour, index) in hours" :key="hour" class="picker-item" :class="timePickerIndexes[0] == index ? 'selected' : ''">{{hour}} 时</view></picker-view-column>
                    <picker-view-column><view v-for="(minute, index) in minutes" :key="minute" class="picker-item" :class="timePickerIndexes[1] == index ? 'selected' : ''">{{minute}} 分</view></picker-view-column>
                </picker-view>
                <view class="sheet-actions"><view class="sheet-cancel" @tap="closeAddSheet"><text>取消</text></view><view class="sheet-confirm" @tap="confirmAddHour"><text>确认添加</text></view></view>
                <view class="sheet-actions"><view class="sheet-cancel" @tap="closeAddSheet"><text>取消</text></view><view class="sheet-confirm" @tap="confirmAddHour"><text>{{editingHourIndex >= 0 ? '确认修改' : '确认添加'}}</text></view></view>
            </view>
        </view>
    </view>
@@ -63,6 +63,7 @@
    const saving = ref(false)
    let cancelHoursRequest: (() => void) | null = null
    const addSheetVisible = ref(false)
    const editingHourIndex = ref(-1)
    const activeTimeField = ref('start')
    const startTime = ref('08:00')
    const endTime = ref('12:00')
@@ -117,14 +118,27 @@
            return
        }
        activeTimeField.value = 'start'
        editingHourIndex.value = -1
        startTime.value = '08:00'
        endTime.value = '12:00'
        setPickerTime(startTime.value)
        addSheetVisible.value = true
    }
    const showEditSheet = (index: number) => {
        const period = businessHours.value[index]
        if (period == null) return
        editingHourIndex.value = index
        activeTimeField.value = 'start'
        startTime.value = period.start
        endTime.value = period.end
        setPickerTime(startTime.value)
        addSheetVisible.value = true
    }
    const closeAddSheet = () => {
        addSheetVisible.value = false
        editingHourIndex.value = -1
    }
    const selectTimeField = (field: string) => {
@@ -155,14 +169,18 @@
            uni.showToast({ title: '结束时间需晚于开始时间', icon: 'none' })
            return
        }
        const overlapHour = businessHours.value.find((item) => {
            return startMinutes < getTimeMinutes(item.end) && endMinutes > getTimeMinutes(item.start)
        const overlapHour = businessHours.value.find((item, index) => {
            return index != editingHourIndex.value && startMinutes < getTimeMinutes(item.end) && endMinutes > getTimeMinutes(item.start)
        })
        if (overlapHour != null) {
            uni.showToast({ title: '时间段不能与已有时段重叠', icon: 'none' })
            return
        }
        businessHours.value.push({ start: startTime.value, end: endTime.value })
        if (editingHourIndex.value >= 0) {
            businessHours.value.splice(editingHourIndex.value, 1, { start: startTime.value, end: endTime.value })
        } else {
            businessHours.value.push({ start: startTime.value, end: endTime.value })
        }
        addSheetVisible.value = false
    }
@@ -205,7 +223,7 @@
    .hero-mask { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-image: linear-gradient(180deg, rgba(27, 25, 23, 0.22) 0%, rgba(27, 25, 23, 0.58) 100%); }
    .hero-nav { display: flex; height: 154rpx; padding: 82rpx 38rpx 0; position: relative; box-sizing: border-box; }
    .hero-back { display: flex; width: 58rpx; height: 58rpx; align-items: center; justify-content: center; }
    .hero-back-icon { width: 34rpx; height: 34rpx; transform: rotate(180deg); }
    .hero-back-icon { width: 34rpx; height: 34rpx; }
    .hero-title-wrap { display: flex; flex-direction: column; align-items: center; position: relative; margin-top: 50rpx; }
    .hero-title { color: #FFFFFF; font-size: 42rpx; line-height: 59rpx; font-weight: 700; }
    .hero-subtitle { color: rgba(255, 255, 255, 0.90); font-size: 25rpx; line-height: 35rpx; margin-top: 4rpx; }
rencaika_merchant/pages/consume-input/consume-input.uvue
@@ -154,7 +154,7 @@
    .navigation-bar { display: flex; height: 176rpx; flex: none; justify-content: center; align-items: flex-end; padding-bottom: 26rpx; box-sizing: border-box; background-color: #FFFFFF; position: relative; }
    .navigation-title { color: #293344; font-size: 34rpx; line-height: 48rpx; font-weight: 700; }
    .back-button { display: flex; width: 72rpx; height: 72rpx; justify-content: center; align-items: center; position: absolute; left: 24rpx; bottom: 14rpx; }
    .back-icon { width: 38rpx; height: 38rpx; transform: rotate(180deg); }
    .back-icon { width: 38rpx; height: 38rpx; }
    .content { flex: 1; height: 0; padding: 32rpx 30rpx 0; box-sizing: border-box; }
    .talent-card, .amount-card, .method-card, .summary-card { border-radius: 14rpx; background-color: #FFFFFF; box-shadow: 0 4rpx 18rpx rgba(49, 62, 83, 0.06); box-sizing: border-box; }
    .talent-card { display: flex; min-height: 204rpx; padding: 28rpx 30rpx; flex-direction: column; border-width: 1rpx; border-style: solid; border-color: #E4BE59; background-image: linear-gradient(135deg, #C99D32 0%, #F7E69D 55%, #E5BD4E 100%); position: relative; overflow: hidden; }
rencaika_merchant/pages/index/index.uvue
@@ -13,7 +13,6 @@
            <view class="dashboard">
                <view v-if="dashboardState == 'loading'" class="dashboard-state"><text class="dashboard-state-text">经营数据加载中</text></view>
                <view v-else-if="dashboardState == 'error'" class="dashboard-state"><text class="dashboard-state-text">{{dashboardError}}</text><text class="dashboard-retry" @tap="loadDashboard">重新加载</text></view>
                <view v-else-if="dashboardState == 'empty'" class="dashboard-state"><text class="dashboard-state-text">暂无经营数据</text><text class="dashboard-state-tip">完成核销后将在这里展示经营统计</text></view>
                <view v-else>
                <view class="amount-card">
                    <view class="card-copy">
@@ -23,21 +22,18 @@
                            <text class="amount-unit">元</text>
                        </view>
                    </view>
                    <view class="round-icon wallet-icon"><image class="metric-svg" src="/static/icons/lucide/wallet-cards.svg" mode="aspectFit"></image></view>
                </view>
                <view class="metric-row">
                    <view class="small-card daily-card">
                        <view class="small-card-top">
                            <text class="metric-label">今日核销</text>
                            <view class="mini-icon verify-icon"><image class="mini-svg" src="/static/icons/lucide/scan-line-orange.svg" mode="aspectFit"></image></view>
                        </view>
                        <view class="small-number-line"><text class="small-number">{{todayVerificationCount}}</text><text class="small-unit">笔</text></view>
                    </view>
                    <view class="small-card weekly-card">
                        <view class="small-card-top">
                            <text class="metric-label">本周核销</text>
                            <view class="mini-icon calendar-icon"><image class="mini-svg" src="/static/icons/lucide/calendar-days.svg" mode="aspectFit"></image></view>
                        </view>
                        <view class="small-number-line"><text class="small-number">{{weekVerificationCount}}</text><text class="small-unit">笔</text></view>
                    </view>
@@ -46,7 +42,6 @@
                <view class="subsidy-card">
                    <view class="small-card-top">
                        <text class="metric-label">补贴总额</text>
                        <view class="mini-icon subsidy-icon"><image class="mini-svg" src="/static/icons/lucide/hand-coins.svg" mode="aspectFit"></image></view>
                    </view>
                    <view class="small-number-line"><text class="subsidy-value">{{subsidyAmount}}</text><text class="amount-unit">元</text></view>
                </view>
@@ -71,10 +66,11 @@
    const dashboardState = ref('loading')
    const dashboardError = ref('')
    const transactionAmount = ref('')
    const todayVerificationCount = ref('')
    const weekVerificationCount = ref('')
    const subsidyAmount = ref('')
    // 即使商户尚无核销记录,也始终展示统计卡片并以 0 补齐数据。
    const transactionAmount = ref('0.00')
    const todayVerificationCount = ref('0')
    const weekVerificationCount = ref('0')
    const subsidyAmount = ref('0.00')
    const formatAmount = (value: any): string => {
        const amount = Number(value) / 100
@@ -96,7 +92,7 @@
            return
        }
        if (!hasActiveSession()) {
            dashboardState.value = 'empty'
            dashboardState.value = 'success'
            dashboardError.value = ''
            return
        }
rencaika_merchant/pages/merchant-onboarding-pending/merchant-onboarding-pending.uvue
@@ -24,7 +24,7 @@
    .page { display: flex; flex: 1; width: 100%; height: 100vh; flex-direction: column; overflow: hidden; background-color: #F4F6F9; }
    .navigation-bar { display: flex; height: 174rpx; padding: 68rpx 38rpx 0; flex: none; flex-direction: row; align-items: center; position: relative; background-color: #F4F6F9; box-sizing: border-box; }
    .back-button { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; position: absolute; left: 38rpx; top: 89rpx; z-index: 2; }
    .back-icon { width: 38rpx; height: 38rpx; transform: rotate(180deg); }
    .back-icon { width: 38rpx; height: 38rpx; }
    .page-title { width: 100%; color: #202B3E; font-size: 34rpx; line-height: 48rpx; text-align: center; font-weight: 700; }
    .result-content { display: flex; padding: 70rpx 44rpx 0; flex: 1; flex-direction: column; align-items: center; box-sizing: border-box; }
    .status-icon-wrap { display: flex; width: 176rpx; height: 176rpx; justify-content: center; align-items: center; border-radius: 88rpx; background-image: linear-gradient(145deg, #FFF0E6 0%, #FFF8F3 100%); box-shadow: 0 18rpx 38rpx rgba(255, 107, 44, 0.12); }
rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue
@@ -161,7 +161,7 @@
    }
    const normalizeCreditCode = () => {
        creditCode.value = creditCode.value.toUpperCase()
        creditCode.value = creditCode.value.toUpperCase().replace(/[^0-9A-Z]/g, '')
        clearFieldError('creditCode')
    }
@@ -342,7 +342,7 @@
    .page { display: flex; flex: 1; width: 100%; height: 100vh; flex-direction: column; overflow: hidden; background-color: #F4F6F9; }
    .navigation-bar { display: flex; height: 174rpx; padding: 68rpx 38rpx 0; flex: none; flex-direction: row; align-items: center; position: relative; background-color: #F4F6F9; box-sizing: border-box; }
    .back-button { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; position: absolute; left: 38rpx; top: 89rpx; z-index: 2; }
    .back-icon { width: 38rpx; height: 38rpx; transform: rotate(180deg); }
    .back-icon { width: 38rpx; height: 38rpx; }
    .page-title { width: 100%; color: #202B3E; font-size: 34rpx; line-height: 48rpx; text-align: center; font-weight: 700; }
    .form-scroll { flex: 1; height: 0; padding: 0 34rpx; box-sizing: border-box; }
    .form-card { margin-top: 26rpx; padding: 30rpx; border-radius: 30rpx; background-color: #FFFFFF; box-shadow: 0 14rpx 34rpx rgba(49, 55, 66, 0.05); box-sizing: border-box; }
rencaika_merchant/pages/order-detail/order-detail.uvue
@@ -24,13 +24,13 @@
            <text class="section-title benefit-section-title">权益信息</text>
            <view class="benefit-card">
                <view class="benefit-glow"></view>
                <view class="benefit-head"><text class="customer-name">{{customerName}}</text><text class="talent-tag">卓越人才卡</text></view>
                <view class="benefit-head"><text class="customer-name">{{customerName}}</text><text class="talent-tag">{{cardLevel}}</text></view>
                <text class="customer-phone">{{customerPhone}}</text>
                <view class="benefit-line"><text class="benefit-label">当前权益</text><text class="benefit-content">免费5次/年,超出次数按市价9折</text></view>
                <view class="benefit-line"><text class="benefit-label">当前权益</text><text class="benefit-content">{{benefitName}}</text></view>
            </view>
            <view class="bottom-space"></view>
        </scroll-view>
        <view v-if="isCompleted && !isStoreCompleted" class="bottom-actions"><view class="cancel-verification-button" :class="revokeSubmitting ? 'cancel-verification-submitting' : ''" @tap="handleRevokeVerification"><text>{{revokeSubmitting ? '撤销中…' : '撤销核销'}}</text></view></view>
        <view v-if="isCompleted && !isStoreCompleted" class="bottom-actions"><view class="cancel-verification-button" :class="revokeSubmitting ? 'cancel-verification-submitting' : (!canRevokeVerification ? 'cancel-verification-disabled' : '')" @tap="handleRevokeVerification"><text>{{revokeSubmitting ? '撤销中…' : '撤销核销'}}</text></view></view>
    </view>
</template>
@@ -58,19 +58,23 @@
    const verificationAt = ref('')
    const customerName = ref('')
    const customerPhone = ref('')
    const cardLevel = ref('人才卡')
    const benefitName = ref('以当前订单权益规则为准')
    const formatCents = (value: number): string => '¥' + (value / 100).toFixed(2)
    const applyRemoteOrder = (detail: MerchantOrderDetail) => {
        orderNo.value = detail.orderNo
        productAmount.value = formatCents(detail.orderAmount)
        talentDiscount.value = '-¥0.00'
        productDiscount.value = '-¥0.00'
        talentDiscount.value = '-¥' + (detail.talentDiscountAmount / 100).toFixed(2)
        productDiscount.value = '-¥' + (detail.couponAmount / 100).toFixed(2)
        paidAmount.value = formatCents(detail.payAmount)
        subsidyAmount.value = formatCents(detail.subsidyAmount)
        createdAt.value = detail.createdAt.replace('T', ' ')
        verificationAt.value = detail.verifyTime.length > 0 ? detail.verifyTime.replace('T', ' ') : '待核销'
        customerName.value = detail.customerName
        customerPhone.value = detail.customerMobile
        cardLevel.value = detail.cardLevel.length > 0 ? detail.cardLevel : '人才卡'
        benefitName.value = detail.benefitName.length > 0 ? detail.benefitName : '以当前订单权益规则为准'
        isCompleted.value = detail.status == 'completed'
        isStoreCompleted.value = detail.category == 'store' && detail.status == 'completed'
        canRevokeVerification.value = detail.canRevoke
@@ -144,7 +148,6 @@
    const handleRevokeVerification = () => {
        if (revokeSubmitting.value) return
        if (!canRevokeVerification.value) {
            showRevokeExpired()
            return
        }
        uni.showModal({
@@ -168,7 +171,7 @@
    .paid-page { background-image: linear-gradient(180deg, #E8EDF4 0%, #F3F5F8 390rpx, #F6F5F2 760rpx); }
    .navigation-bar { display: flex; height: 174rpx; flex: none; position: relative; box-sizing: border-box; }
    .back-button { display: flex; width: 72rpx; height: 72rpx; justify-content: center; align-items: center; position: absolute; top: 82rpx; left: 28rpx; z-index: 2; }
    .back-icon { width: 40rpx; height: 40rpx; transform: rotate(180deg); }
    .back-icon { width: 40rpx; height: 40rpx; }
    .detail-scroll { flex: 1; height: 0; padding: 10rpx 34rpx 0; box-sizing: border-box; }
    .status-panel { display: flex; min-height: 126rpx; flex-direction: row; justify-content: space-between; align-items: flex-start; }
    .status-copy { display: flex; flex: 1; flex-direction: column; }
@@ -201,4 +204,5 @@
    .bottom-actions { display: flex; height: 154rpx; padding: 20rpx 96rpx 34rpx; flex: none; background-color: #F6F5F2; box-sizing: border-box; }
    .cancel-verification-button { display: flex; flex: 1; height: 94rpx; justify-content: center; align-items: center; border-radius: 48rpx; color: #FFFFFF; font-size: 29rpx; line-height: 41rpx; font-weight: 600; background-image: linear-gradient(90deg, #FF7625 0%, #FF9630 100%); box-shadow: 0 12rpx 24rpx rgba(255, 107, 36, 0.20); }
    .cancel-verification-submitting { opacity: 0.62; box-shadow: none; }
    .cancel-verification-disabled { background-image: none; background-color: #C8CDD5; box-shadow: none; }
</style>
rencaika_merchant/pages/orders/orders.uvue
@@ -176,9 +176,14 @@
    const currentYear = ref(deviceDate.getFullYear())
    const currentMonth = ref(deviceDate.getMonth() + 1)
    const currentDay = ref(deviceDate.getDate())
    const years = ref([(currentYear.value - 2).toString() + '年', (currentYear.value - 1).toString() + '年', currentYear.value.toString() + '年'])
    const months = ref(allMonths.slice(0, currentMonth.value))
    const days = ref(allDays.slice(0, currentDay.value))
    const buildYears = (fromYear: number): Array<string> => {
        const values: Array<string> = []
        for (let year = fromYear; year <= 2099; year++) values.push(year.toString() + '年')
        return values
    }
    const years = ref(buildYears(currentYear.value - 2))
    const months = ref(allMonths.slice())
    const days = ref(allDays.slice(0, new Date(currentYear.value, currentMonth.value, 0).getDate()))
    const defaultDate = ref(currentYear.value.toString() + '年' + currentMonth.value.toString() + '月' + currentDay.value.toString() + '日')
    const defaultDateValue = ref(currentYear.value * 10000 + currentMonth.value * 100 + currentDay.value)
    const startDate = ref(datePlaceholder)
@@ -204,8 +209,8 @@
        const year = Number(parts[0]); const month = Number(parts[1]); const day = Number(parts[2])
        if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day) || month < 1 || month > 12 || day < 1 || day > 31) return
        currentYear.value = year; currentMonth.value = month; currentDay.value = day
        years.value = [(year - 2).toString() + '年', (year - 1).toString() + '年', year.toString() + '年']
        months.value = allMonths.slice(0, month); days.value = allDays.slice(0, day)
        years.value = buildYears(year - 2)
        months.value = allMonths.slice(); days.value = allDays.slice(0, new Date(year, month, 0).getDate())
        defaultDate.value = year.toString() + '年' + month.toString() + '月' + day.toString() + '日'
        defaultDateValue.value = year * 10000 + month * 100 + day
        if (!dateFilterApplied.value) { startDate.value = datePlaceholder; startDateValue.value = 0; endDate.value = datePlaceholder; endDateValue.value = 0; pickerIndexes.value = [2, month - 1, day - 1] }
@@ -390,9 +395,6 @@
    }
    const getMaxDay = (year: number, month: number) => {
        if (year == currentYear.value && month == currentMonth.value) {
            return currentDay.value
        }
        if (month == 2) {
            return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) ? 29 : 28
        }
@@ -404,7 +406,7 @@
        const month = Math.floor(dateValue / 100) % 100
        const day = dateValue % 100
        const yearIndex = year - (currentYear.value - 2)
        months.value = year == currentYear.value ? allMonths.slice(0, currentMonth.value) : allMonths
        months.value = allMonths.slice()
        days.value = allDays.slice(0, getMaxDay(year, month))
        pickerIndexes.value = [yearIndex, month - 1, day - 1]
    }
@@ -415,7 +417,7 @@
        let monthIndex = indexes[1]
        let dayIndex = indexes[2]
        const selectedYear = currentYear.value - 2 + yearIndex
        months.value = selectedYear == currentYear.value ? allMonths.slice(0, currentMonth.value) : allMonths
        months.value = allMonths.slice()
        if (monthIndex >= months.value.length) {
            monthIndex = months.value.length - 1
        }
@@ -452,8 +454,8 @@
        endDate.value = datePlaceholder
        startDateValue.value = 0
        endDateValue.value = 0
        months.value = allMonths.slice(0, currentMonth.value)
        days.value = allDays.slice(0, currentDay.value)
        months.value = allMonths.slice()
        days.value = allDays.slice(0, getMaxDay(currentYear.value, currentMonth.value))
        pickerIndexes.value = [2, currentMonth.value - 1, currentDay.value - 1]
        dateFilterText.value = '时间筛选'
        dateFilterApplied.value = false
rencaika_merchant/pages/product-detail/product-detail.uvue
@@ -358,7 +358,7 @@
    .page { display: flex; flex: 1; flex-direction: column; width: 100%; height: 100vh; overflow: hidden; background-color: #F4F6F9; }
    .navigation-bar { display: flex; height: 174rpx; padding: 68rpx 38rpx 0; flex-direction: row; align-items: center; background-color: #F4F6F9; box-sizing: border-box; position: relative; flex: none; }
    .back-button { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; position: absolute; left: 38rpx; top: 89rpx; z-index: 2; }
    .back-icon { width: 38rpx; height: 38rpx; transform: rotate(180deg); }
    .back-icon { width: 38rpx; height: 38rpx; }
    .page-title { width: 100%; color: #202B3E; font-size: 34rpx; line-height: 48rpx; text-align: center; font-weight: 700; }
    .detail-scroll { flex: 1; height: 0; padding: 0 34rpx 164rpx; background-color: #F4F6F9; box-sizing: border-box; }
rencaika_merchant/pages/products/products.uvue
@@ -114,7 +114,7 @@
    .page { display: flex; flex: 1; flex-direction: column; width: 100%; height: 100vh; overflow: hidden; background-color: #F4F6F9; }
    .navigation-bar { display: flex; height: 174rpx; padding: 68rpx 38rpx 0; flex-direction: row; align-items: center; background-color: #F4F6F9; box-sizing: border-box; position: relative; flex: none; }
    .back-button { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; position: absolute; left: 38rpx; top: 89rpx; z-index: 2; }
    .back-icon { width: 38rpx; height: 38rpx; transform: rotate(180deg); }
    .back-icon { width: 38rpx; height: 38rpx; }
    .page-title { width: 100%; color: #202B3E; font-size: 34rpx; line-height: 48rpx; text-align: center; font-weight: 700; }
    .tabs { display: flex; height: 96rpx; padding: 0 42rpx; flex-direction: row; background-color: #F4F6F9; box-sizing: border-box; flex: none; }
rencaika_merchant/pages/scan/scan.uvue
@@ -15,15 +15,7 @@
            <cover-view v-else class="status-mark" :class="scanState == 'success' ? 'success-mark' : 'error-mark'">{{scanState == 'success' ? '✓' : '!'}}</cover-view>
            <cover-view class="status-title">{{statusTitle}}</cover-view>
            <cover-view class="status-message">{{statusMessage}}</cover-view>
            <cover-view v-if="scanState == 'success' && !verificationConfirmed" class="credential-summary">
                <cover-view class="credential-type">{{credentialLabel}}</cover-view>
                <cover-view class="credential-person">{{customerName}} {{cardLevel}}</cover-view>
                <cover-view v-if="customerMobile != ''" class="credential-mobile">{{customerMobile}}</cover-view>
                <cover-view v-if="orderNo != ''" class="credential-order">订单号:{{orderNo}}</cover-view>
            </cover-view>
            <cover-view v-if="scanState == 'success' && !verificationConfirmed" class="status-actions"><cover-view class="secondary-action half-action" @tap="resetScanning">重新扫码</cover-view><cover-view class="primary-action half-action" @tap="confirmVerification">确认核销</cover-view></cover-view>
            <cover-view v-else-if="scanState == 'success'" class="primary-action" @tap="resetScanning">继续扫码</cover-view>
            <cover-view v-else-if="scanState == 'camera-error'" class="primary-action" @tap="openCameraSettings">打开相机设置</cover-view>
            <cover-view v-if="scanState == 'camera-error'" class="primary-action" @tap="openCameraSettings">打开相机设置</cover-view>
            <cover-view v-else-if="scanState == 'error'" class="status-actions">
                <cover-view class="secondary-action" :class="canRetryVerification ? 'half-action' : 'full-action'" @tap="resetScanning">重新扫描</cover-view>
                <cover-view v-if="canRetryVerification" class="primary-action half-action" @tap="retryVerification">重试核验</cover-view>
@@ -33,7 +25,7 @@
</template>
<script setup lang="uts">
    import { confirmVerificationCode, scanVerificationCode, VerificationScanResult } from '../../services/verification.uts'
    import { scanVerificationCode, VerificationScanResult } from '../../services/verification.uts'
    import { HttpError } from '../../services/http.uts'
    const hasScanned = ref(false)
@@ -41,15 +33,7 @@
    const statusTitle = ref('')
    const statusMessage = ref('')
    const canRetryVerification = ref(false)
    const verificationConfirmed = ref(false)
    const confirming = ref(false)
    const credentialLabel = ref('')
    const customerName = ref('')
    const customerMobile = ref('')
    const cardLevel = ref('')
    const orderNo = ref('')
    let lastScanResult = ''
    let currentVerificationId = ''
    let openedManualConsumption = false
    let openedConfirmation = false
    let cancelVerificationRequest: (() => void) | null = null
@@ -130,62 +114,14 @@
        verifyScanResult(lastScanResult)
    }
    const submitConfirmation = () => {
        if (confirming.value || currentVerificationId.length == 0) return
        confirming.value = true
        statusTitle.value = '正在确认核销'
        statusMessage.value = '请稍候,不要重复提交'
        cancelVerificationRequest = confirmVerificationCode(currentVerificationId, 'merchant-verify-' + currentVerificationId + '-' + Date.now(), (result: any, _traceId: string) => {
            cancelVerificationRequest = null
            confirming.value = false
            verificationConfirmed.value = true
            scanState.value = 'success'
            statusTitle.value = '核销成功'
            statusMessage.value = result.message != null ? result.message : '本次核销已完成'
        }, (error: HttpError) => {
            cancelVerificationRequest = null
            confirming.value = false
            scanState.value = 'success'
            statusTitle.value = '确认核销失败'
            statusMessage.value = error.message
        })
    }
    const confirmVerification = () => {
        if (confirming.value || currentVerificationId.length == 0) return
        uni.showModal({
            title: '确认核销',
            content: '确认后将消耗本次核销凭证,是否继续?',
            confirmText: '确认核销',
            confirmColor: '#FF6B2C',
            success: (result: any) => {
                if (result.confirm) submitConfirmation()
            }
        })
    }
    const resetScanning = () => {
        cancelPendingRequest()
        hasScanned.value = false
        lastScanResult = ''
        currentVerificationId = ''
        verificationConfirmed.value = false
        confirming.value = false
        scanState.value = 'scanning'
        statusTitle.value = ''
        statusMessage.value = ''
        credentialLabel.value = ''
        customerName.value = ''
        customerMobile.value = ''
        cardLevel.value = ''
        orderNo.value = ''
        canRetryVerification.value = false
    }
    const credentialTypeLabel = (type: string): string => {
        if (type == 'ORDER_CODE') return '订单核销码'
        if (type == 'FREE_STAY_CODE') return '免费入住核销码'
        return '人才码'
    }
    const onCameraError = (event: any) => {
@@ -227,7 +163,7 @@
    .scan-camera { width: 100%; height: 100vh; background-color: #000000; position: absolute; top: 0; right: 0; bottom: 0; left: 0; }
    .camera-shade { position: absolute; top: 0; right: 0; bottom: 0; left: 0; background-color: rgba(0, 0, 0, 0.32); }
    .back-button { display: flex; width: 72rpx; height: 72rpx; justify-content: center; align-items: center; position: absolute; top: 82rpx; left: 28rpx; z-index: 20; }
    .back-icon { width: 40rpx; height: 40rpx; transform: rotate(180deg); }
    .back-icon { width: 40rpx; height: 40rpx; }
    .scan-frame { width: 570rpx; height: 570rpx; position: absolute; top: 382rpx; left: 90rpx; z-index: 10; background-color: rgba(40, 137, 91, 0.22); box-sizing: border-box; }
    .scan-frame.scan-paused { background-color: rgba(24, 36, 31, 0.42); }
    .frame-top, .frame-bottom { width: 100%; height: 4rpx; position: absolute; left: 0; background-color: #6BE2AD; opacity: 0.85; }
rencaika_merchant/pages/settings/settings.uvue
@@ -86,7 +86,7 @@
    .page { display: flex; flex: 1; flex-direction: column; width: 100%; height: 100vh; overflow: hidden; background-color: #F4F6F9; }
    .navigation-bar { display: flex; height: 174rpx; padding: 68rpx 38rpx 0; flex-direction: row; align-items: center; background-color: #F4F6F9; box-sizing: border-box; position: relative; flex: none; }
    .back-button { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; position: absolute; left: 38rpx; top: 89rpx; z-index: 2; }
    .back-icon { width: 38rpx; height: 38rpx; transform: rotate(180deg); }
    .back-icon { width: 38rpx; height: 38rpx; }
    .page-title { width: 100%; color: #202B3E; font-size: 34rpx; line-height: 48rpx; text-align: center; font-weight: 700; }
    .content { flex: 1; padding: 0 34rpx 164rpx; background-color: #F4F6F9; box-sizing: border-box; }
rencaika_merchant/pages/verification-confirm/verification-confirm.uvue
@@ -1,74 +1,154 @@
<template>
    <view class="page">
        <view class="navigation-bar"><view class="back-button" @tap="goBack"><image class="back-icon" src="/static/icons/lucide/back-left.svg" mode="aspectFit"></image></view><text class="navigation-title">确认核销</text></view>
        <view class="content">
            <view class="notice-card"><text class="notice-title">请确认核销信息</text><text class="notice-desc">确认后将完成本次权益核销,请仔细核对。</text></view>
            <view class="credential-card"><view class="credential-head"><text class="credential-title">{{credentialLabel}}</text><text class="card-level">{{cardLevel}}</text></view><text class="customer-name">{{customerName}}</text><text class="customer-mobile">{{displayMobile(customerMobile)}}</text><view v-if="orderNo.length > 0" class="info-row"><text>订单号</text><text>{{orderNo}}</text></view></view>
        <view class="navigation-bar">
            <view class="back-button" @tap.stop="goBack"><image class="back-icon" src="/static/icons/lucide/back-left.svg" mode="aspectFit"></image></view>
        </view>
        <view class="bottom-bar"><view class="confirm-button" :class="submitting ? 'button-disabled' : ''" @tap="confirm"><text>{{submitting ? '核销中…' : '确认核销'}}</text></view></view>
        <scroll-view class="detail-scroll" scroll-y="true" :show-scrollbar="false">
            <view class="status-panel">
                <view class="status-copy"><text class="status-title">待核销</text><text class="status-desc">订单已完成支付,请确认后进行核销。</text></view>
                <view class="status-icon-wrap"><image class="status-icon" src="/static/icons/lucide/badge-check.svg" mode="aspectFit"></image></view>
            </view>
            <view class="product-card">
                <view class="product-cover"></view>
                <view class="product-copy"><text class="product-name">{{title}}</text><view class="product-price-row"><text class="unit-price">¥{{formatAmount(orderAmount)}}</text><view class="actual-price"><text class="actual-label">实付</text><text class="actual-amount">¥{{formatAmount(payAmount)}}</text></view></view></view>
            </view>
            <text class="section-title">订单信息</text>
            <view class="info-card">
                <view class="info-row"><text class="info-label">订单号</text><text class="info-value">{{orderNo}}</text></view>
                <view class="info-row"><text class="info-label">商品金额</text><text class="info-value">¥{{formatAmount(orderAmount)}}</text></view>
                <view class="info-row"><text class="info-label">人才折扣</text><text class="info-value">-¥{{formatAmount(talentDiscountAmount)}}</text></view>
                <view class="info-row"><text class="info-label">商品优惠</text><text class="info-value">-¥{{formatAmount(couponAmount)}}</text></view>
                <view class="info-row"><text class="info-label">实际支付</text><text class="info-value pay-value">¥{{formatAmount(payAmount)}}</text></view>
                <view class="info-row"><text class="info-label">补贴金额</text><text class="info-value">¥{{formatAmount(subsidyAmount)}}</text></view>
                <view class="info-row"><text class="info-label">下单时间</text><text class="info-value time-value">{{formatDateTime(createdAt)}}</text></view>
                <view class="info-row last-row"><text class="info-label">核销时间</text><text class="info-value muted-value">待核销</text></view>
            </view>
            <text class="section-title benefit-section-title">权益信息</text>
            <view class="benefit-card"><view class="benefit-glow"></view><view class="benefit-head"><text class="customer-name">{{customerName}}</text><text class="talent-tag">{{cardLevel}}</text></view><text class="customer-phone">{{displayMobile(customerMobile)}}</text><view class="benefit-line"><text class="benefit-label">当前权益</text><text class="benefit-content">{{benefitName}}</text></view></view>
            <view class="bottom-space"></view>
        </scroll-view>
        <view class="bottom-actions"><view class="confirm-button" :class="submitting ? 'button-disabled' : ''" @tap="confirm"><text>{{submitting ? '核销中...' : '确认核销'}}</text></view></view>
    </view>
</template>
<script setup lang="uts">
    import { loadMerchantOrderDetail, MerchantOrderDetail } from '../../services/orders.uts'
    import { confirmVerificationCode } from '../../services/verification.uts'
    const verificationId = ref('')
    const credentialType = ref('')
    const customerName = ref('持卡人')
    const customerMobile = ref('')
    const cardLevel = ref('人才卡')
    const orderNo = ref('')
    const title = ref('订单')
    const benefitName = ref('人才专属权益')
    const orderAmount = ref(0)
    const talentDiscountAmount = ref(0)
    const couponAmount = ref(0)
    const payAmount = ref(0)
    const subsidyAmount = ref(0)
    const createdAt = ref('')
    const submitting = ref(false)
    const credentialLabel = computed((): string => {
        if (credentialType.value == 'ORDER_CODE') return '订单核销码'
        if (credentialType.value == 'FREE_STAY_CODE') return '免费入住核销码'
        return '人才码'
    })
    const displayMobile = (mobile: string): string => mobile.length < 7 ? mobile : mobile.substring(0, 3) + '***' + mobile.substring(mobile.length - 4)
    const goBack = () => uni.navigateBack()
    const formatAmount = (amount: number): string => (amount / 100).toFixed(2)
    const formatDateTime = (value: string): string => value.length > 0 ? value.replace('T', ' ') : '-'
    const displayMobile = (mobile: string): string => mobile.length < 7 ? mobile : mobile.substring(0, 3) + '****' + mobile.substring(mobile.length - 4)
    const textOption = (options: any, name: string, fallback: string): string => options != null && options[name] != null && ('' + options[name]).length > 0 ? decodeURIComponent('' + options[name]) : fallback
    const goBack = () => uni.navigateBack()
    const applyOrder = (order: MerchantOrderDetail) => {
        title.value = order.title.length > 0 ? order.title : title.value
        benefitName.value = order.benefitName.length > 0 ? order.benefitName : benefitName.value
        orderAmount.value = order.orderAmount
        talentDiscountAmount.value = order.talentDiscountAmount
        couponAmount.value = order.couponAmount
        payAmount.value = order.payAmount
        subsidyAmount.value = order.subsidyAmount
        createdAt.value = order.createdAt
        customerName.value = order.customerName.length > 0 ? order.customerName : customerName.value
        customerMobile.value = order.customerMobile.length > 0 ? order.customerMobile : customerMobile.value
        cardLevel.value = order.cardLevel.length > 0 ? order.cardLevel : cardLevel.value
    }
    const loadOrder = () => {
        if (orderNo.value.length == 0) return
        loadMerchantOrderDetail(orderNo.value, (order: MerchantOrderDetail, _traceId: string) => applyOrder(order), () => {})
    }
    const confirm = () => {
        if (submitting.value || verificationId.value.length == 0) return
        submitting.value = true
        confirmVerificationCode(verificationId.value, 'merchant-verify-' + verificationId.value + '-' + Date.now(), (result: any, _traceId: string) => {
            submitting.value = false
            uni.redirectTo({ url: '/pages/verification-success/verification-success?message=' + encodeURIComponent(result.message != null ? '' + result.message : '本次核销已完成') })
        }, (error: any) => {
            submitting.value = false
            uni.showToast({ title: error.message != null ? error.message : '核销失败,请重试', icon: 'none' })
        uni.showModal({
            title: '确认核销',
            content: '确认后将完成本次订单核销,是否继续?',
            confirmText: '确认核销',
            confirmColor: '#FF6B2C',
            success: (modal: any) => {
                if (!modal.confirm) return
                submitting.value = true
                confirmVerificationCode(verificationId.value, 'merchant-verify-' + verificationId.value + '-' + Date.now(), (result: any, _traceId: string) => {
                    submitting.value = false
                    uni.redirectTo({ url: '/pages/verification-success/verification-success?orderNo=' + encodeURIComponent(orderNo.value) })
                }, (error: any) => {
                    submitting.value = false
                    uni.showToast({ title: error.message != null ? error.message : '核销失败,请重试', icon: 'none' })
                })
            }
        })
    }
    onLoad((options: any) => {
        verificationId.value = textOption(options, 'verificationId', '')
        credentialType.value = textOption(options, 'credentialType', '')
        customerName.value = textOption(options, 'customerName', '持卡人')
        customerMobile.value = textOption(options, 'customerMobile', '')
        cardLevel.value = textOption(options, 'cardLevel', '人才卡')
        orderNo.value = textOption(options, 'orderNo', '')
        loadOrder()
    })
</script>
<style>
    page { background-color: #F6F5F2; }
    .page { display: flex; width: 100%; height: 100vh; flex-direction: column; background-color: #F6F5F2; }
    .navigation-bar { display: flex; height: 176rpx; flex: none; justify-content: center; align-items: flex-end; padding-bottom: 26rpx; box-sizing: border-box; background-color: #FFFFFF; position: relative; }
    .navigation-title { color: #293344; font-size: 34rpx; line-height: 48rpx; font-weight: 700; }
    .back-button { display: flex; width: 72rpx; height: 72rpx; justify-content: center; align-items: center; position: absolute; left: 24rpx; bottom: 14rpx; }
    .back-icon { width: 38rpx; height: 38rpx; transform: rotate(180deg); }
    .content { flex: 1; padding: 34rpx 32rpx; box-sizing: border-box; }
    .notice-card, .credential-card { border-radius: 28rpx; background-color: #FFFFFF; box-shadow: 0 12rpx 28rpx rgba(62, 69, 80, 0.06); box-sizing: border-box; }
    .notice-card { padding: 30rpx; }
    .notice-title { display: block; color: #263043; font-size: 32rpx; line-height: 45rpx; font-weight: 700; }
    .notice-desc { display: block; margin-top: 12rpx; color: #87909E; font-size: 25rpx; line-height: 36rpx; }
    .credential-card { padding: 32rpx; margin-top: 26rpx; border-width: 1rpx; border-style: solid; border-color: #E4BE59; background-image: linear-gradient(135deg, #C99D32 0%, #F7E69D 55%, #E5BD4E 100%); }
    .credential-head { display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
    .credential-title, .customer-name, .customer-mobile { color: #FFFFFF; }
    .credential-title { font-size: 27rpx; line-height: 38rpx; font-weight: 600; }
    .card-level { padding: 8rpx 18rpx; border-radius: 22rpx; color: #FFFFFF; font-size: 21rpx; line-height: 30rpx; background-color: rgba(149, 104, 22, 0.46); }
    .customer-name { display: block; margin-top: 34rpx; font-size: 40rpx; line-height: 56rpx; font-weight: 700; }
    .customer-mobile { display: block; margin-top: 11rpx; font-size: 28rpx; line-height: 40rpx; }
    .info-row { display: flex; padding-top: 25rpx; margin-top: 25rpx; border-top-width: 1rpx; border-top-style: solid; border-top-color: rgba(255, 255, 255, 0.35); flex-direction: row; justify-content: space-between; color: #FFFFFF; font-size: 24rpx; line-height: 34rpx; }
    .bottom-bar { display: flex; height: 148rpx; padding: 20rpx 84rpx 30rpx; flex: none; background-color: #FFFFFF; box-sizing: border-box; }
    .confirm-button { display: flex; flex: 1; height: 92rpx; justify-content: center; align-items: center; border-radius: 46rpx; color: #FFFFFF; font-size: 29rpx; line-height: 41rpx; font-weight: 700; background-image: linear-gradient(90deg, #FF7428 0%, #FF9630 100%); }
    .button-disabled { opacity: 0.62; }
    .page { display: flex; flex: 1; flex-direction: column; width: 100%; height: 100vh; overflow: hidden; background-image: linear-gradient(180deg, #FFE3C4 0%, #FFF5EA 390rpx, #F6F5F2 760rpx); }
    .navigation-bar { display: flex; height: 174rpx; flex: none; position: relative; box-sizing: border-box; }
    .back-button { display: flex; width: 72rpx; height: 72rpx; justify-content: center; align-items: center; position: absolute; top: 82rpx; left: 28rpx; z-index: 2; }
    .back-icon { width: 40rpx; height: 40rpx; }
    .detail-scroll { flex: 1; height: 0; padding: 10rpx 34rpx 0; box-sizing: border-box; }
    .status-panel { display: flex; min-height: 126rpx; flex-direction: row; justify-content: space-between; align-items: flex-start; }
    .status-copy { display: flex; flex: 1; flex-direction: column; }
    .status-title { color: #FF633A; font-size: 50rpx; line-height: 70rpx; font-weight: 700; }
    .status-desc { margin-top: 11rpx; color: #97A0AE; font-size: 25rpx; line-height: 36rpx; }
    .status-icon-wrap { display: flex; width: 60rpx; height: 60rpx; margin-top: 10rpx; justify-content: center; align-items: center; border-radius: 30rpx; background-color: #FFE9D8; }
    .status-icon { width: 32rpx; height: 32rpx; }
    .product-card { display: flex; height: 192rpx; padding: 24rpx 28rpx; margin-top: 6rpx; flex-direction: row; align-items: center; border-radius: 28rpx; background-color: #FFFFFF; box-shadow: 0 14rpx 32rpx rgba(86, 69, 49, 0.05); box-sizing: border-box; }
    .product-cover { width: 128rpx; height: 128rpx; border-radius: 20rpx; flex: none; background-color: #D9D9D9; }
    .product-copy { display: flex; height: 128rpx; margin-left: 26rpx; flex: 1; flex-direction: column; justify-content: space-between; }
    .product-name { color: #263043; font-size: 30rpx; line-height: 42rpx; font-weight: 700; }
    .product-price-row, .actual-price { display: flex; flex-direction: row; align-items: flex-end; }
    .product-price-row { justify-content: space-between; }
    .unit-price, .actual-label { color: #344055; font-size: 26rpx; line-height: 37rpx; font-weight: 600; }
    .actual-label { margin-right: 12rpx; }
    .actual-amount { color: #FF6A26; font-size: 35rpx; line-height: 43rpx; font-weight: 700; }
    .section-title { display: block; margin-top: 32rpx; color: #293244; font-size: 31rpx; line-height: 44rpx; font-weight: 700; }
    .info-card { padding: 20rpx 30rpx; margin-top: 22rpx; border-radius: 32rpx; background-color: #FFFFFF; box-shadow: 0 16rpx 38rpx rgba(69, 65, 57, 0.06); box-sizing: border-box; }
    .info-row { display: flex; min-height: 57rpx; flex-direction: row; justify-content: space-between; align-items: center; }
    .info-label { color: #A1A9B6; font-size: 27rpx; line-height: 38rpx; }
    .info-value { color: #414B5E; font-size: 27rpx; line-height: 38rpx; font-weight: 600; text-align: right; }
    .pay-value { color: #FF6439; }
    .time-value { font-size: 25rpx; }
    .muted-value { color: #9AA4B3; font-weight: 500; }
    .last-row { min-height: 60rpx; }
    .benefit-section-title { margin-top: 32rpx; }
    .benefit-card { min-height: 238rpx; padding: 34rpx 34rpx 28rpx; margin-top: 22rpx; border-width: 1rpx; border-style: solid; border-color: #E4BE59; border-radius: 38rpx; background-image: linear-gradient(135deg, #C99D32 0%, #F7E69D 55%, #E5BD4E 100%); box-shadow: 0 18rpx 38rpx rgba(188, 139, 37, 0.20); box-sizing: border-box; position: relative; overflow: hidden; }
    .benefit-glow { width: 200rpx; height: 200rpx; border-radius: 100rpx; position: absolute; top: -75rpx; right: -18rpx; background-color: rgba(255, 255, 255, 0.18); }
    .benefit-head { display: flex; flex-direction: row; justify-content: space-between; align-items: center; position: relative; }
    .customer-name { color: #FFFFFF; font-size: 39rpx; line-height: 55rpx; font-weight: 700; }
    .talent-tag { padding: 9rpx 20rpx; border-radius: 24rpx; color: #FFFFFF; font-size: 22rpx; line-height: 31rpx; font-weight: 600; background-color: rgba(149, 104, 22, 0.46); }
    .customer-phone { display: block; margin-top: 15rpx; color: #FFFFFF; font-size: 29rpx; line-height: 41rpx; font-weight: 600; position: relative; }
    .benefit-line { display: flex; margin-top: 22rpx; flex-direction: row; align-items: center; position: relative; }
    .benefit-label { color: rgba(255, 255, 255, 0.78); font-size: 23rpx; line-height: 33rpx; }
    .benefit-content { margin-left: 18rpx; color: #FFFFFF; font-size: 23rpx; line-height: 33rpx; font-weight: 600; }
    .bottom-space { height: 190rpx; }
    .bottom-actions { display: flex; height: 154rpx; padding: 20rpx 96rpx 34rpx; background-color: #FFFFFF; box-sizing: border-box; position: fixed; right: 0; bottom: 0; left: 0; z-index: 10; }
    .confirm-button { display: flex; flex: 1; height: 94rpx; justify-content: center; align-items: center; border-radius: 48rpx; color: #FFFFFF; font-size: 29rpx; line-height: 41rpx; font-weight: 700; background-image: linear-gradient(90deg, #FF7625 0%, #FF962F 100%); box-shadow: 0 12rpx 24rpx rgba(255, 107, 36, 0.22); }
    .button-disabled { opacity: 0.62; box-shadow: none; }
</style>
rencaika_merchant/pages/verification-detail/verification-detail.uvue
@@ -90,7 +90,7 @@
    .page { display: flex; flex: 1; flex-direction: column; width: 100%; height: 100vh; overflow: hidden; background-image: linear-gradient(180deg, #FFE3C4 0%, #FFF5EA 390rpx, #F6F5F2 760rpx); }
    .navigation-bar { display: flex; height: 174rpx; flex: none; position: relative; box-sizing: border-box; }
    .back-button { display: flex; width: 72rpx; height: 72rpx; justify-content: center; align-items: center; position: absolute; top: 82rpx; left: 28rpx; z-index: 2; }
    .back-icon { width: 40rpx; height: 40rpx; transform: rotate(180deg); }
    .back-icon { width: 40rpx; height: 40rpx; }
    .detail-scroll { flex: 1; height: 0; padding: 10rpx 34rpx 0; box-sizing: border-box; }
    .status-panel { display: flex; min-height: 126rpx; flex-direction: row; justify-content: space-between; align-items: flex-start; }
    .status-copy { display: flex; flex: 1; flex-direction: column; }
rencaika_merchant/pages/verification-success/verification-success.uvue
@@ -1,13 +1,20 @@
<template>
    <view class="page">
        <view class="navigation-bar"><view class="back-button" @tap="returnToScan"><image class="back-icon" src="/static/icons/lucide/back-left.svg" mode="aspectFit"></image></view><text class="navigation-title">核销结果</text></view>
        <view class="result-content"><view class="success-shell"><image class="success-icon" src="/static/icons/lucide/badge-check-orange.svg" mode="aspectFit"></image></view><text class="result-title">核销成功</text><text class="result-message">{{message}}</text><view class="return-button" @tap="returnToScan"><text>继续核销</text></view></view>
        <view class="result-content"><view class="success-shell"><image class="success-icon" src="/static/icons/lucide/badge-check-orange.svg" mode="aspectFit"></image></view><text class="result-title">核销成功</text><text class="result-message">订单号:{{orderNo}}</text><view class="return-button" @tap="viewOrder"><text>查看订单</text></view></view>
    </view>
</template>
<script setup lang="uts">
    const message = ref('本次核销已完成')
    onLoad((options: any) => { if (options != null && options.message != null && ('' + options.message).length > 0) message.value = decodeURIComponent('' + options.message) })
    const orderNo = ref('-')
    onLoad((options: any) => { if (options != null && options.orderNo != null && ('' + options.orderNo).length > 0) orderNo.value = decodeURIComponent('' + options.orderNo) })
    const viewOrder = () => {
        if (orderNo.value == '-' || orderNo.value.length == 0) {
            uni.showToast({ title: '订单号缺失', icon: 'none' })
            return
        }
        uni.navigateTo({ url: '/pages/order-detail/order-detail?orderNo=' + encodeURIComponent(orderNo.value) })
    }
    const returnToScan = () => uni.navigateBack({ delta: 1, fail: () => uni.reLaunch({ url: '/pages/scan/scan' }) })
</script>
@@ -17,11 +24,11 @@
    .navigation-bar { display: flex; height: 176rpx; flex: none; justify-content: center; align-items: flex-end; padding-bottom: 26rpx; box-sizing: border-box; background-color: #FFFFFF; position: relative; }
    .navigation-title { color: #293344; font-size: 34rpx; line-height: 48rpx; font-weight: 700; }
    .back-button { display: flex; width: 72rpx; height: 72rpx; justify-content: center; align-items: center; position: absolute; left: 24rpx; bottom: 14rpx; }
    .back-icon { width: 38rpx; height: 38rpx; transform: rotate(180deg); }
    .back-icon { width: 38rpx; height: 38rpx; }
    .result-content { display: flex; flex: 1; padding: 176rpx 64rpx 0; flex-direction: column; align-items: center; box-sizing: border-box; }
    .success-shell { display: flex; width: 170rpx; height: 170rpx; justify-content: center; align-items: center; border-radius: 85rpx; background-color: #FFF0E7; box-shadow: 0 16rpx 34rpx rgba(255, 107, 44, 0.13); }
    .success-icon { width: 100rpx; height: 100rpx; }
    .result-title { margin-top: 42rpx; color: #273345; font-size: 38rpx; line-height: 54rpx; font-weight: 700; }
    .result-message { margin-top: 20rpx; color: #8A94A3; font-size: 25rpx; line-height: 36rpx; text-align: center; }
    .return-button { display: flex; width: 100%; height: 88rpx; margin-top: 94rpx; justify-content: center; align-items: center; border-radius: 44rpx; color: #FFFFFF; font-size: 29rpx; line-height: 41rpx; font-weight: 700; background-image: linear-gradient(90deg, #FF7625 0%, #FF9630 100%); }
    .return-button { display: flex; width: 240rpx; height: 72rpx; margin-top: 34rpx; justify-content: center; align-items: center; border-width: 1rpx; border-style: solid; border-color: #FFC9A7; border-radius: 38rpx; color: #FF5B3B; font-size: 26rpx; line-height: 37rpx; font-weight: 600; background-color: #FFF8EF; }
</style>
rencaika_merchant/pages/withdrawal-detail/withdrawal-detail.uvue
@@ -4,10 +4,32 @@
        <scroll-view class="form-scroll" scroll-y="true" :show-scrollbar="false" :enhanced="false">
            <view class="available-row"><text class="available-label">当前可提</text><text class="available-value">¥{{availableText}}</text></view>
            <view class="form-card">
                <view class="field-block"><view class="field-row"><text class="field-label">提现金额</text><view class="amount-input-shell" :class="error.length > 0 ? 'field-invalid' : ''"><text class="amount-prefix">¥</text><input class="amount-input" v-model="amount" type="digit" maxlength="10" placeholder="请输入提现金额" placeholder-class="input-placeholder" @input="clearError" /></view></view><text v-if="error.length > 0" class="field-error">{{error}}</text></view>
                <view class="field-block bank-field-block"><view class="field-row bank-field-row"><text class="field-label">提现银行卡</text><view v-if="selected == null" class="select-bank-button" @tap="chooseBank"><text>选择银行卡</text></view><view v-else class="selected-bank-card"><view class="selected-bank-copy"><text class="selected-card-number">{{selected.cardMasked}}</text><text class="selected-bank-name">{{selected.bankName}}</text></view><view class="change-bank-button" @tap="chooseBank"><text>更换</text></view></view></view></view>
                <view class="submit-button" :class="submitting ? 'submit-disabled' : ''" @tap="submit"><text>{{submitting ? '提交中' : '发起提现'}}</text></view>
                <view class="field-block">
                    <view class="field-row">
                        <text class="field-label">提现金额</text>
                        <view class="amount-input-shell" :class="error.length > 0 ? 'field-invalid' : ''">
                            <text class="amount-prefix">¥</text>
                            <input class="amount-input" v-model="amount" type="digit" maxlength="10" placeholder="请输入" placeholder-class="input-placeholder" @input="clearError" />
                        </view>
                    </view>
                    <text v-if="error.length > 0" class="field-error">{{error}}</text>
                </view>
                <view class="field-divider"></view>
                <view class="field-block bank-field-block">
                    <view class="field-row bank-field-row">
                        <text class="field-label">提现银行卡</text>
                        <view class="bank-value">
                            <view v-if="selected != null" class="selected-bank-copy">
                                <text class="selected-bank-name">{{selected.bankName}}</text>
                                <text class="selected-card-number">{{selected.cardMasked}}</text>
                            </view>
                            <text v-else class="bank-placeholder">请选择银行卡</text>
                        </view>
                        <view class="select-bank-button" @tap="chooseBank"><text>{{selected == null ? '选择' : '更换'}}</text></view>
                    </view>
                </view>
            </view>
            <view class="submit-button" :class="submitting ? 'submit-disabled' : ''" @tap="submit"><text>{{submitting ? '提交中' : '发起提现'}}</text></view>
            <view class="safe-space"></view>
        </scroll-view>
    </view>
@@ -54,5 +76,6 @@
</script>
<style>
    page { background-color: #F4F6F9; }.page { display: flex; flex: 1; width: 100%; height: 100vh; flex-direction: column; overflow: hidden; background-color: #F4F6F9; }.navigation-bar { display: flex; height: 174rpx; padding: 68rpx 38rpx 0; flex: none; flex-direction: row; align-items: center; position: relative; box-sizing: border-box; }.back-button { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; position: absolute; left: 38rpx; top: 89rpx; z-index: 2; }.back-icon { width: 38rpx; height: 38rpx; transform: rotate(180deg); }.page-title { width: 100%; color: #202B3E; font-size: 34rpx; line-height: 48rpx; text-align: center; font-weight: 700; }.form-scroll { flex: 1; height: 0; padding: 0 34rpx; box-sizing: border-box; }.available-row { display: flex; height: 88rpx; padding: 0 16rpx; align-items: center; }.available-label { color: #7D8797; font-size: 27rpx; }.available-value { margin-left: 16rpx; color: #FF6B2C; font-size: 30rpx; font-weight: 700; }.form-card { padding: 34rpx 30rpx 36rpx; border-radius: 34rpx; background-color: #FFFFFF; box-shadow: 0 16rpx 38rpx rgba(49,55,66,.06); }.field-block { display: flex; flex-direction: column; }.bank-field-block { margin-top: 34rpx; }.field-row { display: flex; min-height: 94rpx; align-items: center; }.bank-field-row { align-items: flex-start; }.field-label { width: 156rpx; padding-top: 26rpx; flex: none; color: #2C3748; font-size: 28rpx; font-weight: 700; }.amount-input-shell { display: flex; height: 94rpx; padding: 0 24rpx; flex: 1; align-items: center; border: 1rpx solid #E3E7ED; border-radius: 22rpx; background-color: #FFFEFD; box-sizing: border-box; }.amount-prefix { color: #2C3748; font-size: 29rpx; font-weight: 700; }.amount-input { height: 92rpx; margin-left: 10rpx; flex: 1; color: #2C3748; font-size: 29rpx; }.input-placeholder { color: #B5BDC9; font-size: 27rpx; }.field-invalid { border-color: #F0746D; background-color: #FFF9F8; }.field-error { margin: 10rpx 0 0 156rpx; color: #E95B55; font-size: 23rpx; }.select-bank-button { display: flex; min-width: 190rpx; height: 74rpx; padding: 0 28rpx; margin-top: 10rpx; align-items: center; justify-content: center; border: 2rpx solid #FFC3A4; border-radius: 38rpx; color: #FF6B2C; background-color: #FFF7F2; font-size: 26rpx; font-weight: 700; }.selected-bank-card { display: flex; min-height: 118rpx; padding: 22rpx 20rpx 20rpx 24rpx; flex: 1; align-items: center; border-radius: 24rpx; color: #FFFFFF; background-image: linear-gradient(110deg,#FF8D37 0%,#FF6D2C 100%); box-shadow: 0 12rpx 24rpx rgba(255,107,44,.18); box-sizing: border-box; }.selected-bank-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; }.selected-card-number { color: #FFFFFF; font-size: 27rpx; font-weight: 700; }.selected-bank-name { margin-top: 8rpx; color: rgba(255,255,255,.88); font-size: 23rpx; }.change-bank-button { display: flex; width: 86rpx; height: 56rpx; margin-left: 16rpx; flex: none; align-items: center; justify-content: center; border: 1rpx solid rgba(255,255,255,.78); border-radius: 28rpx; color: #FFFFFF; font-size: 23rpx; font-weight: 700; }.submit-button { display: flex; width: 100%; height: 88rpx; margin-top: 54rpx; align-items: center; justify-content: center; border-radius: 44rpx; color: #FFFFFF; background-image: linear-gradient(90deg,#FF7625 0%,#FF9630 100%); font-size: 29rpx; font-weight: 700; box-shadow: 0 12rpx 24rpx rgba(255,107,36,.20); }.submit-disabled { opacity: .58; }.safe-space { height: 70rpx; }
    page { background-color: #F4F6F9; }.page { display: flex; flex: 1; width: 100%; height: 100vh; flex-direction: column; overflow: hidden; background-color: #F4F6F9; }.navigation-bar { display: flex; height: 174rpx; padding: 68rpx 38rpx 0; flex: none; flex-direction: row; align-items: center; position: relative; box-sizing: border-box; }.back-button { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; position: absolute; left: 38rpx; top: 89rpx; z-index: 2; }.back-icon { width: 38rpx; height: 38rpx; transform: rotate(180deg); }.page-title { width: 100%; color: #202B3E; font-size: 34rpx; line-height: 48rpx; text-align: center; font-weight: 700; }.form-scroll { flex: 1; height: 0; padding: 0 34rpx; box-sizing: border-box; }.available-row { display: flex; height: 72rpx; padding: 0 12rpx; align-items: center; }.available-label { color: #7D8797; font-size: 25rpx; }.available-value { margin-left: 12rpx; color: #2C3748; font-size: 27rpx; font-weight: 700; }.form-card { padding: 6rpx 28rpx; border-radius: 16rpx; background-color: #FFFFFF; box-shadow: 0 8rpx 24rpx rgba(49,55,66,.05); }.field-block { display: flex; flex-direction: column; }.bank-field-block { margin-top: 0; }.field-row { display: flex; min-height: 104rpx; align-items: center; }.bank-field-row { align-items: center; }.field-label { width: 178rpx; padding-top: 0; flex: none; color: #5F6978; font-size: 27rpx; font-weight: 400; }.amount-input-shell { display: flex; height: 70rpx; padding: 0 18rpx; flex: 1; align-items: center; border: 1rpx solid #E3E7ED; border-radius: 8rpx; background-color: #FFFFFF; box-sizing: border-box; }.amount-prefix { color: #8B94A2; font-size: 27rpx; font-weight: 400; }.amount-input { height: 68rpx; margin-left: 8rpx; flex: 1; color: #2C3748; font-size: 27rpx; }.input-placeholder { color: #B5BDC9; font-size: 25rpx; }.field-invalid { border-color: #F0746D; background-color: #FFF9F8; }.field-error { margin: 0 0 14rpx 178rpx; color: #E95B55; font-size: 22rpx; }.field-divider { height: 1rpx; margin-left: 178rpx; background-color: #EEF0F3; }.bank-value { display: flex; min-width: 0; flex: 1; }.bank-placeholder { color: #B5BDC9; font-size: 25rpx; }.selected-bank-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; }.selected-bank-name { color: #2C3748; font-size: 25rpx; }.selected-card-number { margin-top: 4rpx; color: #8A93A1; font-size: 22rpx; }.select-bank-button { display: flex; width: 84rpx; height: 52rpx; padding: 0; margin: 0 0 0 14rpx; flex: none; align-items: center; justify-content: center; border: 1rpx solid #58A5F8; border-radius: 8rpx; color: #318CF0; background-color: #FFFFFF; font-size: 23rpx; font-weight: 400; }.submit-button { display: flex; width: 360rpx; height: 72rpx; margin: 34rpx auto 0; align-items: center; justify-content: center; border-radius: 8rpx; color: #FFFFFF; background-color: #409EFF; font-size: 27rpx; font-weight: 500; box-shadow: none; }.submit-disabled { opacity: .58; }.safe-space { height: 70rpx; }
    .back-icon { transform: none; }
</style>
rencaika_merchant/pages/withdrawal-success/withdrawal-success.uvue
@@ -35,7 +35,7 @@
    .page { display: flex; flex: 1; width: 100%; height: 100vh; flex-direction: column; overflow: hidden; background-color: #F4F6F9; }
    .navigation-bar { display: flex; height: 174rpx; padding: 68rpx 38rpx 0; flex: none; flex-direction: row; align-items: center; position: relative; background-color: #F4F6F9; box-sizing: border-box; }
    .back-button { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; position: absolute; left: 38rpx; top: 89rpx; z-index: 2; }
    .back-icon { width: 38rpx; height: 38rpx; transform: rotate(180deg); }
    .back-icon { width: 38rpx; height: 38rpx; }
    .page-title { width: 100%; color: #202B3E; font-size: 34rpx; line-height: 48rpx; text-align: center; font-weight: 700; }
    .result-content { display: flex; flex: 1; padding: 112rpx 64rpx 0; flex-direction: column; align-items: center; box-sizing: border-box; }
    .success-icon-shell { display: flex; width: 170rpx; height: 170rpx; justify-content: center; align-items: center; border-radius: 85rpx; background-color: #FFF0E7; box-shadow: 0 16rpx 34rpx rgba(255, 107, 44, 0.13); }
rencaika_merchant/services/orders.uts
@@ -12,6 +12,9 @@
    customerMobile: string
    cardLevel: string
    orderAmount: number
    basePayAmount?: number
    couponAmount?: number
    talentDiscountAmount?: number
    payAmount: number
    subsidyAmount: number
    createdAt: string
@@ -26,6 +29,9 @@
}
export type MerchantOrderDetail = MerchantOrder & {
    basePayAmount: number
    couponAmount: number
    talentDiscountAmount: number
    benefitName: string
    merchantName: string
    canRevoke: boolean
@@ -95,6 +101,9 @@
    customerMobile: value != null && value.customerMobile != null ? '' + value.customerMobile : '',
    cardLevel: value != null && value.cardLevel != null ? '' + value.cardLevel : '卓越人才卡',
    orderAmount: numberValue(value != null ? value.orderAmount : 0),
    basePayAmount: numberValue(value != null ? value.basePayAmount : 0),
    couponAmount: numberValue(value != null ? value.couponAmount : 0),
    talentDiscountAmount: numberValue(value != null ? value.talentDiscountAmount : 0),
    payAmount: numberValue(value != null ? value.payAmount : 0),
    subsidyAmount: numberValue(value != null ? value.subsidyAmount : 0),
    createdAt: value != null && value.createdAt != null ? '' + value.createdAt : '',
@@ -105,6 +114,9 @@
    const order = toOrder(value)
    return {
        ...order,
        basePayAmount: order.basePayAmount != null ? order.basePayAmount : order.orderAmount,
        couponAmount: order.couponAmount != null ? order.couponAmount : 0,
        talentDiscountAmount: order.talentDiscountAmount != null ? order.talentDiscountAmount : 0,
        benefitName: value != null && value.benefitName != null ? '' + value.benefitName : order.title,
        merchantName: value != null && value.merchantName != null ? '' + value.merchantName : '',
        canRevoke: value != null && value.canRevoke == true,
talent-card-server/src/main/java/com/talentcard/server/business/merchant/ManualConsumptionService.java
@@ -68,16 +68,13 @@
            return createdOrder(existingOrderId);
        }
        ensurePending(source);
        if (expired(source.get("expiresAt"))) {
            throw new BusinessException(ErrorCode.CONFLICT, "人才码已失效,请重新扫码");
        }
        Map<String, Object> rule = rule(principal.merchantId(), string(source.get("cardLevel")), request.ruleId());
        Map<String, Object> rule = rule(principal.merchantId(), string(source.get("cardLevel")), request.ruleId());
        Map<String, Object> coupon = usableCoupon(principal.merchantId(), number(source.get("talentId")), string(source.get("cardLevel")), request.couponId(), discountedAmount(rule, request.amount()));
        Map<String, Object> amounts = quote(rule, coupon, request.amount());
        Long dynamicCodeId = number(source.get("dynamicCodeId"));
        int codeUpdated = jdbc.update("""
                update talent_dynamic_code set status = 1, merchant_id = ?, used_at = now()
                where id = ? and status = 0 and expires_at > now()
                where id = ? and status = 0
                """, principal.merchantId(), dynamicCodeId);
        if (codeUpdated == 0) {
            throw new BusinessException(ErrorCode.CONFLICT, "人才码已被使用或已失效");
@@ -128,10 +125,7 @@
        if (number(source.get("attemptStatus")) != ATTEMPT_PENDING) {
            throw new BusinessException(ErrorCode.CONFLICT, "该人才码已处理,请重新扫码");
        }
        if (expired(source.get("expiresAt"))) {
            throw new BusinessException(ErrorCode.CONFLICT, "人才码已失效,请重新扫码");
        }
    }
    }
    private List<Map<String, Object>> rules(Long merchantId, String cardLevel) {
        List<Map<String, Object>> configured = configuredRules(merchantId, cardLevel);
@@ -336,6 +330,10 @@
                .map(benefit -> string(benefit.get("benefitText"))).findFirst().orElse("以当前商户到店消费规则为准");
    }
    String benefitTextForMerchant(Long merchantId, String cardLevel) {
        return benefitText(merchantId, cardLevel);
    }
    private boolean matchesCardLevel(String configured, String actual) {
        return normalizedCardLevel(configured).equals(normalizedCardLevel(actual));
    }
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
@@ -4,6 +4,7 @@
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Duration;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -13,6 +14,7 @@
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.business.portal.ProductPriceCalculator;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -21,12 +23,14 @@
@Service
public class MerchantWorkService {
    private static final long REVOCATION_HOURS = 24;
    private static final long REVOCATION_HOURS = 3;
    private final JdbcTemplate jdbcTemplate;
    private final ManualConsumptionService manualConsumptionService;
    public MerchantWorkService(JdbcTemplate jdbcTemplate) {
    public MerchantWorkService(JdbcTemplate jdbcTemplate, ManualConsumptionService manualConsumptionService) {
        this.jdbcTemplate = jdbcTemplate;
        this.manualConsumptionService = manualConsumptionService;
    }
    @Transactional(readOnly = true)
@@ -41,8 +45,8 @@
        arguments.addAll(filters.arguments());
        String recordSql = """
                select o.id, o.order_no, tu.name as talent_name, tu.mobile as talent_mobile, tc.card_level,
                       b.benefit_name, o.order_type, o.order_amount, o.subsidy_amount, o.pay_amount,
                select o.id, o.order_no, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender, tc.card_level,
                       b.benefit_name, o.product_name_snapshot, o.order_type, o.order_amount, o.subsidy_amount, o.pay_amount,
                       o.order_status, o.verify_status, o.created_at, verified.verify_time
                from benefit_order o
                left join talent_user tu on tu.id = o.talent_id
@@ -65,9 +69,10 @@
    @Transactional(readOnly = true)
    public Map<String, Object> order(String orderNo, Long merchantId, Long storeId) {
        List<Map<String, Object>> rows = query("""
                select o.id, o.order_no, o.talent_id, tu.name as talent_name, tu.mobile as talent_mobile,
                select o.id, o.order_no, o.talent_id, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender,
                       o.card_id, tc.card_no, tc.card_level, o.benefit_id, b.benefit_name,
                       o.merchant_id, m.merchant_name, o.order_type, o.order_amount,
                       o.merchant_id, m.merchant_name, o.product_id, o.product_name_snapshot, p.rights_config,
                       o.order_type, o.order_amount, o.base_pay_amount, o.coupon_amount,
                       o.subsidy_amount, o.pay_amount, o.order_status, o.verify_status, o.created_at,
                       vr.verify_time, vr.status as verify_record_status
                from benefit_order o
@@ -75,6 +80,7 @@
                left join talent_card tc on tc.id = o.card_id
                left join benefit b on b.id = o.benefit_id
                left join merchant m on m.id = o.merchant_id
                left join merchant_product p on p.id = o.product_id and p.deleted = 0
                left join verify_record vr on vr.order_id = o.id and vr.status = 1
                where o.deleted = 0 and o.order_no = ? and o.merchant_id = ? and (o.store_id is null or o.store_id = ?)
                """, orderNo, merchantId, storeId);
@@ -206,9 +212,15 @@
        result.put("category", category);
        result.put("status", status);
        result.put("statusText", "pending_payment".equals(status) ? "待支付" : ("completed".equals(status) ? "已完成" : "待核销"));
        result.put("customerName", maskName(String.valueOf(order.get("talentName"))));
        result.put("customerName", displayName(order.get("talentName"), number(order.get("gender"))));
        result.put("customerMobile", maskMobile(String.valueOf(order.get("talentMobile"))));
        result.put("title", order.get("benefitName") == null ? ("store".equals(category) ? "到店消费订单" : "团购订单") : String.valueOf(order.get("benefitName")));
        result.put("title", orderTitle(order, category));
        result.put("benefitName", currentBenefitText(order, category));
        long orderAmount = number(order.get("orderAmount")) == null ? 0 : number(order.get("orderAmount"));
        long basePayAmount = number(order.get("basePayAmount")) == null ? orderAmount : number(order.get("basePayAmount"));
        long couponAmount = number(order.get("couponAmount")) == null ? 0 : number(order.get("couponAmount"));
        result.put("talentDiscountAmount", Math.max(0, orderAmount - basePayAmount));
        result.put("couponAmount", Math.max(0, couponAmount));
        LocalDateTime verifyTime = time(order.get("verifyTime"));
        boolean canRevoke = "GROUP_BUY".equals(orderType) && verifyTime != null && Duration.between(verifyTime, LocalDateTime.now()).toHours() < REVOCATION_HOURS && verifyStatus != null && verifyStatus == 1;
        result.put("canRevoke", canRevoke);
@@ -268,10 +280,53 @@
        result.put("category", category);
        result.put("status", status);
        result.put("statusText", statusText(status));
        result.put("customerName", maskName(String.valueOf(order.get("talentName"))));
        result.put("customerName", displayName(order.get("talentName"), number(order.get("gender"))));
        result.put("customerMobile", maskMobile(String.valueOf(order.get("talentMobile"))));
        result.put("title", order.get("benefitName") == null ? categoryTitle(category) : String.valueOf(order.get("benefitName")));
        result.put("title", orderTitle(order, category));
        return result;
    }
    private String orderTitle(Map<String, Object> order, String category) {
        String snapshot = text(order.get("productNameSnapshot"));
        if (!snapshot.isBlank()) return snapshot;
        String benefit = text(order.get("benefitName"));
        return benefit.isBlank() ? categoryTitle(category) : benefit;
    }
    private String currentBenefitText(Map<String, Object> order, String category) {
        String cardLevel = text(order.get("cardLevel"));
        if ("store".equals(category)) {
            Long merchantId = number(order.get("merchantId"));
            return merchantId == null ? "以当前商户到店消费规则为准" : manualConsumptionService.benefitTextForMerchant(merchantId, cardLevel);
        }
        if ("group".equals(category)) {
            ProductPriceCalculator.ProductRight right = ProductPriceCalculator.productRight(order.get("rightsConfig"), cardLevel);
            if (right != null) return productBenefitText(right);
        }
        String configured = text(order.get("benefitName"));
        return configured.isBlank() ? "以当前订单权益规则为准" : configured;
    }
    private String productBenefitText(ProductPriceCalculator.ProductRight right) {
        String discountText = discountText(right.discount());
        if (!right.free()) return discountText.isBlank() ? "以商品配置为准" : "按市价" + discountText;
        String freeText = right.limitCount() > 0 ? "免费" + right.limitCount() + "次/" + cycleText(right.resetCycle()) : "免费";
        return discountText.isBlank() ? freeText : freeText + ",超出次数按市价" + discountText;
    }
    private String discountText(BigDecimal discount) {
        if (discount == null || discount.signum() <= 0) return "";
        return discount.stripTrailingZeros().toPlainString() + "折";
    }
    private String cycleText(String cycle) {
        String value = normalize(cycle).toUpperCase();
        return switch (value) {
            case "DAY", "DAILY", "每天" -> "天";
            case "WEEK", "WEEKLY", "每周" -> "周";
            case "MONTH", "MONTHLY", "每月" -> "月";
            default -> "年";
        };
    }
    private String category(String orderType) {
@@ -304,6 +359,18 @@
        return value.length() == 1 ? value + "*" : value.substring(0, 1) + "*".repeat(value.length() - 1);
    }
    private String displayName(Object rawName, Long gender) {
        String name = text(rawName);
        if (name.isBlank()) return "";
        if (gender != null && gender == 1) return name.substring(0, 1) + "先生";
        if (gender != null && gender == 2) return name.substring(0, 1) + "女士";
        return maskName(name);
    }
    private String text(Object value) {
        return value == null || "null".equals(String.valueOf(value)) ? "" : String.valueOf(value).trim();
    }
    private String maskMobile(String value) {
        if (value == null || value.length() < 7 || "null".equals(value)) return "";
        return value.substring(0, 3) + "****" + value.substring(value.length() - 4);
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -5,6 +5,7 @@
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -122,12 +123,19 @@
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> policies(int page, int size) {
        return page("""
                select id, policy_no, title, subtitle, category_name, cover_url, tags, author, source, view_count, is_top, publish_time
        PageResult<Map<String, Object>> result = page("""
                select id, policy_no, title, subtitle, category_name, cover_url, tags, author, source, content, view_count, is_top, publish_time
                from policy_article
                where deleted = 0 and status = 1
                order by is_top desc, sort_no desc, publish_time desc, id desc
                """, "select count(*) from policy_article where deleted = 0 and status = 1", page, size);
        List<Map<String, Object>> records = result.records().stream().map(row -> {
            Map<String, Object> item = new LinkedHashMap<>(row);
            item.put("contentPreview", htmlToText((String) item.get("content")));
            item.remove("content");
            return item;
        }).toList();
        return new PageResult<>(records, result.page(), result.size(), result.total());
    }
    @Transactional
@@ -634,7 +642,7 @@
    public Map<String, Object> merchantDetail(Long id, Long talentId) {
        Map<String, Object> merchant = one("""
                select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                       business_status, longitude, latitude, group_buying_enabled, usage_instructions, rights_text,
                       business_status, longitude, latitude, group_buying_enabled, usage_instructions, rights_text, merchant_rights_config,
                       service_label, status, is_recommend, sort_no
                from merchant
                where deleted = 0 and id = ?
@@ -643,7 +651,100 @@
        merchant.put("groupBuyingEnabled", ((Number) merchant.getOrDefault("groupBuyingEnabled", 1)).intValue() == 1);
        merchant.put("couponAvailable", hasActiveMerchantCoupon(id));
        merchant.put("discountText", merchantLowestTalentDiscount(id, talentId));
        String cardLevel = activeProductCardLevel(talentId);
        Map<String, Object> configuredRight = matchingMerchantRight(merchant.get("merchantRightsConfig"), cardLevel);
        if (configuredRight != null) {
            merchant.put("rightsTag", textOrDefault(configuredRight.get("tag"), ""));
            merchant.put("rightsText", textOrDefault(configuredRight.get("detail"), textOrDefault(merchant.get("rightsText"), "")));
            merchant.put("applicableCards", textOrDefault(configuredRight.get("tag"), cardLevelText(cardLevel) + "可用"));
        }
        merchant.put("usageInstructions", merchantUsageInstructions(
                id, merchant.get("merchantRightsConfig"), cardLevel, merchant.get("usageInstructions")));
        List<String> heroImages = new ArrayList<>();
        String coverUrl = textOrDefault(merchant.get("coverUrl"), "");
        if (!coverUrl.isBlank()) heroImages.add(coverUrl);
        for (Map<String, Object> product : query("""
                select cover_url
                from merchant_product
                where merchant_id = ? and deleted = 0 and status = 1 and audit_status = 2
                  and cover_url is not null and cover_url <> ''
                order by sort_no desc, id desc
                limit 8
                """, id)) {
            String imageUrl = textOrDefault(product.get("coverUrl"), "");
            if (!imageUrl.isBlank() && !heroImages.contains(imageUrl)) heroImages.add(imageUrl);
        }
        merchant.put("heroImages", heroImages);
        return merchant;
    }
    private Map<String, Object> matchingMerchantRight(Object rightsConfig, String cardLevel) {
        if (cardLevel == null) return null;
        for (Map<String, Object> right : merchantRightsConfigs(rightsConfig)) {
            if (booleanValue(right.get("enabled")) && sameCardLevel(right.get("level"), cardLevel)) return right;
        }
        return null;
    }
    private String cardLevelText(String cardLevel) {
        return switch (normalizedCardLevel(cardLevel)) {
            case "EXCELLENT" -> "卓越卡";
            case "OUTSTANDING" -> "杰出卡";
            case "ELITE" -> "菁英卡";
            default -> "人才卡";
        };
    }
    private String merchantUsageInstructions(Long merchantId, Object rightsConfig, String cardLevel, Object fallback) {
        if (cardLevel != null) {
            for (Map<String, Object> right : merchantRightsConfigs(rightsConfig)) {
                if (!booleanValue(right.get("enabled")) || !booleanValue(right.get("consumeEnabled"))
                        || !booleanValue(right.get("storeConsume")) || !sameCardLevel(right.get("level"), cardLevel)) continue;
                String description = textOrDefault(right.get("consumeDesc"), "");
                if (!description.isBlank()) return description;
            }
            List<Map<String, Object>> benefits = query("""
                    select card_level, benefit_text
                    from merchant_card_benefit
                    where merchant_id = ? and status = 1
                    order by sort_no desc, id desc
                    """, merchantId);
            for (Map<String, Object> benefit : benefits) {
                if (sameCardLevel(benefit.get("cardLevel"), cardLevel)) {
                    String description = textOrDefault(benefit.get("benefitText"), "");
                    if (!description.isBlank()) return description;
                }
            }
        }
        return textOrDefault(fallback, "以当前商户到店消费规则为准");
    }
    private List<Map<String, Object>> merchantRightsConfigs(Object value) {
        if (value == null || String.valueOf(value).isBlank()) return List.of();
        try {
            return OBJECT_MAPPER.readValue(String.valueOf(value), new TypeReference<List<Map<String, Object>>>() {});
        } catch (Exception ignored) {
            return List.of();
        }
    }
    private boolean booleanValue(Object value) {
        if (value instanceof Boolean bool) return bool;
        if (value instanceof Number number) return number.intValue() != 0;
        String text = value == null ? "" : String.valueOf(value).trim();
        return "true".equalsIgnoreCase(text) || "1".equals(text) || "yes".equalsIgnoreCase(text);
    }
    private boolean sameCardLevel(Object configured, String actual) {
        return normalizedCardLevel(configured).equals(normalizedCardLevel(actual));
    }
    private String normalizedCardLevel(Object value) {
        String level = value == null ? "" : String.valueOf(value).trim().toUpperCase(Locale.ROOT);
        if (level.contains("卓越") || "EXCELLENT".equals(level)) return "EXCELLENT";
        if (level.contains("杰出") || "OUTSTANDING".equals(level)) return "OUTSTANDING";
        if (level.contains("菁英") || "ELITE".equals(level)) return "ELITE";
        return level;
    }
    private boolean hasActiveMerchantCoupon(Long merchantId) {
@@ -666,20 +767,15 @@
                from merchant_product
                where merchant_id = ? and deleted = 0 and status = 1 and audit_status = 2
                """, merchantId)) {
            BigDecimal originalPrice = decimal(product.get("originalPrice"));
            if (originalPrice.signum() <= 0) originalPrice = decimal(product.get("salePrice"));
            if (originalPrice.signum() <= 0) continue;
            ProductPriceCalculator.ProductPrice price = ProductPriceCalculator.calculate(
                    originalPrice, product.get("salePrice"), product.get("rightsConfig"), cardLevel,
                    usedFreeCount(talentId, product.get("id"), product.get("rightsConfig"), cardLevel));
            if (!price.personalized()) continue;
            BigDecimal rate = price.salePrice().divide(originalPrice, 6, RoundingMode.HALF_UP);
            ProductPriceCalculator.ProductRight right = ProductPriceCalculator.productRight(
                    product.get("rightsConfig"), cardLevel);
            if (right == null || right.discount().signum() <= 0) continue;
            BigDecimal rate = right.discount().divide(BigDecimal.TEN, 6, RoundingMode.HALF_UP);
            if (lowestRate == null || rate.compareTo(lowestRate) < 0) {
                lowestRate = rate;
            }
        }
        if (lowestRate == null || lowestRate.compareTo(BigDecimal.ONE) >= 0) return "";
        if (lowestRate.signum() == 0) return "免费";
        double discount = Math.round(lowestRate.doubleValue() * 100) / 10.0;
        return discount + "折";
    }
@@ -713,7 +809,9 @@
        BigDecimal personalizedPrice = personalizedProductPrice(product, talentId);
        product.put("salePrice", personalizedPrice);
        product.put("personalized", talentId != null && personalizedPrice.compareTo(configuredSalePrice) != 0);
        product.put("discountText", discountText(personalizedPrice, decimal(product.get("originalPrice"))));
        Long merchantId = ((Number) product.get("merchantId")).longValue();
        product.put("couponAvailable", hasActiveMerchantCoupon(merchantId));
        product.put("discountText", merchantLowestTalentDiscount(merchantId, talentId));
        return product;
    }
talent-card-server/src/main/java/com/talentcard/server/business/portal/ProductPriceCalculator.java
@@ -9,7 +9,7 @@
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
final class ProductPriceCalculator {
public final class ProductPriceCalculator {
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
    private static final TypeReference<List<Map<String, Object>>> RIGHTS_TYPE = new TypeReference<>() {};
@@ -45,13 +45,15 @@
        return new ProductPrice(original, calculatedSale.setScale(2, RoundingMode.HALF_UP), right != null);
    }
    static ProductRight productRight(Object rightsConfig, Object cardLevel) {
    public static ProductRight productRight(Object rightsConfig, Object cardLevel) {
        Map<String, Object> right = matchingRight(rightsConfig, cardLevel);
        if (right == null) return null;
        boolean free = booleanValue(right.get("free"));
        return new ProductRight(
                booleanValue(right.get("free")),
                free,
                intValue(right.get("limitCount")),
                text(right.get("resetCycle")));
                text(right.get("resetCycle")),
                discountValue(right.get(free ? "exceedDiscount" : "discount")));
    }
    private static Map<String, Object> matchingRight(Object rightsConfig, Object cardLevel) {
@@ -133,6 +135,6 @@
    record ProductPrice(BigDecimal originalPrice, BigDecimal salePrice, boolean personalized) {
    }
    record ProductRight(boolean free, int limitCount, String resetCycle) {
    public record ProductRight(boolean free, int limitCount, String resetCycle, BigDecimal discount) {
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/portal/TalentCardPortalService.java
@@ -68,7 +68,8 @@
        List<Map<String, Object>> cards = query("""
                select c.id as card_id, c.card_no, c.card_level, c.valid_start, c.valid_end, c.status as card_status,
                       c.is_family_card_enabled, c.family_card_limit,
                       t.id as talent_id, t.name, t.gender, t.work_unit, t.status as talent_status
                       t.id as talent_id, t.name, t.gender, t.work_unit, t.status as talent_status,
                       t.advisor_id, t.service_staff_id
                from talent_card c
                join talent_user t on t.id = c.talent_id and t.deleted = 0
                where c.deleted = 0 and c.talent_id = ? and c.card_type = 'PERSONAL'
@@ -97,10 +98,17 @@
        card.put("familyCardLimit", ((Number) row.getOrDefault("familyCardLimit", 0)).intValue());
        result.put("hasTalentCard", true);
        result.put("card", card);
        advisor(level).ifPresent(advisor -> {
            String consultUrl = value(advisor.get("consultUrl"), "");
            advisor.put("consultUrl", isAllowedExternalWebUrl(consultUrl) ? consultUrl : "");
            result.put("advisor", advisor);
        java.util.Optional<Map<String, Object>> advisor = serviceStaff(row.get("serviceStaffId") instanceof Number staffId ? staffId.longValue() : null);
        if (advisor.isEmpty()) {
            advisor = advisor(row.get("advisorId") instanceof Number advisorId ? advisorId.longValue() : null);
        }
        if (advisor.isEmpty()) {
            advisor = advisor(level);
        }
        advisor.ifPresent(item -> {
            String consultUrl = value(item.get("consultUrl"), "");
            item.put("consultUrl", isAllowedExternalWebUrl(consultUrl) ? consultUrl : "");
            result.put("advisor", item);
        });
        return result;
    }
@@ -287,6 +295,42 @@
        return java.util.Optional.of(result);
    }
    private java.util.Optional<Map<String, Object>> advisor(Long advisorId) {
        if (advisorId == null) return java.util.Optional.empty();
        List<Map<String, Object>> advisors = query("""
                select advisor_name, department, mobile, consult_url
                from talent_card_advisor
                where id = ? and status = 1
                limit 1
                """, advisorId);
        if (advisors.isEmpty()) return java.util.Optional.empty();
        Map<String, Object> row = advisors.get(0);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("name", row.get("advisorName"));
        result.put("department", row.get("department"));
        result.put("mobile", row.get("mobile"));
        result.put("consultUrl", row.get("consultUrl"));
        return java.util.Optional.of(result);
    }
    private java.util.Optional<Map<String, Object>> serviceStaff(Long staffId) {
        if (staffId == null) return java.util.Optional.empty();
        List<Map<String, Object>> staff = query("""
                select display_name, position_name, mobile
                from admin_operator
                where id = ? and deleted = 0 and status = 1
                limit 1
                """, staffId);
        if (staff.isEmpty()) return java.util.Optional.empty();
        Map<String, Object> row = staff.get(0);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("name", row.get("displayName"));
        result.put("department", value(row.get("positionName"), ""));
        result.put("mobile", row.get("mobile"));
        result.put("consultUrl", "");
        return java.util.Optional.of(result);
    }
    private boolean isExcellenceCardLevel(String level) {
        if (level == null) return false;
        String normalized = level.trim().toUpperCase(Locale.ROOT);
talent-card-server/src/main/resources/db/migration/V108__sync_admin_protocol_settings_to_agreements.sql
talent-card-server/src/test/java/com/talentcard/server/TalentCardServerApplicationTests.java
@@ -63,7 +63,7 @@
    void adminCanPublishNewsForThePortalAndContentIsSanitized() {
        Map<String, Object> draft = adminBusinessService.createNews(new NewsArticleRequest(
                "后台发布资讯", "用于联调", "", "东区发布", "管理员",
                "<p>安全正文</p><script>alert('x')</script>", true, 12, 0));
                "<p>安全正文</p><script>alert('x')</script>", true, 12, 0), "测试管理员");
        Long id = ((Number) draft.get("id")).longValue();
        assertThat(portalService.news(1, 10).total()).isZero();