shinnosuke
2026-08-26 6a972c54fe99a186b5885db1b1319643ce9679dd
又又更新
2个文件已添加
52个文件已修改
804 ■■■■■ 已修改文件
rencaika/pages/benefits/benefits.uvue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/card-application-conditions/card-application-conditions.uvue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/children-entrustment/children-entrustment.uvue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/merchant/merchant.uvue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/premium-benefits/premium-benefits.uvue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/product-detail/product-detail.uvue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/shopping-benefits/shopping-benefits.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/spouse-employment-form/spouse-employment-form.uvue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/services/portal.uts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/bank-card-select/bank-card-select.uvue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/benefits/benefits.uvue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/business-hours/business-hours.uvue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/merchant/merchant.uvue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/scan/scan.uvue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/style.css 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/types/admin.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/BusinessManagementView.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/DashboardView.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/FuelServiceDetailView.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/FuelServiceView.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/HotelReservationDetailView.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/HotelReservationView.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MedicalReservationDetailView.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantOnboardingDetailView.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MovieTicketView.vue 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/OrderDetailView.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/RoleFormView.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/SpouseEmploymentDetailView.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/SpouseEmploymentView.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/StaffManagementView.vue 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessController.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java 137 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementService.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/BankBranchDirectoryInitializer.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantManagementService.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/order/OrderService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalController.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/TalentCardPortalService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/DisplayServiceMapper.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/MerchantDisplayPolicyMerchantMapper.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/TicketPackageCodeMapper.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/TicketPackageMapper.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/TransportCarWashServiceMapper.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/mapper/TransportParkingCardLevelRightMapper.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/request/TicketPackageSaveRequest.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/application.yml 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V122__merchant_bank_card_application_history.sql 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V123__merchant_product_audit_history.sql 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/benefits/benefits.uvue
@@ -43,7 +43,7 @@
                    <image class="merchant-image" :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image>
                    <view class="benefit-info">
                        <text class="benefit-name">{{item.name}}</text>
                        <view class="tag-row"><text class="coupon-tag">券</text><text class="discount-tag">{{item.discount}}</text></view>
                        <view v-if="item.hasCoupon || item.discount != ''" class="tag-row"><text v-if="item.hasCoupon" class="coupon-tag">券</text><text v-if="item.discount != ''" :class="item.hasCoupon ? 'discount-tag' : 'discount-tag no-coupon'">{{item.discount}}</text></view>
                        <view class="address-row"><image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text></view>
                    </view>
                </view>
@@ -68,6 +68,7 @@
    import { isApiConfigured } from '../../config/api.uts'
    import { getMerchants } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    const navTop = ref(28)
    const navHeight = ref(32)
@@ -75,6 +76,7 @@
    const navRight = ref(10)
    const isSolidNav = ref(false)
    const fallbackImage = ''
    const talentId = ref('')
    const replaceImage = (item: any) => { if (item.image != '') item.image = '' }
    onLoad((options: any) => {
@@ -91,6 +93,8 @@
            failInitialOnce.value = previewState == 'initialError'
            failLoadMoreOnce.value = previewState == 'loadMoreError'
        }
        const session = getSession()
        if (session != null && session.talentId != null) talentId.value = '' + session.talentId
        loadInitialBenefits()
    })
@@ -130,12 +134,6 @@
    const loadMoreError = ref(false)
    const failInitialOnce = ref(false)
    const failLoadMoreOnce = ref(false)
    const formatDiscount = (item: any): string => {
        const sale = item.salePrice != null ? Number(item.salePrice) : 0
        const original = item.originalPrice != null ? Number(item.originalPrice) : 0
        if (sale > 0 && original > 0 && sale < original) return '' + Math.round(sale / original * 100) / 10 + '折'
        return '人才专享'
    }
    const mapMerchants = (records: any[]): any[] => {
        const mapped: any[] = []
        for (let index = 0; index < records.length; index++) {
@@ -146,7 +144,8 @@
                id: '' + item.id,
                name: item.merchantName != null ? item.merchantName as string : '人才权益商户',
                merchantType: merchantType,
                discount: formatDiscount(item),
                hasCoupon: item.couponAvailable === true || Number(item.couponAvailable) == 1,
                discount: item.discountText != null ? item.discountText as string : '',
                address: item.address != null ? item.address as string : '',
                image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage
            })
@@ -161,7 +160,7 @@
        hasLoaded.value = false
        currentPage.value = 0
        if (isApiConfigured()) {
            getMerchants(1, 4, '', (data: any) => {
            getMerchants(1, 4, '', talentId.value, (data: any) => {
                const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []
                visibleBenefits.value = mapMerchants(records)
                loadedBenefitCount.value = records.length
@@ -241,7 +240,7 @@
        loadMoreError.value = false
        loadingMore.value = true
        if (isApiConfigured()) {
            getMerchants(currentPage.value + 1, 4, '', (data: any) => {
            getMerchants(currentPage.value + 1, 4, '', talentId.value, (data: any) => {
                const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []
                const next = visibleBenefits.value.slice()
                const mapped = mapMerchants(records)
@@ -320,7 +319,7 @@
    .benefit-name { color: #292929; font-size: 29rpx; line-height: 40rpx; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .tag-row { flex-direction: row; align-items: center; }
    .coupon-tag { padding: 6rpx 8rpx; border-radius: 6rpx; background-color: #FFF0EC; color: #FF5D37; font-size: 21rpx; }
    .discount-tag { margin-left: 11rpx; padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF5E7; color: #FF7C2B; font-size: 21rpx; }
    .discount-tag { margin-left: 11rpx; padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF5E7; color: #FF7C2B; font-size: 21rpx; }.discount-tag.no-coupon { margin-left: 0; }
    .address-row { display: flex; min-width: 0; box-sizing: border-box; padding: 7rpx 10rpx; border-radius: 7rpx; background-image: linear-gradient(90deg, rgba(239,240,242,.98) 0%, rgba(242,243,245,.76) 58%, rgba(255,255,255,0) 100%); flex-direction: row; align-items: center; }
    .address-row image { width: 27rpx; height: 27rpx; margin-right: 9rpx; flex-shrink: 0; }
    .address-row text { flex: 1; color: #7E8490; font-size: 21rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
rencaika/pages/card-application-conditions/card-application-conditions.uvue
@@ -115,7 +115,7 @@
        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'
        if (/^\d+\.\s*[^((]+[。.]$/.test(line)) return 'condition-line numbered-heading'
        if (/^\d+\.\s*/.test(line)) return 'condition-line numbered-heading'
        return 'condition-line'
    }
    const goBack = () => safeBack('/pages/card/card')
@@ -125,7 +125,7 @@
    .page { display: flex; width: 100%; height: 100vh; background-color: #FFF9F3; flex-direction: column; }
    .custom-nav { flex-shrink: 0; padding-bottom: 24rpx; background-image: linear-gradient(180deg, #FFE5AB 0%, #FFF4DC 100%); }
    .safe-space { height: 28px; } .nav-row { display: flex; height: 32px; box-sizing: border-box; padding-left: 26rpx; padding-right: 10px; position: relative; align-items: center; justify-content: space-between; }
    .back-button { display: flex; width: 60rpx; height: 60rpx; margin-left: -8rpx; justify-content: center; align-items: center; } .back-button image { width: 38rpx; height: 38rpx; } .back-button-active { opacity: .55; }
    .back-button { display: flex; width: 60rpx; height: 60rpx; position: absolute; left: 18rpx; top: 50%; margin: 0; transform: translateY(-50%); justify-content: center; align-items: center; z-index: 2; } .back-button image { width: 38rpx; height: 38rpx; } .back-button-active { opacity: .55; }
    .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; }
@@ -135,6 +135,6 @@
    .conditions-content .card-heading:first-child { margin-top: 0; }
    .recipient-heading { margin: 15rpx 0 22rpx; color: #E84B35; font-weight: 700; text-align: center; }
    .category-heading { margin-top: 19rpx; color: #34383E; font-weight: 700; text-align: center; }
    .numbered-heading { margin-top: 19rpx; color: #34383E; font-weight: 700; }
    .numbered-heading { margin-top: 19rpx; color: #222222; font-weight: 700; }
    .content-bottom { height: 52rpx; height: calc(52rpx + env(safe-area-inset-bottom)); }
</style>
rencaika/pages/children-entrustment/children-entrustment.uvue
@@ -11,7 +11,7 @@
        </view>
        <view class="content-sheet">
            <view class="title-block"><text class="page-title">{{benefitTitle}}</text><view class="talent-tag"><text>{{applicableCards}}</text></view></view>
            <view class="title-block"><text class="page-title">{{benefitTitle}}</text><view v-if="applicableCards != ''" class="talent-tag"><text>{{applicableCards}}</text></view></view>
            <view class="tab-bar">
                <view class="tab" :class="activeTab == 0 ? 'active' : ''" @tap="switchTab(0)">
                    <text>使用说明</text><view class="tab-line"></view>
@@ -29,7 +29,7 @@
                    <view v-if="schoolLoaded && schools.length > 0" class="school-panel">
                        <view class="school-table-header"><view class="school-head-name"><text>学校名称</text></view><view class="school-head-action"></view></view>
                    <view class="school-row" v-for="item in schools" :key="item.id">
                        <view :class="item.detailAvailable ? 'school-name-cell' : 'school-name-cell is-disabled'" hover-class="school-name-cell-active" @tap="openSchoolDetail(item)"><text>{{item.name}}</text></view>
                        <view :class="item.detailAvailable ? 'school-name-cell' : 'school-name-cell is-disabled'" hover-class="school-name-cell-active" @tap="openSchoolDetail(item)"><text class="school-name-text">{{item.name}}</text></view>
                        <view class="school-actions">
                            <view :class="canNavigate(item) ? 'school-action' : 'school-action is-disabled'" @tap="openLocation(item)"><image src="/static/premium-assets/school-location.svg" mode="aspectFit"></image></view>
                            <view v-if="item.phone != ''" class="school-action" @tap="callSchool(item)"><image src="/static/premium-assets/school-phone.svg" mode="aspectFit"></image></view>
@@ -99,7 +99,7 @@
        benefitError.value = false
        getChildrenEntrustmentBenefit(serviceId.value, talentId.value, (data: any) => {
            benefitTitle.value = data.title || '子女托管'
            applicableCards.value = data.applicableCards || '以服务说明为准'
            applicableCards.value = data.applicableCards != null ? data.applicableCards as string : ''
            heroImage.value = data.heroImage != null && data.heroImage != '' ? resolveApiUrl(data.heroImage as string) : ''
            description.value = data.rightsText || '暂无使用说明'
            benefitLoading.value = false
@@ -164,7 +164,7 @@
    .school-head-name text { width: 100%; color: #26374F; font-size: 23rpx; font-weight: 700; text-align: left; }
    .school-row { display: flex; height: 92rpx; min-height: 92rpx; border-top-width: 1rpx; border-top-style: solid; border-top-color: #E7ECF4; flex-direction: row; align-items: center; }
    .school-name-cell { display: flex; min-width: 0; height: 92rpx; flex: 1; box-sizing: border-box; padding: 0 22rpx; align-items: center; justify-content: flex-start; }
    .school-name-cell > text:first-child { min-width:0; flex:1; color:#48566A; font-size:21rpx; line-height:31rpx; text-align:left; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
    .school-name-text { display:block; width:100%; min-width:0; height:92rpx; flex:1; color:#48566A; font-size:21rpx; line-height:92rpx; text-align:left; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
    .school-name-cell-active { background-color:#F5F8FD; }
    .school-actions { display: flex; width: 126rpx; height: 92rpx; flex-direction: row; align-items: center; justify-content: space-evenly; flex-shrink: 0; }
    .school-action { display: flex; width: 46rpx; height: 46rpx; box-sizing: border-box; border-width: 2rpx; border-style: solid; border-color: #B7D6FF; border-radius: 50%; background-color: #F1F7FF; justify-content: center; align-items: center; }
rencaika/pages/merchant/merchant.uvue
@@ -33,9 +33,9 @@
                    </view>
                    <view v-else class="merchant-title-row">
                        <text class="merchant-title">{{merchantName}}</text>
                        <view class="merchant-tags"><text v-if="merchantHasCoupon" class="coupon-tag">券</text><text v-if="merchantDiscount != ''" class="discount-tag">{{merchantDiscount}}</text><text v-if="isHotelMerchant && merchantConsumeDiscount != ''" class="discount-tag">{{merchantConsumeDiscount}}</text></view>
                        <view class="merchant-tags"><text v-if="merchantHasCoupon && !isFuelMerchant" class="coupon-tag">券</text><text v-if="merchantDiscount != '' && !isFuelMerchant" class="discount-tag">{{merchantDiscount}}</text><text v-if="isHotelMerchant && merchantConsumeDiscount != ''" class="discount-tag">{{merchantConsumeDiscount}}</text></view>
                    </view>
                    <view v-if="merchantServiceLabel != '' || applicableCards != ''" class="service-tags"><text v-if="!isParkingMerchant && !isMedicalMerchant && merchantServiceLabel != ''">{{merchantServiceLabel}}</text><text v-if="applicableCards != ''">{{applicableCards}}</text></view>
                    <view v-if="(!isFuelMerchant && !isHotelMerchant && merchantServiceLabel != '') || applicableCards != ''" class="service-tags"><text v-if="!isParkingMerchant && !isMedicalMerchant && !isFuelMerchant && !isHotelMerchant && merchantServiceLabel != ''">{{merchantServiceLabel}}</text><text v-if="applicableCards != ''">{{applicableCards}}</text></view>
                    <view v-if="!isParkingMerchant && merchantType != 'apartment'" class="merchant-address-row">
                        <image class="address-icon" src="/static/benefits-assets/map-pin.svg"></image>
                        <text>{{merchantAddress}}</text>
@@ -84,10 +84,10 @@
                    <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>
                    <view v-else-if="listLoaded && applicableStores.length == 0" class="list-state"><text>暂无适用门店</text></view>
                    <view v-if="listLoaded && applicableStores.length > 0" class="store-table-header"><view class="store-head-name"><text>适用门店名称</text></view><view class="store-head-address"><text>详细地址</text></view><view class="store-head-action"></view></view>
                    <view v-if="listLoaded && applicableStores.length > 0" class="store-table-header"><view class="store-head-name"><text>{{isParkingMerchant ? '停车场名称' : '适用门店名称'}}</text></view><view class="store-head-address"><text>详细地址</text></view><view class="store-head-action"></view></view>
                    <view class="store-row" v-for="item in applicableStores" :key="item.id">
                        <view class="store-name-cell"><text>{{item.name}}</text></view>
                        <view class="store-address-cell"><text>{{item.address}}</text></view>
                        <view class="store-name-cell"><view class="store-cell-content"><text>{{item.name}}</text></view></view>
                        <view class="store-address-cell"><view class="store-cell-content"><text>{{item.address}}</text></view></view>
                        <view :class="item.canNavigate === false ? 'store-location is-disabled' : 'store-location'" @tap="openStoreLocation(item)"><image src="/static/merchant-assets/store-pin-blue.svg" mode="aspectFit"></image></view>
                    </view>
                </view>
@@ -986,9 +986,10 @@
    .store-head-action { width: 72rpx; height: 67rpx; flex-shrink: 0; }
    .store-head-name text, .store-head-address text { width: 100%; color: #26374F; font-size: 23rpx; font-weight: 700; text-align: left; }
    .store-row { display: flex; min-height: 92rpx; border-top-width: 1rpx; border-top-style: solid; border-top-color: #E7ECF4; flex-direction: row; align-items: stretch; }
    .store-name-cell { display: flex; width: 250rpx; box-sizing: border-box; padding: 17rpx 13rpx; border-right-width: 1rpx; border-right-style: solid; border-right-color: #E7ECF4; align-items: center; justify-content: flex-start; }
    .store-address-cell { display: flex; min-width: 0; flex: 1; box-sizing: border-box; padding: 17rpx 13rpx; align-items: center; justify-content: flex-start; }
    .store-name-cell text, .store-address-cell text { width: 100%; color: #48566A; font-size: 21rpx; line-height: 31rpx; text-align: left; }
    .store-name-cell { display: flex; width: 250rpx; min-height: 92rpx; box-sizing: border-box; border-right-width: 1rpx; border-right-style: solid; border-right-color: #E7ECF4; align-items: stretch; justify-content: flex-start; }
    .store-address-cell { display: flex; min-width: 0; min-height: 92rpx; flex: 1; box-sizing: border-box; align-items: stretch; justify-content: flex-start; }
    .store-cell-content { display: flex; width: 100%; min-width: 0; min-height: 92rpx; box-sizing: border-box; padding: 13rpx; flex-direction: row; align-items: center; }
    .store-cell-content text { width: 100%; color: #48566A; font-size: 21rpx; line-height: 31rpx; text-align: left; }
    .store-location { display: flex; width: 72rpx; flex-shrink: 0; justify-content: center; align-items: center; }
    .store-location image { width: 34rpx; height: 34rpx; box-sizing: border-box; padding: 4rpx; border-width: 2rpx; border-style: solid; border-color: #8BB9FF; border-radius: 50%; background-color: #EEF6FF; }
    .store-location.is-disabled { opacity: .4; }
rencaika/pages/premium-benefits/premium-benefits.uvue
@@ -5,7 +5,7 @@
            <view class="hero"><image class="hero-image" :src="activeScene.image" mode="aspectFill" @error="replaceImage(activeScene)"></image><view class="hero-top-shade"></view><view class="hero-bottom-shade"></view><text class="scene-name">{{activeScene.name}}</text><scroll-view class="scene-scroll" scroll-x :show-scrollbar="false" scroll-with-animation :scroll-left="sceneScrollLeft" @scrollend="handleSceneScrollEnd"><view class="scene-row" :style="'width:' + sceneTrackWidth + 'rpx'"><view class="scene-pad"></view><view v-for="(item, index) in scenes" :key="item.id" class="scene-item" :class="index == activeIndex ? 'active' : ''" @tap="selectScene(index)"><image :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image></view><view class="scene-pad"></view></view></scroll-view></view>
            <view v-if="topicError" class="topic-error"><text>专题配置加载失败</text><text @tap="loadPremiumTopic">重新加载</text></view>
            <view v-if="topicDescription != ''" class="topic-summary"><view class="intro-card"><text>{{topicDescription}}</text></view></view>
            <view class="service-section"><scroll-view class="tab-scroll" scroll-x :show-scrollbar="false"><view class="service-tabs" :style="'width:' + Math.max(750, serviceTabs.length * 190) + 'rpx'"><view v-for="tab in serviceTabs" :key="tab.id" class="service-tab" :class="tab.id == activeTab ? 'active' : ''" @tap="selectTab(tab.id)"><text>{{tab.name}}</text><view class="tab-line"></view></view></view></scroll-view>
            <view class="service-section"><scroll-view class="tab-scroll" scroll-x :show-scrollbar="false"><view class="service-tabs" :style="'width:' + Math.max(750, serviceTabs.length * 234) + 'rpx'"><view v-for="tab in serviceTabs" :key="tab.id" class="service-tab" :class="tab.id == activeTab ? 'active' : ''" @tap="selectTab(tab.id)"><text>{{tab.name}}</text><view class="tab-line"></view></view></view></scroll-view>
                <view v-if="initialLoading" class="service-empty"><view class="loading-spinner"></view><text>正在加载臻享服务</text></view><view v-else-if="initialError" class="service-empty error-state"><text>臻享服务加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view><view v-else-if="hasLoaded && visibleServices.length == 0" class="service-empty"><text>暂无相关臻享服务</text></view><view v-else-if="hasLoaded" class="service-list"><view v-for="item in visibleServices" :key="item.id" class="service-item" :class="item.available ? '' : 'is-unavailable'" @tap="openService(item)"><image class="service-image" :src="item.image" mode="aspectFill" @error="replaceImage(item)"></image><view class="service-info"><text class="service-name">{{item.name}}</text><text class="service-benefit">{{item.available ? item.benefit : '已停用'}}</text><view class="address-row"><image src="/static/benefits-assets/map-pin.svg" mode="aspectFit"></image><text>{{item.address}}</text></view></view></view></view>
                <view v-if="loadingMore" class="loading-row"><view class="loading-spinner"></view><text>正在加载</text></view><view v-else-if="loadMoreError" class="loading-row load-error" @tap="retryLoadMore"><text>加载失败,点击重试</text></view><view v-else-if="hasLoaded && !hasMore && visibleServices.length > 0" class="no-more"><view></view><text>已经到底了</text><view></view></view>
            </view><view class="bottom-space"></view>
@@ -50,7 +50,7 @@
    const loadServices = (append: boolean) => {
        if (!isApiConfigured() || activeTab.value == '') { initialLoading.value = false; loadingMore.value = false; initialError.value = true; return }; const requestVersion = listRequestVersion.value
        if (!append && failInitialOnce.value) { failInitialOnce.value = false; initialLoading.value = false; initialError.value = true; return }; if (append && failLoadMoreOnce.value) { failLoadMoreOnce.value = false; loadingMore.value = false; loadMoreError.value = true; return }
        getPremiumTopicServices(activeTab.value, currentPage.value + 1, 4, (data: any) => { if (requestVersion != listRequestVersion.value) return; const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []; const next = append ? visibleServices.value.slice() : []; for (let index = 0; index < records.length; index++) { const item = records[index]; next.push({ id: '' + item.id, businessType: item.businessType != null ? item.businessType as string : 'MERCHANT', merchantType: item.merchantType != null ? item.merchantType as string : 'service', name: item.merchantName != null ? item.merchantName as string : '臻享服务', benefit: activeTab.value == '免费体检' || activeTab.value == '配偶推荐就业' ? '免费' : (item.benefitText != null ? item.benefitText as string : '人才专享'), address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false }) }; visibleServices.value = next; currentPage.value++; hasMore.value = data != null && data.total != null ? next.length < data.total : false; initialLoading.value = false; loadingMore.value = false; hasLoaded.value = true }, () => { if (requestVersion != listRequestVersion.value) return; initialLoading.value = false; loadingMore.value = false; if (append) loadMoreError.value = true; else initialError.value = true })
        getPremiumTopicServices(activeTab.value, currentPage.value + 1, 4, (data: any) => { if (requestVersion != listRequestVersion.value) return; const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []; const next = append ? visibleServices.value.slice() : []; for (let index = 0; index < records.length; index++) { const item = records[index]; next.push({ id: '' + item.id, businessType: item.businessType != null ? item.businessType as string : 'MERCHANT', merchantType: item.merchantType != null ? item.merchantType as string : 'service', name: item.merchantName != null ? item.merchantName as string : '臻享服务', benefit: activeTab.value == '免费体检' || activeTab.value == '配偶推荐就业' || item.businessType == 'ADMIN_APPROVAL' ? '免费' : (item.benefitText != null ? item.benefitText as string : '人才专享'), address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false }) }; visibleServices.value = next; currentPage.value++; hasMore.value = data != null && data.total != null ? next.length < data.total : false; initialLoading.value = false; loadingMore.value = false; hasLoaded.value = true }, () => { if (requestVersion != listRequestVersion.value) return; initialLoading.value = false; loadingMore.value = false; if (append) loadMoreError.value = true; else initialError.value = true })
    }
    const loadMore = () => { if (initialLoading.value || loadingMore.value || !hasMore.value) return; loadMoreError.value = false; loadingMore.value = true; loadServices(true) }
    const goBack = () => safeBack('/pages/benefits/benefits')
@@ -68,6 +68,6 @@
<style>
    @font-face { font-family:'FoodCalligraphy'; src:url('/static/fonts/food-calligraphy-subset.ttf') format('truetype'); }.page,.page-scroll { width:100%; height:100vh; background-color:#FFFFFF; }.custom-nav { position:fixed; z-index:20; left:0; right:0; top:0; padding-bottom:20rpx; }.nav-bg { position:absolute; left:0; right:0; top:0; bottom:0; opacity:0; background-image:linear-gradient(180deg,#F26A38,#EE542B); transition:opacity 280ms ease-out; }.custom-nav.is-solid .nav-bg { opacity:1; }.safe-space,.nav-row { position:relative; z-index:1; }.safe-space { height:28px; }.nav-row { display:flex; box-sizing:border-box; padding-left:24rpx; flex-direction:row; align-items:center; justify-content:space-between; }.back-button { display:flex; width:54rpx; height:54rpx; position:relative; z-index:2; border-radius:50%; background-color:rgba(45,45,45,.52); justify-content:center; align-items:center; }.custom-nav.is-solid .back-button { background-color:transparent; }.back-button image { width:38rpx; height:38rpx; }.nav-title { position:absolute; z-index:1; left:0; right:0; color:#FFFFFF; font-size:31rpx; font-weight:500; text-align:center; text-shadow:0 2rpx 8rpx rgba(0,0,0,.35); }.capsule-space { height:100%; flex-shrink:0; }
    .hero { height:730rpx; position:relative; overflow:visible; background-color:#7296B5; }.hero-image { position:absolute; left:0; top:0; width:100%; height:730rpx; }.hero-top-shade { position:absolute; left:0; right:0; top:0; height:245rpx; background-image:linear-gradient(180deg,rgba(0,0,0,.48),rgba(0,0,0,0)); }.hero-bottom-shade { position:absolute; left:0; right:0; bottom:0; height:230rpx; background-image:linear-gradient(180deg,rgba(0,0,0,0),rgba(0,0,0,.40)); }.scene-name { position:absolute; z-index:3; left:38rpx; top:318rpx; color:#FFFFFF; font-family:'FoodCalligraphy'; font-size:48rpx; line-height:66rpx; text-shadow:0 3rpx 10rpx rgba(0,0,0,.55); }.scene-scroll { position:absolute; z-index:4; left:0; right:0; top:469rpx; width:100%; height:150rpx; white-space:nowrap; }.scene-row { display:flex; height:150rpx; flex-direction:row; align-items:center; }.scene-pad { width:322rpx; height:1rpx; flex-shrink:0; }.scene-item { display:flex; width:106rpx; height:106rpx; margin-right:31rpx; box-sizing:border-box; overflow:hidden; position:relative; z-index:1; border:3rpx solid rgba(255,255,255,.9); border-radius:8rpx; flex-shrink:0; transition:transform 180ms ease-out; }.scene-item image { width:100%; height:100%; }.scene-item.active { z-index:3; border-width:4rpx; border-color:#FFD329; transform:scale(1.28); box-shadow:0 8rpx 18rpx rgba(0,0,0,.28); }
    .topic-error { display:flex; height:76rpx; padding:0 31rpx; background-color:#FFF8F4; flex-direction:row; justify-content:center; align-items:center; }.topic-error text { color:#A48A7A; font-size:22rpx; }.topic-error text + text { margin-left:22rpx; color:#F26A38; }.topic-summary { position:relative; z-index:4; margin-top:-66rpx; }.intro-card { margin:0 31rpx; box-sizing:border-box; padding:25rpx 31rpx; border-radius:26rpx; background-image:linear-gradient(90deg,#FF5320,#FF8A22); box-shadow:0 10rpx 22rpx rgba(255,104,28,.24); }.intro-card text { color:#FFFFFF; font-size:23rpx; line-height:34rpx; }.service-section { padding:48rpx 31rpx 0; }.tab-scroll { width:100%; height:78rpx; white-space:nowrap; }.service-tabs { display:flex; height:72rpx; flex-direction:row; align-items:flex-start; }.service-tab { display:flex; width:148rpx; margin-right:42rpx; flex-direction:column; align-items:center; flex-shrink:0; }.service-tab text { color:#8A8D92; font-size:28rpx; font-weight:600; }.service-tab.active text { color:#363636; font-weight:700; }.tab-line { width:0; height:7rpx; margin-top:12rpx; border-radius:4rpx; background-image:linear-gradient(90deg,#FF4D36,#FFBF3A); transition:width 180ms ease-out; }.service-tab.active .tab-line { width:61rpx; }
    .topic-error { display:flex; height:76rpx; padding:0 31rpx; background-color:#FFF8F4; flex-direction:row; justify-content:center; align-items:center; }.topic-error text { color:#A48A7A; font-size:22rpx; }.topic-error text + text { margin-left:22rpx; color:#F26A38; }.topic-summary { position:relative; z-index:4; margin-top:-66rpx; }.intro-card { margin:0 31rpx; box-sizing:border-box; padding:25rpx 31rpx; border-radius:26rpx; background-image:linear-gradient(90deg,#FF5320,#FF8A22); box-shadow:0 10rpx 22rpx rgba(255,104,28,.24); }.intro-card text { color:#FFFFFF; font-size:23rpx; line-height:34rpx; }.service-section { padding:48rpx 31rpx 0; }.tab-scroll { width:100%; height:78rpx; white-space:nowrap; }.service-tabs { display:flex; height:72rpx; flex-direction:row; align-items:flex-start; }.service-tab { display:flex; width:210rpx; margin-right:24rpx; flex-direction:column; align-items:center; flex-shrink:0; }.service-tab text { color:#8A8D92; font-size:28rpx; font-weight:600; white-space:nowrap; }.service-tab.active text { color:#363636; font-weight:700; }.tab-line { width:0; height:7rpx; margin-top:12rpx; border-radius:4rpx; background-image:linear-gradient(90deg,#FF4D36,#FFBF3A); transition:width 180ms ease-out; }.service-tab.active .tab-line { width:61rpx; }
    .service-list { padding-top:10rpx; }.service-item { display:flex; min-height:215rpx; margin-bottom:22rpx; flex-direction:row; align-items:flex-start; }.service-item.is-unavailable { opacity:.55; }.service-image { width:184rpx; height:184rpx; border-radius:17rpx; flex-shrink:0; background-color:#EEEEEE; }.service-info { display:flex; min-width:0; min-height:184rpx; flex:1; box-sizing:border-box; padding:4rpx 0 4rpx 24rpx; flex-direction:column; justify-content:space-between; }.service-name { color:#292929; font-size:29rpx; line-height:40rpx; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.service-benefit { align-self:flex-start; max-width:400rpx; padding:6rpx 9rpx; border-radius:6rpx; background-color:#FFF5E7; color:#FF7C2B; font-size:21rpx; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.address-row { display:flex; min-width:0; box-sizing:border-box; padding:7rpx 10rpx; border-radius:7rpx; background-image:linear-gradient(90deg,rgba(239,240,242,.98),rgba(242,243,245,.76) 58%,rgba(255,255,255,0)); flex-direction:row; align-items:flex-start; }.address-row image { width:27rpx; height:27rpx; margin-right:8rpx; flex-shrink:0; }.address-row text { min-width:0; flex:1; max-height:62rpx; overflow:hidden; color:#7E8490; font-size:21rpx; line-height:31rpx; }.service-empty { display:flex; height:310rpx; justify-content:center; align-items:center; }.service-empty text { color:#A5A8AD; font-size:25rpx; }.error-state { flex-direction:column; }.error-state .retry-text { margin-top:22rpx; color:#F26A38; }.loading-row,.no-more { display:flex; height:90rpx; flex-direction:row; justify-content:center; align-items:center; }.loading-spinner { width:28rpx; height:28rpx; margin-right:13rpx; box-sizing:border-box; border:3rpx solid #FFD6C4; border-top-color:#FF6C2B; border-radius:50%; animation:premium-loading 720ms linear infinite; }.loading-row text { color:#989DA5; font-size:22rpx; }.load-error text { color:#F26A38; }.no-more view { width:70rpx; height:1rpx; background-color:#E7E7E7; }.no-more text { margin:0 18rpx; color:#B1B3B7; font-size:21rpx; }.bottom-space { height:calc(30rpx + env(safe-area-inset-bottom)); }@keyframes premium-loading { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
</style>
rencaika/pages/product-detail/product-detail.uvue
@@ -24,7 +24,7 @@
            <view v-if="detailText.length > 0" class="detail-card">
                <text class="card-title">商品详情</text>
                <text class="detail-description">{{detailText}}</text>
                <rich-text class="detail-description" :nodes="detailText"></rich-text>
            </view>
            <view class="notice-card">
@@ -47,6 +47,7 @@
<script setup lang="uts">
    import { isApiConfigured } from '../../config/api.uts'
    import { getProductDetail } from '../../services/portal.uts'
    import { sanitizeRichText } from '../../services/business.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { safeBack } from '../../utils/navigation.uts'
@@ -70,7 +71,7 @@
    const productAvailable = ref(true)
    const productSoldOut = ref(false)
    // 后台分别返回图片数组与文字结构,不使用富文本 HTML。
    // Sanitize and render backend-configured product details as rich text.
    const detailText = ref('')
    // 购买须知固定只有四项,后台只返回四项各自的内容文本。
@@ -130,7 +131,7 @@
            productAvailable.value = data == null || data.available !== false
            productSoldOut.value = data != null && (data.soldOut === true || Number(data.stock) == 0)
            const rawDetailText = data != null && data.detailText != null ? data.detailText as string : ''
            detailText.value = stripDetailMarkup(rawDetailText)
            detailText.value = sanitizeRichText(rawDetailText)
            purchaseNotices.value = buildPurchaseNotices(data != null ? data.purchaseNotice : null)
        }, () => {})
    }
@@ -161,12 +162,6 @@
        return next
    }
    const stripDetailMarkup = (value: string): string => {
        if (value == '') return ''
        let text = value.replace(/<img[^>]*>/gi, '')
        text = text.replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>/gi, '\n').replace(/<[^>]+>/g, '')
        return text.replace(/&nbsp;/gi, ' ').replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>').trim()
    }
    const goBack = () => safeBack('/pages/benefits/benefits')
    const handleScroll = (event: any) => {
        const shouldShow = event.detail.scrollTop > 64
rencaika/pages/shopping-benefits/shopping-benefits.uvue
@@ -6,7 +6,7 @@
            <view v-if="topicError" class="topic-error"><text>专题配置加载失败</text><text @tap="loadShoppingTopic">重新加载</text></view>
            <view v-if="topicDescription != ''" class="topic-summary"><view class="intro-card"><text>{{topicDescription}}</text></view></view>
            <view class="service-section">
                <scroll-view class="tab-scroll" scroll-x :show-scrollbar="false"><view class="service-tabs" :style="'width:' + Math.max(750, serviceTabs.length * 186) + 'rpx'"><view v-for="tab in serviceTabs" :key="tab.id" class="service-tab" :class="tab.id == activeTab ? 'active' : ''" @tap="selectTab(tab.id)"><text>{{tab.name}}</text><view class="tab-line"></view></view></view></scroll-view>
                <scroll-view class="tab-scroll" scroll-x :show-scrollbar="false"><view class="service-tabs" :style="'width:' + Math.max(750, serviceTabs.length * 210) + 'rpx'"><view v-for="tab in serviceTabs" :key="tab.id" class="service-tab" :class="tab.id == activeTab ? 'active' : ''" @tap="selectTab(tab.id)"><text>{{tab.name}}</text><view class="tab-line"></view></view></view></scroll-view>
                <view v-if="initialLoading" class="service-empty"><view class="loading-spinner"></view><text>正在加载购物服务</text></view>
                <view v-else-if="initialError" class="service-empty error-state"><text>购物服务加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view>
                <view v-else-if="hasLoaded && visibleServices.length == 0" class="service-empty"><text>暂无相关购物服务</text></view>
@@ -105,6 +105,6 @@
    @font-face { font-family:'FoodCalligraphy'; src:url('/static/fonts/food-calligraphy-subset.ttf') format('truetype'); }
    .page,.page-scroll { width:100%; height:100vh; background-color:#FFFFFF; }.custom-nav { position:fixed; z-index:20; left:0; right:0; top:0; padding-bottom:20rpx; }.nav-bg { position:absolute; left:0; right:0; top:0; bottom:0; opacity:0; background-image:linear-gradient(180deg,#F26A38,#EE542B); transition:opacity 280ms ease-out; }.custom-nav.is-solid .nav-bg { opacity:1; }.safe-space,.nav-row { position:relative; z-index:1; }.safe-space { height:28px; }.nav-row { display:flex; box-sizing:border-box; padding-left:24rpx; flex-direction:row; align-items:center; justify-content:space-between; }.back-button { display:flex; width:54rpx; height:54rpx; position:relative; z-index:2; border-radius:50%; background-color:rgba(45,45,45,.52); justify-content:center; align-items:center; }.custom-nav.is-solid .back-button { background-color:transparent; }.back-button image { width:38rpx; height:38rpx; }.nav-title { position:absolute; z-index:1; left:0; right:0; color:#FFFFFF; font-size:31rpx; font-weight:500; text-align:center; text-shadow:0 2rpx 8rpx rgba(0,0,0,.35); }.capsule-space { height:100%; flex-shrink:0; }
    .hero { height:730rpx; position:relative; overflow:visible; background-color:#1C315E; }.hero-image { position:absolute; left:0; top:0; width:100%; height:730rpx; }.hero-top-shade { position:absolute; left:0; right:0; top:0; height:245rpx; background-image:linear-gradient(180deg,rgba(0,0,0,.62),rgba(0,0,0,0)); }.hero-bottom-shade { position:absolute; left:0; right:0; bottom:0; height:230rpx; background-image:linear-gradient(180deg,rgba(0,0,0,0),rgba(0,0,0,.42)); }.scene-name { position:absolute; z-index:3; left:38rpx; top:318rpx; color:#FFFFFF; font-family:'FoodCalligraphy'; font-size:48rpx; line-height:66rpx; text-shadow:0 3rpx 10rpx rgba(0,0,0,.55); }.scene-scroll { position:absolute; z-index:4; left:0; right:0; top:469rpx; width:100%; height:150rpx; white-space:nowrap; }.scene-row { display:flex; height:150rpx; flex-direction:row; align-items:center; }.scene-pad { width:322rpx; height:1rpx; flex-shrink:0; }.scene-item { display:flex; width:106rpx; height:106rpx; margin-right:31rpx; box-sizing:border-box; overflow:hidden; position:relative; z-index:1; border:3rpx solid rgba(255,255,255,.9); border-radius:8rpx; flex-shrink:0; transition:transform 180ms ease-out; }.scene-item image { width:100%; height:100%; }.scene-item.active { z-index:3; border-width:4rpx; border-color:#FFD329; transform:scale(1.28); box-shadow:0 8rpx 18rpx rgba(0,0,0,.28); }
    .topic-error { display:flex; height:76rpx; padding:0 31rpx; background-color:#FFF8F4; flex-direction:row; justify-content:center; align-items:center; }.topic-error text { color:#A48A7A; font-size:22rpx; }.topic-error text + text { margin-left:22rpx; color:#F26A38; }.topic-summary { position:relative; z-index:4; margin-top:-66rpx; }.intro-card { margin:0 31rpx; box-sizing:border-box; padding:25rpx 31rpx; border-radius:26rpx; background-image:linear-gradient(90deg,#FF5320,#FF8A22); box-shadow:0 10rpx 22rpx rgba(255,104,28,.24); }.intro-card text { color:#FFFFFF; font-size:23rpx; line-height:34rpx; }.service-section { padding:32rpx 31rpx 0; }.tab-scroll { width:100%; height:78rpx; white-space:nowrap; }.service-tabs { display:flex; height:72rpx; flex-direction:row; align-items:flex-start; }.service-tab { display:flex; width:144rpx; margin-right:42rpx; flex-direction:column; align-items:center; flex-shrink:0; }.service-tab text { color:#8A8D92; font-size:29rpx; font-weight:600; }.service-tab.active text { color:#363636; font-weight:700; }.tab-line { width:0; height:7rpx; margin-top:12rpx; border-radius:4rpx; background-image:linear-gradient(90deg,#FF4D36,#FFBF3A); transition:width 180ms ease-out; }.service-tab.active .tab-line { width:61rpx; }
    .topic-error { display:flex; height:76rpx; padding:0 31rpx; background-color:#FFF8F4; flex-direction:row; justify-content:center; align-items:center; }.topic-error text { color:#A48A7A; font-size:22rpx; }.topic-error text + text { margin-left:22rpx; color:#F26A38; }.topic-summary { position:relative; z-index:4; margin-top:-66rpx; }.intro-card { margin:0 31rpx; box-sizing:border-box; padding:25rpx 31rpx; border-radius:26rpx; background-image:linear-gradient(90deg,#FF5320,#FF8A22); box-shadow:0 10rpx 22rpx rgba(255,104,28,.24); }.intro-card text { color:#FFFFFF; font-size:23rpx; line-height:34rpx; }.service-section { padding:32rpx 31rpx 0; }.tab-scroll { width:100%; height:78rpx; white-space:nowrap; }.service-tabs { display:flex; height:72rpx; flex-direction:row; align-items:flex-start; }.service-tab { display:flex; width:180rpx; margin-right:30rpx; flex-direction:column; align-items:center; flex-shrink:0; }.service-tab text { color:#8A8D92; font-size:29rpx; line-height:40rpx; font-weight:600; white-space:nowrap; }.service-tab.active text { color:#363636; font-weight:700; }.tab-line { width:0; height:7rpx; margin-top:12rpx; border-radius:4rpx; background-image:linear-gradient(90deg,#FF4D36,#FFBF3A); transition:width 180ms ease-out; }.service-tab.active .tab-line { width:61rpx; }
    .service-list { padding-top:10rpx; }.service-item { display:flex; min-height:215rpx; margin-bottom:22rpx; flex-direction:row; align-items:flex-start; }.service-item.is-unavailable { opacity:.55; }.service-image { width:184rpx; height:184rpx; border-radius:17rpx; flex-shrink:0; background-color:#EEEEEE; }.service-info { display:flex; min-width:0; min-height:184rpx; flex:1; box-sizing:border-box; padding:4rpx 0 4rpx 24rpx; flex-direction:column; justify-content:space-between; }.service-name { color:#292929; font-size:29rpx; line-height:40rpx; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.service-discount { align-self:flex-start; max-width:400rpx; padding:6rpx 9rpx; border-radius:6rpx; background-color:#FFF5E7; color:#FF7C2B; font-size:21rpx; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.service-tags { display:flex; min-height:38rpx; flex-direction:row; align-items:center; }.service-tags text { margin-right:9rpx; padding:5rpx 9rpx; border-radius:5rpx; font-size:21rpx; line-height:28rpx; white-space:nowrap; }.coupon-tag { background-color:#FFF0EC; color:#FF5D37; }.service-tag { background-color:#FFF5E7; color:#FF7C2B; }.address-row { display:flex; min-width:0; box-sizing:border-box; padding:7rpx 10rpx; border-radius:7rpx; background-image:linear-gradient(90deg,rgba(239,240,242,.98),rgba(242,243,245,.76) 58%,rgba(255,255,255,0)); flex-direction:row; align-items:flex-start; }.address-row image { width:27rpx; height:27rpx; margin-right:8rpx; flex-shrink:0; }.address-row text { min-width:0; flex:1; color:#7E8490; font-size:21rpx; line-height:31rpx; max-height:62rpx; overflow:hidden; }.service-empty { display:flex; height:310rpx; justify-content:center; align-items:center; }.service-empty text { color:#A5A8AD; font-size:25rpx; }.error-state { flex-direction:column; }.error-state .retry-text { margin-top:22rpx; color:#F26A38; }.loading-row,.no-more { display:flex; height:90rpx; flex-direction:row; justify-content:center; align-items:center; }.loading-spinner { width:28rpx; height:28rpx; margin-right:13rpx; box-sizing:border-box; border:3rpx solid #FFD6C4; border-top-color:#FF6C2B; border-radius:50%; animation:shopping-loading 720ms linear infinite; }.loading-row text { color:#989DA5; font-size:22rpx; }.load-error text { color:#F26A38; }.no-more view { width:70rpx; height:1rpx; background-color:#E7E7E7; }.no-more text { margin:0 18rpx; color:#B1B3B7; font-size:21rpx; }.bottom-space { height:calc(30rpx + env(safe-area-inset-bottom)); }@keyframes shopping-loading { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
</style>
rencaika/pages/spouse-employment-form/spouse-employment-form.uvue
@@ -7,10 +7,10 @@
                <text class="field-label">手机号</text><view class="input-box"><input v-model="form.mobile" type="number" maxlength="11" placeholder="请输入手机号" placeholder-class="input-placeholder" /></view>
                <text class="field-label">身份证号</text><view class="input-box"><input :value="form.idCard" type="idcard" maxlength="18" placeholder="请输入身份证号" placeholder-class="input-placeholder" @input="handleIdCardInput" /></view>
                <text class="field-label">政治面貌</text><view class="input-box"><input v-model="form.politicalStatus" maxlength="64" placeholder="请输入政治面貌" placeholder-class="input-placeholder" /></view>
                <text class="field-label">学历</text><picker class="field-picker" :range="educationOptions" :value="educationIndex" @change="changeEducation"><view class="picker-box"><text :class="form.education == '' ? 'picker-placeholder' : ''">{{form.education == '' ? '请选择' : form.education}}</text><text class="picker-arrow">⌄</text></view></picker>
                <text class="field-label">学历</text><picker class="field-picker" :range="educationOptions" :value="educationIndex" @change="changeEducation"><view class="picker-box"><text :class="form.education == '' ? 'picker-placeholder' : ''">{{form.education == '' ? '请选择' : form.education}}</text><view class="picker-arrow"></view></view></picker>
                <text class="field-label">毕业院校及专业</text><view class="input-box"><input v-model="form.schoolMajor" maxlength="255" placeholder="请输入毕业院校及专业" placeholder-class="input-placeholder" /></view>
                <text class="field-label">现工作单位及职务</text><view class="input-box"><input v-model="form.workUnit" maxlength="255" placeholder="请输入现工作单位及职务" placeholder-class="input-placeholder" /></view>
                <text class="field-label">是否为机关事业单位在编人员</text><picker class="field-picker" :range="institutionOptions" :value="institutionIndex" @change="changeInstitution"><view class="picker-box"><text :class="form.inInstitution == '' ? 'picker-placeholder' : ''">{{form.inInstitution == '' ? '请选择' : form.inInstitution}}</text><text class="picker-arrow">⌄</text></view></picker>
                <text class="field-label">是否为机关事业单位在编人员</text><picker class="field-picker" :range="institutionOptions" :value="institutionIndex" @change="changeInstitution"><view class="picker-box"><text :class="form.inInstitution == '' ? 'picker-placeholder' : ''">{{form.inInstitution == '' ? '请选择' : form.inInstitution}}</text><view class="picker-arrow"></view></view></picker>
                <text class="field-label">工作经历</text><view class="textarea-box"><textarea v-model="form.workExperience" maxlength="4000" placeholder="请输入工作经历" placeholder-class="input-placeholder"></textarea></view>
                <view :class="isSubmitting ? 'submit-button submit-button-disabled' : 'submit-button'" hover-class="submit-button-active" @tap="submitForm"><text>{{isSubmitting ? '提交中' : '提交配偶信息'}}</text></view><view class="safe-bottom"></view>
            </view>
@@ -58,5 +58,5 @@
</script>
<style>
    .page { display:flex; width:100%; height:100vh; background-color:#F4F7FB; background-image:linear-gradient(180deg,#FFF0D7 0%,#FFF2DE 250rpx,#F4F7FB 510rpx); flex-direction:column; }.page-header { position:relative; z-index:10; flex-shrink:0; }.safe-space { height:28px; }.nav-row { display:flex; box-sizing:border-box; padding-left:20rpx; position:relative; flex-direction:row; align-items:center; justify-content:space-between; }.back-button { display:flex; width:70rpx; height:64rpx; margin-left:-4rpx; justify-content:center; align-items:center; }.back-button image { width:36rpx; height:36rpx; }.nav-title { position:absolute; left:50%; top:50%; color:#171B22; font-size:37rpx; line-height:48rpx; font-weight:700; transform:translate(-50%,-50%); white-space:nowrap; }.capsule-space { height:100%; flex-shrink:0; }.form-scroll { min-height:0; flex:1; }.form-content { padding:55rpx 34rpx 0; }.field-label { display:block; margin-left:5rpx; color:#74808F; font-size:25rpx; line-height:36rpx; font-weight:600; }.field-label:not(:first-child) { margin-top:44rpx; }.input-box,.picker-box { display:flex; height:112rpx; margin-top:14rpx; box-sizing:border-box; padding:0 25rpx; border-radius:28rpx; background-color:#FFFFFF; flex-direction:row; align-items:center; }.input-box input { width:100%; color:#4E5867; font-size:28rpx; }.input-placeholder,.picker-placeholder { color:#C1C8D1; }.field-picker { display:block; }.picker-box { color:#4E5867; font-size:28rpx; justify-content:space-between; }.picker-arrow { color:#8793A3; font-size:39rpx; }.textarea-box { height:272rpx; margin-top:14rpx; box-sizing:border-box; padding:22rpx 25rpx; border-radius:28rpx; background-color:#FFFFFF; }.textarea-box textarea { width:100%; height:100%; color:#4E5867; font-size:28rpx; line-height:42rpx; }.submit-button { display:flex; height:100rpx; margin:45rpx 4rpx 0; border-radius:51rpx; background-image:linear-gradient(90deg,#FF4E35,#FF8318); justify-content:center; align-items:center; }.submit-button-disabled { opacity:.62; }.submit-button text { color:#FFFFFF; font-size:29rpx; font-weight:700; }.submit-button-active { opacity:.8; transform:scale(.99); }.state-box { display:flex; min-height:400rpx; color:#8D96A5; font-size:28rpx; flex-direction:column; align-items:center; justify-content:center; }.retry { margin-top:24rpx; color:#FF7A27; }.safe-bottom { height:70rpx; height:calc(70rpx + env(safe-area-inset-bottom)); }
    .page { display:flex; width:100%; height:100vh; background-color:#F4F7FB; background-image:linear-gradient(180deg,#FFF0D7 0%,#FFF2DE 250rpx,#F4F7FB 510rpx); flex-direction:column; }.page-header { position:relative; z-index:10; flex-shrink:0; }.safe-space { height:28px; }.nav-row { display:flex; box-sizing:border-box; padding-left:20rpx; position:relative; flex-direction:row; align-items:center; justify-content:space-between; }.back-button { display:flex; width:70rpx; height:64rpx; margin-left:-4rpx; justify-content:center; align-items:center; }.back-button image { width:36rpx; height:36rpx; }.nav-title { position:absolute; left:50%; top:50%; color:#171B22; font-size:37rpx; line-height:48rpx; font-weight:700; transform:translate(-50%,-50%); white-space:nowrap; }.capsule-space { height:100%; flex-shrink:0; }.form-scroll { min-height:0; flex:1; }.form-content { padding:55rpx 34rpx 0; }.field-label { display:block; margin-left:5rpx; color:#74808F; font-size:25rpx; line-height:36rpx; font-weight:600; }.field-label:not(:first-child) { margin-top:44rpx; }.input-box,.picker-box { display:flex; height:112rpx; margin-top:14rpx; box-sizing:border-box; padding:0 25rpx; border-radius:28rpx; background-color:#FFFFFF; flex-direction:row; align-items:center; }.input-box input { width:100%; color:#4E5867; font-size:28rpx; }.input-placeholder,.picker-placeholder { color:#C1C8D1; }.field-picker { display:block; }.picker-box { color:#4E5867; font-size:28rpx; justify-content:space-between; }.picker-arrow { width:14rpx; height:14rpx; margin-right:5rpx; border-right:3rpx solid #8E9AAA; border-bottom:3rpx solid #8E9AAA; flex-shrink:0; transform:rotate(45deg) translateY(-3rpx); }.textarea-box { height:272rpx; margin-top:14rpx; box-sizing:border-box; padding:22rpx 25rpx; border-radius:28rpx; background-color:#FFFFFF; }.textarea-box textarea { width:100%; height:100%; color:#4E5867; font-size:28rpx; line-height:42rpx; }.submit-button { display:flex; height:100rpx; margin:45rpx 4rpx 0; border-radius:51rpx; background-image:linear-gradient(90deg,#FF4E35,#FF8318); justify-content:center; align-items:center; }.submit-button-disabled { opacity:.62; }.submit-button text { color:#FFFFFF; font-size:29rpx; font-weight:700; }.submit-button-active { opacity:.8; transform:scale(.99); }.state-box { display:flex; min-height:400rpx; color:#8D96A5; font-size:28rpx; flex-direction:column; align-items:center; justify-content:center; }.retry { margin-top:24rpx; color:#FF7A27; }.safe-bottom { height:70rpx; height:calc(70rpx + env(safe-area-inset-bottom)); }
</style>
rencaika/services/portal.uts
@@ -22,8 +22,10 @@
    return request<any>({ path: getApiEndpoint('appPolicyDetail') + encodeURIComponent(policyId), method: 'GET', skipAuth: true }, onSuccess, onError)
}
export const getMerchants = (page: number, size: number, keyword: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    return request<any>({ path: getApiEndpoint('appMerchants'), method: 'GET', skipAuth: true, data: { page: page, size: size, keyword: keyword } }, onSuccess, onError)
export const getMerchants = (page: number, size: number, keyword: string, talentId: string, onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
    const data: any = { page: page, size: size, keyword: keyword }
    if (talentId != '') data.talentId = Number(talentId)
    return request<any>({ path: getApiEndpoint('appMerchants'), method: 'GET', skipAuth: true, data: data }, onSuccess, onError)
}
export const getFoodTopic = (onSuccess: (data: any) => void, onError: (error: HttpError) => void): (() => void) => {
rencaika_merchant/pages/bank-card-select/bank-card-select.uvue
@@ -1,15 +1,53 @@
<template>
  <view class="page"><view class="nav"><view class="nav-back" @tap="goBack"><image class="back-icon" src="/static/icons/lucide/back-left.svg" mode="aspectFit"></image></view><text class="nav-title">选择银行卡</text><view class="nav-right-placeholder"></view></view><view class="add" @tap="addCard">添加银行卡</view><scroll-view class="scroll" scroll-y="true"><view v-if="loading" class="state">正在加载银行卡</view><view v-else-if="cards.length == 0" class="state">暂无银行卡</view><view v-for="card in cards" :key="card.id" class="card" @tap="selectCard(card)"><view class="row"><text class="name">{{card.bankName}}</text><text :class="'status ' + card.status">{{statusText(card.status)}}</text></view><text class="number">{{card.cardMasked}}</text><text class="branch">{{card.branchName}}</text><text v-if="card.reason.length > 0" class="reason">{{card.reason}}</text></view></scroll-view></view>
  <view class="page">
    <view class="nav" :style="'height:' + navBarHeight + 'px'">
      <view class="nav-back" :style="'top:' + navTop + 'px;height:' + navHeight + 'px'" @tap="goBack"><image class="back-icon" src="/static/icons/lucide/back-left.svg" mode="aspectFit"></image></view>
      <text class="nav-title" :style="'top:' + navTop + 'px;height:' + navHeight + 'px'">选择银行卡</text>
    </view>
    <view class="add" @tap="addCard">添加银行卡</view>
    <scroll-view class="scroll" scroll-y="true"><view v-if="loading" class="state">正在加载银行卡</view><view v-else-if="cards.length == 0" class="state">暂无银行卡</view><view v-for="card in cards" :key="card.id" class="card" @tap="selectCard(card)"><view class="row"><text class="name">{{card.bankName}}</text><text :class="'status ' + card.status">{{statusText(card.status)}}</text></view><text class="number">{{card.cardMasked}}</text><text class="branch">{{card.branchName}}</text><text v-if="card.reason.length > 0" class="reason">{{card.reason}}</text></view></scroll-view>
  </view>
</template>
<script setup lang="uts">
import { loadBankCards, SettlementBankCard } from '../../services/settlement.uts'
const storageKey = 'merchant-withdrawal-selected-bank-id'
const cards = ref<Array<SettlementBankCard>>([]); const loading = ref(true)
const navTop = ref(28); const navHeight = ref(32); const navBarHeight = ref(76)
const goBack = () => uni.navigateBack()
const addCard = () => uni.navigateTo({ url: '/pages/bank-card-add/bank-card-add' })
const statusText = (value: string): string => value == 'approved' ? '已通过' : value == 'rejected' ? '未通过' : '审核中'
const selectCard = (card: SettlementBankCard) => { if (card.status != 'approved') { uni.showToast({ title: card.reason.length > 0 ? card.reason : '该银行卡尚未审核通过', icon: 'none' }); return }; uni.setStorageSync(storageKey, card.id); uni.navigateBack() }
const load = () => { loading.value = true; loadBankCards((data) => { cards.value = data; loading.value = false }, (error: any) => { loading.value = false; uni.showToast({ title: error.message || '银行卡加载失败', icon: 'none' }) }) }
onLoad(() => {
    // Align every custom navigation element to the native capsule's center line.
    // #ifdef MP-WEIXIN
    const capsule = uni.getMenuButtonBoundingClientRect()
    navTop.value = capsule.top
    navHeight.value = capsule.height
    navBarHeight.value = capsule.top + capsule.height + 16
    // #endif
})
onShow(() => load())
</script>
<style>.page{height:100vh;background:#f4f6f9}.nav{height:174rpx;padding:68rpx 38rpx 0;display:flex;position:relative;justify-content:center;align-items:center;box-sizing:border-box;color:#202b3e}.nav-back{display:flex;width:64rpx;height:64rpx;position:absolute;left:38rpx;top:89rpx;align-items:center;justify-content:center;z-index:2}.back-icon{width:38rpx;height:38rpx}.nav-title{width:100%;font-size:34rpx;line-height:48rpx;text-align:center;font-weight:700}.nav-right-placeholder{width:64rpx;height:64rpx;position:absolute;right:38rpx;top:89rpx}.add{height:88rpx;margin:0 34rpx 28rpx;border-radius:44rpx;background:#ff7625;color:#fff;text-align:center;line-height:88rpx;font-weight:700}.scroll{height:calc(100vh - 290rpx);padding:0 34rpx;box-sizing:border-box}.card{padding:28rpx;margin-bottom:22rpx;background:#fff;border-radius:28rpx}.row{display:flex;justify-content:space-between}.name{font-size:30rpx;font-weight:700;color:#2c3748}.number{display:block;margin-top:20rpx;font-size:28rpx;color:#3a4556}.branch,.reason{display:block;margin-top:10rpx;color:#8993a1;font-size:23rpx}.reason{color:#e95b5b}.status{font-size:23rpx}.approved{color:#249765}.pending{color:#e58b28}.rejected{color:#e95b5b}.state{padding:150rpx 0;text-align:center;color:#8993a1}</style>
<style>
/* Match the shared merchant navigation: one-line title centered on the capsule. */
.nav-title {
  position: absolute;
  left: 112rpx;
  right: 112rpx;
  width: auto;
  display: flex;
  align-items: center;
  justify-content: center;
  line-height: 1;
  white-space: nowrap;
}
.nav-back {
  left: 16px;
  width: 48px;
  justify-content: flex-start;
}
.back-icon { width: 44rpx; height: 44rpx; transform: translateY(5px); }
.nav-right-placeholder { display: none; }
</style>
rencaika_merchant/pages/benefits/benefits.uvue
@@ -78,10 +78,10 @@
    .hero { height: 382rpx; position: relative; overflow: hidden; }
    .hero-image { position: absolute; top: 0; left: 0; width: 100%; height: 382rpx; }
    .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-nav { height: 154rpx; position: absolute; top: 0; left: 0; right: 0; z-index: 3; box-sizing: border-box; }
    .hero-back { display: flex; position: absolute; width: 58rpx; height: 58rpx; align-items: center; justify-content: center; }
    .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-wrap { display: flex; flex-direction: column; align-items: center; position: absolute; top: 204rpx; left: 0; right: 0; z-index: 2; }
    .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
@@ -23,7 +23,7 @@
                            <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>
                        <view class="add-hour" @tap="showAddSheet"><text class="add-plus">+</text><text class="add-hour-label">添加营业时间段</text></view>
                    </view>
                </view>
                <view class="save-button" :class="saving ? 'save-button-disabled' : ''" @tap="saveHours"><text>{{saving ? '保存中' : '保存'}}</text></view>
@@ -242,8 +242,9 @@
    .minus-button { display: flex; width: 34rpx; height: 34rpx; margin-right: 20rpx; justify-content: center; align-items: center; border-radius: 17rpx; color: #FFFFFF; font-size: 27rpx; line-height: 32rpx; font-weight: 700; background-color: #1D2939; }
    .hours-text { flex: 1; color: #202B3E; font-size: 30rpx; line-height: 42rpx; font-weight: 700; }
    .period-tag { padding: 9rpx 17rpx; border-radius: 23rpx; color: #657184; font-size: 22rpx; line-height: 31rpx; background-color: #FFFFFF; }
    .add-hour { display: flex; height: 72rpx; padding: 0 24rpx; margin: 26rpx auto 0; justify-content: center; align-items: center; border-width: 1rpx; border-style: solid; border-color: #FFD1BD; border-radius: 36rpx; color: #FF5A42; font-size: 25rpx; line-height: 35rpx; font-weight: 600; background-color: #FFFCF9; box-sizing: border-box; }
    .add-plus { font-size: 31rpx; line-height: 35rpx; margin-right: 8rpx; font-weight: 400; }
    .add-hour { display: flex; height: 72rpx; padding: 0 24rpx; margin: 26rpx auto 0; flex-direction: row; flex-wrap: nowrap; justify-content: center; align-items: center; border-width: 1rpx; border-style: solid; border-color: #FFD1BD; border-radius: 36rpx; color: #FF5A42; font-size: 25rpx; line-height: 35rpx; font-weight: 600; background-color: #FFFCF9; box-sizing: border-box; }
    .add-plus { display: flex; width: 31rpx; height: 35rpx; margin-right: 8rpx; flex-shrink: 0; align-items: center; justify-content: center; font-size: 31rpx; line-height: 35rpx; font-weight: 400; }
    .add-hour-label { flex-shrink: 0; white-space: nowrap; }
    .save-button { display: flex; height: 104rpx; margin-top: 30rpx; justify-content: center; align-items: center; border-radius: 52rpx; color: #FFFFFF; font-size: 32rpx; line-height: 45rpx; font-weight: 700; background-image: linear-gradient(90deg, #FF6A22 0%, #FF8F20 100%); box-shadow: 0 16rpx 28rpx rgba(255, 110, 35, 0.18); }
    .save-button-disabled { opacity: 0.62; }
    .bottom-space { height: 110rpx; }
rencaika_merchant/pages/merchant/merchant.uvue
@@ -146,9 +146,10 @@
    .setting-chevron { width: 28rpx; height: 28rpx; }
    .business-state { display: flex; flex-direction: row; align-items: center; }
    .business-state-disabled { opacity: 0.58; }
    .business-state-text { color: #FF5A42; font-size: 25rpx; line-height: 35rpx; font-weight: 600; }
    .state-switch { display: flex; width: 68rpx; height: 40rpx; padding: 3rpx; margin-left: 12rpx; border-radius: 22rpx; align-items: center; background-color: #CDD5E0; box-sizing: border-box; }
    .state-switch.open { justify-content: flex-end; background-color: #FF5A42; }
    .state-thumb { width: 34rpx; height: 34rpx; border-radius: 17rpx; background-color: #FFFFFF; box-shadow: 0 2rpx 7rpx rgba(36, 41, 49, 0.18); }
    .business-state-text { color: #FF5545; font-size: 30rpx; line-height: 42rpx; font-weight: 700; }
    .state-switch { position: relative; width: 82rpx; height: 52rpx; margin-left: 18rpx; border-radius: 28rpx; background-color: #CDD5E0; box-sizing: border-box; }
    .state-switch.open { background-color: #FF5A42; }
    .state-thumb { position: absolute; top: 4rpx; left: 4rpx; width: 44rpx; height: 44rpx; border-radius: 22rpx; background-color: #FFFFFF; box-shadow: 0 2rpx 7rpx rgba(36, 41, 49, 0.18); }
    .state-switch.open .state-thumb { left: 34rpx; }
</style>
rencaika_merchant/pages/scan/scan.uvue
@@ -3,10 +3,12 @@
        <camera class="scan-camera" device-position="back" flash="off" mode="scanCode" @scancode="onScanCode" @error="onCameraError"></camera>
        <cover-view class="camera-shade"></cover-view>
        <cover-view class="back-button" @tap.stop="goBack"><cover-image class="back-icon" src="/static/icons/lucide/back-left.svg"></cover-image></cover-view>
        <cover-view class="scan-frame" :class="scanState != 'scanning' ? 'scan-paused' : ''">
            <cover-view class="frame-top"></cover-view><cover-view class="frame-bottom"></cover-view>
        <cover-view class="scan-frame">
            <cover-view class="scan-window">
                <cover-view class="frame-top"></cover-view><cover-view class="frame-bottom"></cover-view>
                <cover-view v-if="scanState == 'scanning'" class="scan-line"></cover-view>
            </cover-view>
            <cover-view class="frame-corner corner-tl"></cover-view><cover-view class="frame-corner corner-tr"></cover-view><cover-view class="frame-corner corner-bl"></cover-view><cover-view class="frame-corner corner-br"></cover-view>
            <cover-view v-if="scanState == 'scanning'" class="scan-line"></cover-view>
        </cover-view>
        <cover-view v-if="scanState == 'scanning'" class="scan-tip">将取景框对准二维码,即可自动扫描</cover-view>
@@ -194,10 +196,12 @@
    .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; }
    .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; }
    .back-icon { width: 40rpx; height: 40rpx; filter: invert(1); }
    .scan-frame { width: 460rpx; height: 460rpx; position: absolute; top: 430rpx; left: 145rpx; z-index: 10; box-sizing: border-box; }
    .scan-window { width: 460rpx; height: 286rpx; position: absolute; top: 0; left: 0; background-color: rgba(40, 137, 91, 0.22); }
    .frame-top, .frame-bottom { height: 4rpx; position: absolute; background-color: #6BE2AD; opacity: 0.85; }
    .frame-top { width: 382rpx; left: 78rpx; }
    .frame-bottom { width: 100%; left: 0; }
    .frame-top { top: 0; }
    .frame-bottom { bottom: 0; }
    .frame-corner { width: 42rpx; height: 42rpx; position: absolute; border-color: #69E6B1; box-sizing: border-box; }
@@ -206,8 +210,8 @@
    .corner-bl { bottom: 0; left: 0; border-bottom-width: 7rpx; border-left-width: 7rpx; }
    .corner-br { right: 0; bottom: 0; border-right-width: 7rpx; border-bottom-width: 7rpx; }
    .scan-line { width: 100%; height: 3rpx; position: absolute; top: 0; left: 0; background-color: rgba(133, 255, 192, 0.9); box-shadow: 0 0 15rpx rgba(111, 241, 170, 0.8); animation: scan-line-move 1800ms ease-in-out infinite alternate; }
    .scan-tip { width: 100%; position: absolute; top: 1116rpx; left: 0; z-index: 10; color: #FFFFFF; font-size: 27rpx; line-height: 38rpx; text-align: center; }
    .status-panel { display: flex; width: 620rpx; min-height: 330rpx; padding: 42rpx 38rpx 34rpx; flex-direction: column; align-items: center; position: absolute; left: 65rpx; bottom: 120rpx; z-index: 30; border-radius: 36rpx; background-color: rgba(255, 255, 255, 0.96); box-sizing: border-box; }
    .scan-tip { width: 100%; position: absolute; top: 975rpx; left: 0; z-index: 10; color: #FFFFFF; font-size: 27rpx; line-height: 38rpx; text-align: center; }
    .status-panel { display: none; }
    .loading-spinner { width: 58rpx; height: 58rpx; border-width: 6rpx; border-style: solid; border-color: #FFD5BF; border-top-color: #FF6B2C; border-radius: 50%; animation: loading-rotate 800ms linear infinite; }
    .status-mark { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; border-radius: 32rpx; color: #FFFFFF; font-size: 38rpx; line-height: 64rpx; font-weight: 700; text-align: center; }
    .success-mark { background-color: #35B879; }
@@ -226,6 +230,6 @@
    .half-action { width: 48%; }
    .status-actions .primary-action.half-action { margin-left: 4%; }
    .full-action { width: 100%; }
    @keyframes scan-line-move { from { transform: translateY(0); } to { transform: translateY(566rpx); } }
    @keyframes scan-line-move { from { transform: translateY(0); } to { transform: translateY(282rpx); } }
    @keyframes loading-rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
talent-card-admin/src/style.css
@@ -617,6 +617,11 @@
.merchant-form-row.address-row {
  grid-template-columns: 68px minmax(0, 1fr) 88px;
  align-items: start;
}
.merchant-form-row.address-row > .el-form-item__label {
  line-height: 32px;
}
.merchant-form-row.three-columns {
@@ -695,6 +700,11 @@
  grid-template-columns: 58px minmax(0, 1fr);
  align-items: center;
  margin: 84px 0 18px;
}
.merchant-create-right .merchant-form-row,
.merchant-create-right .merchant-hours-block {
  column-gap: 18px;
}
.merchant-hours-list {
@@ -2146,6 +2156,34 @@
  box-shadow: 0 0 0 1px #d9dee8 inset !important;
}
/* Keep every editable product control visually consistent with the merchant form. */
.merchant-product-form-panel {
  --el-fill-color-blank: #ffffff;
  --el-fill-color-light: #ffffff;
  --el-disabled-bg-color: #ffffff;
  --el-disabled-text-color: #334155;
}
.merchant-product-form-panel .el-input__wrapper,
.merchant-product-form-panel .el-input-number .el-input__wrapper,
.merchant-product-form-panel .el-select__wrapper,
.merchant-product-form-panel .el-date-editor.el-input__wrapper,
.merchant-product-form-panel .el-time-editor.el-input__wrapper {
  background: #ffffff !important;
  box-shadow: 0 0 0 1px #d9dee8 inset !important;
}
.merchant-product-form-panel .el-input__wrapper:hover,
.merchant-product-form-panel .el-input__wrapper.is-focus,
.merchant-product-form-panel .el-select__wrapper:hover,
.merchant-product-form-panel .el-select__wrapper.is-focused,
.merchant-product-form-panel .el-date-editor.el-input__wrapper:hover,
.merchant-product-form-panel .el-date-editor.el-input__wrapper.is-focus,
.merchant-product-form-panel .el-time-editor.el-input__wrapper:hover,
.merchant-product-form-panel .el-time-editor.el-input__wrapper.is-focus {
  box-shadow: 0 0 0 1px #b8c2d1 inset !important;
}
.merchant-product-form-panel .el-button,
.product-form-actions .el-button {
  height: 28px;
@@ -2281,7 +2319,7 @@
.coupon-requirement-row .coupon-full-amount-control > span {
  color: #334155;
  font-size: 12px;
  font-size: 14px;
  line-height: 28px;
  white-space: nowrap;
}
@@ -2493,6 +2531,14 @@
.detail-money-field {
  grid-template-columns: 56px 160px auto;
}
.detail-period-field {
  grid-template-columns: 84px minmax(0, 1fr);
}
.detail-period-field label {
  white-space: nowrap;
}
.detail-field label,
@@ -3433,9 +3479,38 @@
/* 角色管理筛选项与标题左边缘对齐。 */
.role-management-filter-form .el-form-item__label {
  flex: 0 0 42px;
  width: 42px !important;
  flex: 0 0 86px;
  width: 86px !important;
  justify-content: flex-start;
}
.role-management-filter-form .business-fill-filter-actions {
  grid-column: -2 / -1;
  grid-row: 1;
  justify-self: end;
}
.upgrade-management-filter-form .el-form-item__label {
  justify-content: flex-end;
}
.upgrade-management-filter-form {
  grid-template-columns: minmax(220px, 1fr) minmax(280px, 1.15fr) minmax(220px, 0.9fr) auto;
  gap: 14px 34px;
}
.upgrade-management-filter-form .upgrade-management-filter-date {
  grid-column: 1;
}
.upgrade-management-filter-form .business-fill-filter-actions {
  grid-row: 1 / span 2;
  grid-column: 4;
  align-content: start;
}
.upgrade-management-filter-form .el-date-editor {
  width: 100% !important;
}
.business-fill-filter-form .el-input,
@@ -3757,6 +3832,11 @@
  padding: 18px 0 16px;
}
.movie-ticket-code-pool-tools {
  justify-content: flex-start;
  gap: 12px;
}
.movie-ticket-package-table {
  color: #111827;
  font-size: 12px;
@@ -3806,7 +3886,7 @@
.movie-ticket-create-row {
  display: grid;
  grid-template-columns: 58px minmax(180px, 600px) 36px 180px 86px;
  grid-template-columns: 58px 544px 36px 180px 86px;
  gap: 12px;
  align-items: center;
  color: #111827;
talent-card-admin/src/types/admin.ts
@@ -302,6 +302,7 @@
  merchantId: number | string
  merchantName?: string
  merchantType?: string
  merchantTypeCode?: string
  contactMobile?: string
  productNo?: string
  productType: string
@@ -322,6 +323,7 @@
  productImages?: string[] | string
  rightsConfig?: Array<Record<string, unknown>> | string
  auditStatus?: number
  auditRecordId?: number | string
  auditRemark?: string
  status?: number
  sortNo?: number
talent-card-admin/src/views/BusinessManagementView.vue
@@ -176,7 +176,7 @@
  <div class="page-stack prototype-page business-fill-page">
    <section class="panel">
      <div class="panel-header"><h3>{{ title }}</h3></div>
      <el-form class="business-fill-filter-form" :class="{ 'role-management-filter-form': isRole }" label-width="86px">
      <el-form class="business-fill-filter-form" :class="{ 'role-management-filter-form': isRole, 'upgrade-management-filter-form': isUpgrade }" label-width="86px">
        <el-form-item :label="isUpgrade ? '持卡人' : isRole ? '角色' : '员工'">
          <el-input v-model="filters.keyword" :placeholder="isUpgrade ? '持卡人姓名、手机号、卡号' : '请输入关键词'" clearable />
        </el-form-item>
@@ -196,7 +196,7 @@
            <el-option v-if="isUpgrade" label="已驳回" value="已驳回" />
          </el-select>
        </el-form-item>
        <el-form-item v-if="isUpgrade" label="申请时间">
        <el-form-item v-if="isUpgrade" label="申请时间" class="upgrade-management-filter-date">
          <el-date-picker v-model="filters.timeRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" />
        </el-form-item>
        <div class="business-fill-filter-actions">
talent-card-admin/src/views/DashboardView.vue
@@ -78,6 +78,17 @@
  return String(Math.round(value))
}
function compareTrend(value: unknown) {
  const number = Number(value ?? 0)
  if (!Number.isFinite(number) || number === 0) return 'flat'
  return number > 0 ? 'up' : 'down'
}
function trendIcon(value: unknown) {
  const trend = compareTrend(value)
  return trend === 'up' ? '↑' : trend === 'down' ? '↓' : '—'
}
function barHeight(value: unknown) {
  return `${Math.max(4, (Number(value || 0) / maxBarValue.value) * 168)}px`
}
@@ -123,7 +134,9 @@
        <div v-for="card in summaryCards" :key="card.label" class="subsidy-card" :class="card.tone">
          <div class="subsidy-label">{{ card.label }}</div>
          <div class="subsidy-value">{{ formatNumber(card.value) }}</div>
          <div class="subsidy-compare">{{ card.unit }} <span>{{ formatNumber(card.compare) }}</span></div>
          <div class="subsidy-compare" :class="`trend-${compareTrend(card.compare)}`">
            {{ card.unit }} <span>{{ formatNumber(card.compare) }}</span><b>{{ trendIcon(card.compare) }}</b>
          </div>
          <div class="sparkline" :class="card.tone">
            <i v-for="(point, index) in card.spark" :key="index" :style="{ height: `${point}%` }" />
          </div>
@@ -210,7 +223,12 @@
.subsidy-card.green { background: #f0fff2; }
.subsidy-label { font-size: 14px; color: #111827; }
.subsidy-value { margin-top: 18px; font-size: 25px; line-height: 1; color: #07142c; }
.subsidy-compare { margin-top: 8px; color: #8d98a7; font-size: 12px; }
.subsidy-compare { display: flex; align-items: center; gap: 4px; margin-top: 8px; color: #8d98a7; font-size: 12px; }
.subsidy-compare span { color: inherit; }
.subsidy-compare b { font-size: 15px; line-height: 12px; font-weight: 700; }
.subsidy-compare.trend-up { color: #18a957; }
.subsidy-compare.trend-down { color: #f04444; }
.subsidy-compare.trend-flat { color: #8d98a7; }
.subsidy-compare span { color: #ff3b30; }
.sparkline { position: absolute; right: 14px; bottom: 16px; display: flex; align-items: end; width: 80px; height: 44px; gap: 4px; }
.sparkline i { flex: 1; min-height: 5px; border-radius: 999px 999px 0 0; background: #1677ff; }
@@ -247,4 +265,4 @@
.donut-labels { display: flex; gap: 14px; margin-top: 18px; color: #697589; font-size: 12px; }
.donut-labels i { display: inline-block; width: 22px; height: 9px; margin-right: 4px; border-radius: 2px; vertical-align: middle; }
@media (max-width: 1100px) { .top-grid, .bottom-grid { grid-template-columns: 1fr; } .subsidy-cards { grid-template-columns: 1fr; max-width: none; } }
</style>
</style>
talent-card-admin/src/views/FuelServiceDetailView.vue
@@ -13,6 +13,10 @@
  return value === undefined || value === null || value === '' ? '-' : String(value)
}
function dateTimeText(value?: string | number) {
  return text(value).replace('T', ' ')
}
const cardholderInfo = computed(() => [
  { label: '持卡人姓名:', value: text(detail.value?.talentName) },
  { label: '手机号:', value: text(detail.value?.talentMobile) },
@@ -21,12 +25,12 @@
const submissionInfo = computed(() => [
  { label: '支付宝绑定手机号:', value: text(detail.value?.alipayMobile) },
  { label: '提交时间:', value: text(detail.value?.submittedAt) },
  { label: '提交时间:', value: dateTimeText(detail.value?.submittedAt) },
])
const statusInfo = computed(() => [
  { label: '状态:', value: text(detail.value?.status) },
  { label: '处理时间:', value: text(detail.value?.handledAt) },
  { label: '处理时间:', value: dateTimeText(detail.value?.handledAt) },
  { label: '处理人:', value: text(detail.value?.handlerName) },
])
@@ -83,6 +87,6 @@
      </div>
    </section>
    <el-button v-if="!handled" type="primary" class="reservation-detail-action" @click="markHandled">已处理</el-button>
    <el-button v-if="!handled" type="primary" class="reservation-detail-action" @click="markHandled">处理</el-button>
  </div>
</template>
talent-card-admin/src/views/FuelServiceView.vue
@@ -30,6 +30,14 @@
  total: 0,
})
function formatDateTime(value?: string) {
  return value ? value.replace('T', ' ') : ''
}
function normalizeRecord(row: ServiceApplication): ServiceApplication {
  return { ...row, submittedAt: formatDateTime(row.submittedAt) }
}
async function loadData() {
  loading.value = true
  try {
@@ -44,7 +52,7 @@
      submittedStart: filters.submittedTime?.[0],
      submittedEnd: filters.submittedTime?.[1],
    })
    rows.value = result.records
    rows.value = result.records.map(normalizeRecord)
    pagination.total = result.total
  } catch {
    rows.value = []
@@ -71,7 +79,7 @@
async function download() {
  const result = await getServiceApplications({ page: 1, size: 1000, applicationType: 'FUEL_SERVICE', talentKeyword: filters.talentKeyword || undefined, cardLevel: filters.cardLevel || undefined, status: filters.status || undefined, submissionType: filters.submissionType || undefined, submittedStart: filters.submittedTime?.[0], submittedEnd: filters.submittedTime?.[1] })
  exportApplications('加油服务登记', result.records)
  exportApplications('加油服务登记', result.records.map(normalizeRecord))
}
function exportApplications(filename: string, data: ServiceApplication[]) {
@@ -103,7 +111,12 @@
            <el-option v-for="level in cardLevels" :key="level" :label="level" :value="level" />
          </el-select>
        </el-form-item>
        <div class="fuel-filter-spacer" />
        <el-form-item label="提交类型">
          <el-select v-model="filters.submissionType" placeholder="全部" clearable>
            <el-option label="全部" value="" />
            <el-option v-for="type in submissionTypes" :key="type" :label="type" :value="type" />
          </el-select>
        </el-form-item>
        <el-form-item label="提交时间">
          <el-date-picker
            v-model="filters.submittedTime"
@@ -118,12 +131,6 @@
          <el-select v-model="filters.status" placeholder="全部" clearable>
            <el-option label="全部" value="" />
            <el-option v-for="status in statusOptions" :key="status" :label="status" :value="status" />
          </el-select>
        </el-form-item>
        <el-form-item label="提交类型">
          <el-select v-model="filters.submissionType" placeholder="全部" clearable>
            <el-option label="全部" value="" />
            <el-option v-for="type in submissionTypes" :key="type" :label="type" :value="type" />
          </el-select>
        </el-form-item>
        <div class="fuel-service-filter-actions">
talent-card-admin/src/views/HotelReservationDetailView.vue
@@ -13,6 +13,10 @@
  return value === undefined || value === null || value === '' ? '-' : String(value)
}
function dateTimeText(value?: string | number) {
  return text(value).replace('T', ' ')
}
const cardholderInfo = computed(() => [
  { label: '持卡人姓名:', value: text(detail.value?.talentName) },
  { label: '手机号:', value: text(detail.value?.talentMobile) },
@@ -25,12 +29,12 @@
  { label: '房型:', value: text(detail.value?.productName) },
  { label: '期望入住时间:', value: text(detail.value?.expectedTime) },
  { label: '入住人数:', value: detail.value?.guestCount ? `${detail.value.guestCount}人` : '-' },
  { label: '提交时间:', value: text(detail.value?.submittedAt) },
  { label: '提交时间:', value: dateTimeText(detail.value?.submittedAt) },
])
const statusInfo = computed(() => [
  { label: '状态:', value: text(detail.value?.status) },
  { label: '处理时间:', value: text(detail.value?.handledAt) },
  { label: '处理时间:', value: dateTimeText(detail.value?.handledAt) },
  { label: '处理人:', value: text(detail.value?.handlerName) },
])
talent-card-admin/src/views/HotelReservationView.vue
@@ -31,6 +31,14 @@
  total: 0,
})
function formatDateTime(value?: string) {
  return value ? value.replace('T', ' ') : ''
}
function normalizeRecord(row: ServiceApplication): ServiceApplication {
  return { ...row, submittedAt: formatDateTime(row.submittedAt) }
}
async function loadData() {
  loading.value = true
  try {
@@ -46,7 +54,7 @@
      submittedStart: filters.submittedTime?.[0],
      submittedEnd: filters.submittedTime?.[1],
    })
    rows.value = result.records
    rows.value = result.records.map(normalizeRecord)
    pagination.total = result.total
  } catch {
    rows.value = []
@@ -74,7 +82,7 @@
async function download() {
  const result = await getServiceApplications({ page: 1, size: 1000, applicationType: 'HOTEL_RESERVATION', talentKeyword: filters.talentKeyword || undefined, merchantName: filters.merchantName || undefined, cardLevel: filters.cardLevel || undefined, subsidy: filters.subsidy || undefined, status: filters.status || undefined, submittedStart: filters.submittedTime?.[0], submittedEnd: filters.submittedTime?.[1] })
  downloadExcel('酒店预约', [{ title: '预约单号', value: row => row.applicationNo }, { title: '持卡人', value: row => row.talentName }, { title: '手机号', value: row => row.talentMobile }, { title: '人才等级', value: row => row.talentLevel }, { title: '酒店', value: row => row.merchantName }, { title: '入住时间', value: row => row.expectedTime }, { title: '入住人数', value: row => row.guestCount }, { title: '补贴', value: row => row.subsidy }, { title: '提交时间', value: row => row.submittedAt }, { title: '状态', value: row => row.status }], result.records)
  downloadExcel('酒店预约', [{ title: '预约单号', value: row => row.applicationNo }, { title: '持卡人', value: row => row.talentName }, { title: '手机号', value: row => row.talentMobile }, { title: '人才等级', value: row => row.talentLevel }, { title: '酒店', value: row => row.merchantName }, { title: '入住时间', value: row => row.expectedTime }, { title: '入住人数', value: row => row.guestCount }, { title: '补贴', value: row => row.subsidy }, { title: '提交时间', value: row => formatDateTime(row.submittedAt) }, { title: '状态', value: row => row.status }], result.records)
  ElMessage.success(`已导出 ${result.records.length} 条预约`)
}
talent-card-admin/src/views/MedicalReservationDetailView.vue
@@ -13,6 +13,10 @@
  return value === undefined || value === null || value === '' ? '-' : String(value)
}
function dateTimeText(value?: string | number) {
  return text(value).replace('T', ' ')
}
const cardholderInfo = computed(() => [
  { label: '家属姓名:', value: text(detail.value?.applicantName) },
  { label: '手机号:', value: text(detail.value?.applicantMobile) },
@@ -29,14 +33,14 @@
  { label: '体检人姓名:', value: text(detail.value?.applicantName) },
  { label: '手机号:', value: text(detail.value?.applicantMobile) },
  { label: '期望体检时间:', value: text(detail.value?.expectedTime) },
  { label: '提交时间:', value: text(detail.value?.submittedAt) },
  { label: '提交时间:', value: dateTimeText(detail.value?.submittedAt) },
])
const handled = computed(() => detail.value?.status === '已处理')
const statusInfo = computed(() => [
  { label: '状态:', value: text(detail.value?.status) },
  { label: '处理时间:', value: text(detail.value?.handledAt) },
  { label: '处理时间:', value: dateTimeText(detail.value?.handledAt) },
  { label: '处理人:', value: text(detail.value?.handlerName) },
])
@@ -93,6 +97,6 @@
      </div>
    </section>
    <el-button v-if="!handled" type="primary" class="reservation-detail-action" @click="markHandled">已处理</el-button>
    <el-button v-if="!handled" type="primary" class="reservation-detail-action" @click="markHandled">处理</el-button>
  </div>
</template>
talent-card-admin/src/views/MerchantOnboardingDetailView.vue
@@ -66,7 +66,7 @@
        <div class="detail-field"><span>证照有效期起始日期</span><strong>{{ dateText(detail.licenseBeginDate) }}</strong></div>
        <div class="detail-field"><span>证照有效期结束日期</span><strong>{{ dateText(detail.licenseEndDate) }}</strong></div>
        <div class="detail-field"><span>注册地址(省、市、区)</span><strong>{{ text(detail.registeredRegion) }}</strong></div>
        <div class="detail-field"><span>详细注册地址</span><strong>{{ text(detail.registeredAddress) }}</strong></div>
        <div class="detail-field detail-field--auto-height"><span>详细注册地址</span><strong>{{ text(detail.registeredAddress) }}</strong></div>
        <div class="detail-field"><span>法人姓名</span><strong>{{ text(detail.subjectName) }}</strong></div>
        <div class="detail-field"><span>法人证件类型</span><strong>身份证</strong></div>
        <div class="detail-field"><span>法人证件号码</span><strong>{{ text(detail.subjectIdNo || detail.subjectIdNoMasked) }}</strong></div>
@@ -99,6 +99,8 @@
.common-grid { margin-bottom: -2px; }
.detail-field { display: grid; grid-template-columns: 144px minmax(0, 1fr); align-items: center; gap: 12px; min-height: 32px; }
.detail-field > span { color: #303133; font-size: 13px; font-weight: 500; white-space: nowrap; }
.detail-field > strong { min-height: 32px; box-sizing: border-box; padding: 7px 10px; color: #606266; font-size: 13px; font-weight: 400; line-height: 18px; word-break: break-all; background: #f5f7fa; }
.detail-field > strong { min-width: 0; min-height: 32px; box-sizing: border-box; padding: 7px 10px; color: #606266; font-size: 13px; font-weight: 400; line-height: 18px; word-break: break-all; background: #f5f7fa; }
.detail-field--auto-height { align-items: stretch; }
.detail-field--auto-height > strong { height: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.placeholder-field { visibility: hidden; }
</style>
talent-card-admin/src/views/MovieTicketView.vue
@@ -73,7 +73,7 @@
const form = reactive({
  packageName: '',
  totalQuantity: 0,
  totalQuantity: 1,
  merchantIds: [] as Array<number | string>,
  validStart: '',
  validEnd: '',
@@ -83,7 +83,7 @@
  usageText: '',
  status: 1,
  sortNo: 0,
  levelRules: ticketRuleLevels.map(level => ({ talentLevel: level, enabled: 1, claimableCount: undefined as number | undefined, resetCycle: '每天' })),
  levelRules: ticketRuleLevels.map(level => ({ talentLevel: level, enabled: 1, claimableCount: 1, resetCycle: '每天' })),
})
const codeForm = reactive({ batchNo: '', codesText: '', remark: '' })
@@ -237,7 +237,7 @@
  editingId.value = row ? rowId(row) : null
  Object.assign(form, {
    packageName: row?.packageName || '',
    totalQuantity: Number(row?.totalQuantity || row?.totalCount || row?.totalCodes || 0),
    totalQuantity: Number(row?.totalQuantity || row?.totalCount || row?.totalCodes || 1),
    merchantIds: Array.isArray(row?.merchantIds) && row.merchantIds.length ? row.merchantIds.map(String) : row?.merchantId ? [String(row.merchantId)] : [],
    validStart: row?.validStart || '',
    validEnd: row?.validEnd || '',
@@ -254,7 +254,7 @@
    return {
      talentLevel: level,
      enabled: Number(found?.enabled ?? 1),
      claimableCount: found?.claimableCount === undefined ? undefined : Number(found.claimableCount),
      claimableCount: Number(found?.claimableCount) > 0 ? Number(found?.claimableCount) : 1,
      resetCycle: found?.resetCycle || '每天',
    }
  })
@@ -275,17 +275,30 @@
    ElMessage.warning('请输入票包名称')
    return
  }
  const totalQuantity = Number(form.totalQuantity)
  if (!Number.isInteger(totalQuantity) || totalQuantity < 1) {
    ElMessage.warning('数量必须为正整数')
    return
  }
  const invalidLevelRule = form.levelRules.find(rule => {
    const claimableCount = Number(rule.claimableCount)
    return !Number.isInteger(claimableCount) || claimableCount < 1
  })
  if (invalidLevelRule) {
    ElMessage.warning(`${invalidLevelRule.talentLevel}可领数量必须为正整数`)
    return
  }
  saving.value = true
  try {
    const levelRules = form.levelRules.map(rule => ({
      talentLevel: rule.talentLevel,
      enabled: rule.enabled,
      claimableCount: Number(rule.claimableCount || 1),
      claimableCount: Number(rule.claimableCount),
      resetCycle: rule.resetCycle || '每天',
    }))
    const payload = {
      packageName: form.packageName.trim(),
      totalQuantity: Number(form.totalQuantity || 0),
      totalQuantity,
      merchantId: form.merchantIds[0] ? Number(form.merchantIds[0]) : undefined,
      merchantIds: form.merchantIds.map(item => Number(item)).filter(Boolean),
      talentLevels: levelRules.filter(rule => Number(rule.enabled) === 1).map(rule => rule.talentLevel),
@@ -535,10 +548,9 @@
            <el-button :icon="Refresh" @click="resetCodeFilters">重置</el-button>
          </div>
        </el-form>
        <div class="movie-ticket-package-tools">
        <div class="movie-ticket-package-tools movie-ticket-code-pool-tools">
          <el-button v-permission="'admin.movie-tickets.import'" type="primary" @click="openImport(selectedPackage || {})">导入码</el-button>
          <el-button @click="openGenerate(selectedPackage || {})">批量生成</el-button>
          <span />
        </div>
        <el-table v-loading="loading" :data="rows" border class="movie-ticket-package-table" empty-text="暂无兑换码">
          <el-table-column label="码ID" min-width="90" align="center"><template #default="{ row }">{{ text(row.codeId || row.id) }}</template></el-table-column>
@@ -569,7 +581,7 @@
              <span>票包名称</span>
              <el-input v-model="form.packageName" />
              <span>数量</span>
              <el-input-number v-model="form.totalQuantity" :min="0" controls-position="right" />
              <el-input-number v-model="form.totalQuantity" :min="1" :step="1" :precision="0" controls-position="right" />
              <el-button type="primary" @click="merchantDialogVisible = true">可用商家</el-button>
            </div>
            <div v-if="selectedMerchantNames" class="movie-ticket-selected-merchants">已选商家:{{ selectedMerchantNames }}</div>
@@ -587,7 +599,7 @@
              <strong>{{ rule.talentLevel }}</strong>
              <el-switch v-model="rule.enabled" :active-value="1" :inactive-value="0" />
              <span>可领数量</span>
              <el-input-number v-model="rule.claimableCount" :min="1" controls-position="right" placeholder="请输入" />
              <el-input-number v-model="rule.claimableCount" :min="1" :step="1" :precision="0" controls-position="right" placeholder="请输入" />
              <span>张</span>
              <span>重置周期</span>
              <el-select v-model="rule.resetCycle">
talent-card-admin/src/views/OrderDetailView.vue
@@ -158,7 +158,7 @@
      <section class="detail-section">
        <h3>订单信息</h3>
        <div class="info-card info-grid order-grid">
        <div class="info-card info-grid order-grid" :class="{ 'store-consume-order-grid': detail.orderType === 'STORE_CONSUME' }">
          <div><span>订单编号</span><strong>{{ detail.orderNo || '-' }}</strong></div>
          <div><span>商品金额</span><strong>{{ money(detail.orderAmount) }}</strong></div>
          <div><span>商品数量</span><strong>{{ items.length }}</strong></div>
@@ -178,7 +178,7 @@
              <strong class="discount-amount">{{ yuan(detail.subsidyAmount) }}</strong>
            </section>
          </div>
          <div><span>实际应付</span><strong class="amount-strong">{{ money(detail.payAmount) }}</strong></div>
          <div class="actual-pay-row"><span>实际应付</span><strong class="amount-strong">{{ money(detail.payAmount) }}</strong></div>
          <div><span>下单时间</span><strong>{{ dateTime(detail.createdAt) }}</strong></div>
          <div><span>核销时间</span><strong>{{ dateTime(detail.verifyTime) }}</strong></div>
          <div><span>订单状态</span><strong>{{ orderStatus(detail) }}</strong></div>
@@ -244,6 +244,7 @@
.order-preview-image { display: block; width: 100%; max-height: 520px; }
.empty-cover { color: #86909c; }
.order-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.store-consume-order-grid .actual-pay-row { grid-column: 1 / -1; }
.info-grid .discount-summary-row { grid-column: 1 / -1; display: block !important; align-items: initial !important; gap: 0 !important; }
.discount-prototype-grid {
  display: grid !important;
talent-card-admin/src/views/RoleFormView.vue
@@ -84,13 +84,14 @@
} as Record<string, string>)[action] || 'view'}`
const pageCodes = (page: Page) => page.actions.map((action) => code(page.code, action))
const moduleCodes = (module: Module) => module.pages.flatMap(pageCodes)
const allPermissionCodes = () => modules.flatMap(moduleCodes)
const checked = (values: string[]) => values.every((value) => form.permissionCodes.includes(value))
const partial = (values: string[]) => values.some((value) => form.permissionCodes.includes(value)) && !checked(values)
const toggle = (values: string[], enabled: boolean) => { const selected = new Set(form.permissionCodes); values.forEach((value) => enabled ? selected.add(value) : selected.delete(value)); form.permissionCodes = [...selected] }
const toggleModule = (module: Module, enabled: boolean) => toggle(moduleCodes(module), enabled)
const togglePage = (page: Page, enabled: boolean) => toggle(pageCodes(page), enabled)
const parse = (value: AdminRole['permissionCodes']) => { if (Array.isArray(value)) return value; try { const result = JSON.parse(value || '[]'); return Array.isArray(result) ? result : [] } catch { return [] } }
async function load() { if (!id.value) return; const role = (await getAdminRoles()).find((item) => String(item.id) === id.value); if (!role) { ElMessage.error('角色不存在'); router.replace('/roles'); return }; Object.assign(form, { roleName: role.roleName, remark: role.remark || '', status: role.status, permissionCodes: parse(role.permissionCodes) }) }
async function load() { if (!id.value) return; const role = (await getAdminRoles()).find((item) => String(item.id) === id.value); if (!role) { ElMessage.error('角色不存在'); router.replace('/roles'); return }; Object.assign(form, { roleName: role.roleName, remark: role.remark || '', status: role.status, permissionCodes: role.roleCode === 'platform_admin' ? allPermissionCodes() : parse(role.permissionCodes) }) }
async function save() { if (isViewMode.value) return; const valid = await formRef.value?.validate().catch(() => false); if (!valid) return; saving.value = true; try { const payload = { roleName: form.roleName.trim(), roleCode: '', remark: form.remark.trim(), status: form.status, permissionCodes: form.permissionCodes }; if (id.value) await updateAdminRole(id.value, payload); else await createAdminRole(payload); ElMessage.success('保存成功'); router.replace('/roles') } finally { saving.value = false } }
watch(() => route.fullPath, load, { immediate: true })
</script>
talent-card-admin/src/views/SpouseEmploymentDetailView.vue
@@ -13,6 +13,10 @@
  return value === undefined || value === null || value === '' ? '-' : String(value)
}
function formatDateTime(value?: string | number) {
  return text(value).replace('T', ' ')
}
const cardholderInfo = computed(() => [
  { label: '持卡人姓名:', value: text(detail.value?.talentName) },
  { label: '手机号:', value: text(detail.value?.talentMobile) },
@@ -37,7 +41,7 @@
const statusInfo = computed(() => [
  { label: '状态:', value: text(detail.value?.status) },
  { label: '处理时间:', value: text(detail.value?.handledAt) },
  { label: '处理时间:', value: formatDateTime(detail.value?.handledAt) },
  { label: '处理人:', value: text(detail.value?.handlerName) },
])
talent-card-admin/src/views/SpouseEmploymentView.vue
@@ -28,6 +28,14 @@
  total: 0,
})
function formatDateTime(value?: string) {
  return value ? value.replace('T', ' ') : ''
}
function normalizeRecord(row: ServiceApplication): ServiceApplication {
  return { ...row, submittedAt: formatDateTime(row.submittedAt) }
}
async function loadData() {
  loading.value = true
  try {
@@ -41,7 +49,7 @@
      submittedStart: filters.submittedTime?.[0],
      submittedEnd: filters.submittedTime?.[1],
    })
    rows.value = result.records
    rows.value = result.records.map(normalizeRecord)
    pagination.total = result.total
  } catch {
    rows.value = []
@@ -67,7 +75,7 @@
async function download() {
  const result = await getServiceApplications({ page: 1, size: 1000, applicationType: 'SPOUSE_EMPLOYMENT', talentKeyword: filters.talentKeyword || undefined, cardLevel: filters.cardLevel || undefined, status: filters.status || undefined, submittedStart: filters.submittedTime?.[0], submittedEnd: filters.submittedTime?.[1] })
  downloadExcel('配偶就业申请', [{ title: '申请单号', value: row => row.applicationNo }, { title: '持卡人', value: row => row.talentName }, { title: '手机号', value: row => row.talentMobile }, { title: '人才等级', value: row => row.talentLevel }, { title: '配偶姓名', value: row => row.spouseName }, { title: '配偶手机号', value: row => row.spouseMobile }, { title: '学历', value: row => row.spouseEducation }, { title: '工作单位', value: row => row.spouseWorkUnit }, { title: '提交时间', value: row => row.submittedAt }, { title: '状态', value: row => row.status }], result.records)
  downloadExcel('配偶就业申请', [{ title: '申请单号', value: row => row.applicationNo }, { title: '持卡人', value: row => row.talentName }, { title: '手机号', value: row => row.talentMobile }, { title: '人才等级', value: row => row.talentLevel }, { title: '配偶姓名', value: row => row.spouseName }, { title: '配偶手机号', value: row => row.spouseMobile }, { title: '学历', value: row => row.spouseEducation }, { title: '工作单位', value: row => row.spouseWorkUnit }, { title: '提交时间', value: row => formatDateTime(row.submittedAt) }, { title: '状态', value: row => row.status }], result.records)
  ElMessage.success(`已导出 ${result.records.length} 条申请`)
}
@@ -95,7 +103,6 @@
            <el-option v-for="level in cardLevels" :key="level" :label="level" :value="level" />
          </el-select>
        </el-form-item>
        <div class="spouse-filter-spacer" />
        <el-form-item label="提交时间">
          <el-date-picker
            v-model="filters.submittedTime"
@@ -112,7 +119,6 @@
            <el-option v-for="status in statusOptions" :key="status" :label="status" :value="status" />
          </el-select>
        </el-form-item>
        <div class="spouse-filter-spacer" />
        <div class="spouse-employment-filter-actions">
          <el-button type="primary" :icon="Search" @click="search">查询</el-button>
          <el-button :icon="Refresh" @click="resetFilters">重置</el-button>
talent-card-admin/src/views/StaffManagementView.vue
@@ -231,7 +231,7 @@
  <div class="page-stack">
    <section class="panel">
      <div class="panel-header"><h3>账号</h3></div>
      <el-form class="filters" inline
      <el-form class="filters" inline label-position="left"
        ><el-form-item label="用户名称"
          ><el-input v-model="filters.name" /></el-form-item
        ><el-form-item label="用户电话"
@@ -248,11 +248,12 @@
            ><el-option label="启用" value="1" /><el-option
              label="禁用"
              value="0" /></el-select></el-form-item
        ><el-form-item
          ><el-button :icon="Refresh" @click="reset">重置</el-button
          ><el-button type="primary" :icon="Search"
            >搜索</el-button
          ></el-form-item
        ><el-form-item class="staff-filter-actions"
          ><div class="staff-filter-button-stack"
            ><el-button type="primary" :icon="Search"
              >查询</el-button
            ><el-button :icon="Refresh" @click="reset">重置</el-button
          ></div></el-form-item
        ></el-form
      >
      <div class="tools">
@@ -379,9 +380,7 @@
            v-model="form.status"
            :disabled="editingDefaultAdmin"
            :active-value="1"
            :inactive-value="0"
            active-text="启用"
            inactive-text="禁用" /></el-form-item></el-form
            :inactive-value="0" /></el-form-item></el-form
      ><template #footer
        ><el-button @click="visible = false">取消</el-button
        ><el-button type="primary" :loading="saving" @click="save"
@@ -393,22 +392,46 @@
</template>
<style scoped>
.filters {
  margin: 14px 20px 0;
  padding: 16px;
  display: grid;
  grid-template-columns: max-content max-content max-content max-content minmax(0, 1fr) 74px;
  gap: 12px 28px;
  align-items: start;
  margin: 14px 0 0;
  padding: 16px 0;
  border-bottom: 1px solid #d9dee8;
}
.filters :deep(.el-form-item) {
  margin: 0;
}
.filters :deep(.el-form-item__label) {
  text-align: left !important;
  justify-content: flex-start !important;
}
.filters :deep(.el-input),
.filters :deep(.el-select) {
  width: 130px;
}
.filters :deep(.staff-filter-actions) {
  grid-column: -2 / -1;
  grid-row: 1;
  margin: 0;
}
.staff-filter-button-stack {
  display: grid;
  grid-template-columns: 74px;
  gap: 10px;
}
.staff-filter-button-stack :deep(.el-button + .el-button) {
  margin-left: 0;
}
.tools {
  display: flex;
  justify-content: flex-end;
  padding: 18px 20px 14px;
  justify-content: flex-start;
  padding: 18px 0 14px;
}
.table {
  margin: 0 20px;
  width: calc(100% - 40px);
  margin: 0;
  width: 100%;
}
.full {
  width: 100%;
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessController.java
@@ -224,7 +224,7 @@
    @GetMapping("/products/{id}")
    public ApiResponse<Map<String, Object>> product(@PathVariable Long id) {
        return ApiResponse.success(adminBusinessService.product(id));
        return ApiResponse.success(adminBusinessService.productAuditRecord(id));
    }
    @PostMapping("/products")
@@ -259,7 +259,7 @@
            @PathVariable Long id,
            @Valid @RequestBody ProductAuditRequest request
    ) {
        return ApiResponse.success(adminBusinessService.auditProduct(id, request));
        return ApiResponse.success(adminBusinessService.auditProductAuditRecord(id, request));
    }
    @GetMapping("/coupons")
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -44,7 +44,7 @@
        result.put("cardCount", count("select count(*) from talent_card where deleted = 0"));
        result.put("merchantCount", count("select count(*) from merchant where deleted = 0"));
        result.put("orderCount", count("select count(*) from benefit_order where deleted = 0"));
        result.put("pendingProductCount", count("select count(*) from merchant_product where deleted = 0 and audit_status = 1"));
        result.put("pendingProductCount", count("select count(*) from merchant_product_audit_record where deleted = 0 and audit_status = 1"));
        result.put("todayVerifyCount", count("select count(*) from verify_record where date(verify_time) = current_date"));
        return result;
    }
@@ -273,7 +273,7 @@
            countSql.append(" and date(m.created_at) <= ?");
            args.add(createdEnd);
        }
        sql.append(" order by m.is_recommend desc, m.sort_no desc, m.id desc");
        sql.append(" order by m.sort_no desc, m.created_at desc, m.id desc");
        PageResult<Map<String, Object>> result = page(sql.toString(), countSql.toString(), page, size, args.toArray());
        applyBusinessStatus(result.records());
        return result;
@@ -283,7 +283,7 @@
    public List<Map<String, Object>> pendingNotices() {
        List<Map<String, Object>> notices = new ArrayList<>();
        addPendingNotice(notices, "product-audit", "团单审核", "您有 %d 条团单待审核", "/products",
                "select count(*) as pending_count, max(coalesce(submit_time, created_at)) as latest_time from merchant_product where deleted = 0 and audit_status = 1");
                "select count(*) as pending_count, max(coalesce(submit_time, created_at)) as latest_time from merchant_product_audit_record where deleted = 0 and audit_status = 1");
        addPendingNotice(notices, "family-card", "家属卡申请", "您有 %d 条家属卡申请待审核", "/family-card-applications",
                "select count(*) as pending_count, max(apply_time) as latest_time from family_card_application where deleted = 0 and status = '待审核'");
        addPendingNotice(notices, "upgrade", "升级申请", "您有 %d 条升级申请待审核", "/upgrade-applications",
@@ -719,26 +719,28 @@
            int size
    ) {
        StringBuilder sql = new StringBuilder("""
                select p.id, p.merchant_id, m.merchant_name, m.merchant_type, m.contact_mobile,
                       p.product_no, p.product_type, p.product_name,
                       p.subtitle, p.cover_url, p.original_price, p.sale_price, p.subsidy_amount,
                       p.is_government_subsidy, p.stock, p.sales_count, p.detail_text, p.benefit_desc,
                       p.purchase_notice, p.valid_start_time, p.valid_end_time, p.product_images, p.rights_config,
                       p.audit_status, p.audit_remark, p.status, p.sort_no, p.submit_time, p.created_at
                from merchant_product p
                left join merchant m on m.id = p.merchant_id
                where p.deleted = 0
                select r.id, r.product_id, r.merchant_id, m.merchant_name, m.merchant_type, m.merchant_type_code, m.contact_mobile,
                       r.product_no, r.product_type, r.product_name,
                       r.subtitle, r.cover_url, r.original_price, r.sale_price, r.subsidy_amount,
                       r.is_government_subsidy, r.stock, r.sales_count, r.detail_text, r.benefit_desc,
                       r.purchase_notice, r.valid_start_time, r.valid_end_time, r.product_images, r.rights_config,
                       r.audit_status, r.audit_remark, r.audit_by, r.audit_time, r.submit_time,
                       r.source_created_at as created_at, p.status
                from merchant_product_audit_record r
                left join merchant_product p on p.id = r.product_id
                left join merchant m on m.id = r.merchant_id
                where r.deleted = 0
                """);
        StringBuilder countSql = new StringBuilder("""
                select count(*)
                from merchant_product p
                left join merchant m on m.id = p.merchant_id
                where p.deleted = 0
                from merchant_product_audit_record r
                left join merchant m on m.id = r.merchant_id
                where r.deleted = 0
                """);
        List<Object> args = new ArrayList<>();
        if (merchantId != null) {
            sql.append(" and p.merchant_id = ?");
            countSql.append(" and p.merchant_id = ?");
            sql.append(" and r.merchant_id = ?");
            countSql.append(" and r.merchant_id = ?");
            args.add(merchantId);
        }
        if (merchantName != null && !merchantName.isBlank()) {
@@ -752,8 +754,8 @@
            args.add("%" + contactMobile.trim() + "%");
        }
        if (keyword != null && !keyword.isBlank()) {
            sql.append(" and p.product_name like ?");
            countSql.append(" and p.product_name like ?");
            sql.append(" and r.product_name like ?");
            countSql.append(" and r.product_name like ?");
            String like = "%" + keyword.trim() + "%";
            args.add(like);
        }
@@ -763,28 +765,28 @@
            args.add(MerchantTypeCatalog.canonicalCode(type));
        }
        if (status != null) {
            sql.append(" and p.audit_status = ?");
            countSql.append(" and p.audit_status = ?");
            sql.append(" and r.audit_status = ?");
            countSql.append(" and r.audit_status = ?");
            args.add(status);
        }
        if (createdStart != null && !createdStart.isBlank()) {
            sql.append(" and date(p.created_at) >= ?");
            countSql.append(" and date(p.created_at) >= ?");
            sql.append(" and date(coalesce(r.submit_time, r.created_at)) >= ?");
            countSql.append(" and date(coalesce(r.submit_time, r.created_at)) >= ?");
            args.add(createdStart);
        }
        if (createdEnd != null && !createdEnd.isBlank()) {
            sql.append(" and date(p.created_at) <= ?");
            countSql.append(" and date(p.created_at) <= ?");
            sql.append(" and date(coalesce(r.submit_time, r.created_at)) <= ?");
            countSql.append(" and date(coalesce(r.submit_time, r.created_at)) <= ?");
            args.add(createdEnd);
        }
        sql.append(" order by p.submit_time desc, p.id desc");
        sql.append(" order by coalesce(r.submit_time, r.created_at) desc, r.id desc");
        return page(sql.toString(), countSql.toString(), page, size, args.toArray());
    }
    @Transactional(readOnly = true)
    public Map<String, Object> product(Long id) {
        return one("""
                select p.id, p.merchant_id, m.merchant_name, m.merchant_type, m.contact_mobile,
                select p.id, p.merchant_id, m.merchant_name, m.merchant_type, m.merchant_type_code, m.contact_mobile,
                       p.product_no, p.product_type, p.product_name,
                       p.subtitle, p.cover_url, p.original_price, p.sale_price, p.subsidy_amount,
                       p.is_government_subsidy, p.stock, p.sales_count, p.detail_text, p.benefit_desc,
@@ -794,6 +796,23 @@
                left join merchant m on m.id = p.merchant_id
                where p.deleted = 0 and p.id = ?
                """, id);
    }
    @Transactional(readOnly = true)
    public Map<String, Object> productAuditRecord(Long auditRecordId) {
        return one("""
                select r.id, r.product_id, r.merchant_id, m.merchant_name, m.merchant_type, m.merchant_type_code, m.contact_mobile,
                       r.product_no, r.product_type, r.product_name, r.subtitle, r.cover_url,
                       r.original_price, r.sale_price, r.subsidy_amount, r.is_government_subsidy,
                       r.stock, r.sales_count, r.detail_text, r.benefit_desc, r.purchase_notice,
                       r.valid_start_time, r.valid_end_time, r.product_images, r.rights_config,
                       r.audit_status, r.audit_remark, r.audit_by, r.audit_time, r.submit_time,
                       r.source_created_at as created_at, p.status
                from merchant_product_audit_record r
                left join merchant_product p on p.id = r.product_id
                left join merchant m on m.id = r.merchant_id
                where r.deleted = 0 and r.id = ?
                """, auditRecordId);
    }
    @Transactional
@@ -828,7 +847,9 @@
                blankToNull(request.validEndTime()),
                toJson(request.productImages()),
                toJson(request.rightsConfig()));
        return one("select * from merchant_product where product_no = ?", productNo);
        Long productId = jdbcTemplate.queryForObject("select id from merchant_product where product_no = ?", Long.class, productNo);
        appendProductAuditRecord(productId);
        return product(productId);
    }
    @Transactional
@@ -866,6 +887,7 @@
                request.sortNo() == null ? 0 : request.sortNo(),
                id);
        ensureUpdated(updated);
        appendProductAuditRecord(id);
        return product(id);
    }
@@ -890,6 +912,14 @@
    @Transactional
    public Map<String, Object> auditProduct(Long id, ProductAuditRequest request) {
        List<Map<String, Object>> pendingRecords = DatabaseRows.normalize(jdbcTemplate.queryForList("""
                select id from merchant_product_audit_record
                where product_id = ? and deleted = 0 and audit_status = 1
                order by id desc limit 1
                """, id));
        if (!pendingRecords.isEmpty()) {
            return auditProductAuditRecord(Long.valueOf(String.valueOf(pendingRecords.get(0).get("id"))), request);
        }
        if (request.auditStatus() != 2 && request.auditStatus() != 3) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "Invalid audit status");
        }
@@ -904,6 +934,55 @@
                """, request.auditStatus(), request.auditRemark(), request.auditStatus() == 2 ? 1 : 0, id);
        ensureUpdated(updated);
        return product(id);
    }
    @Transactional
    public Map<String, Object> auditProductAuditRecord(Long auditRecordId, ProductAuditRequest request) {
        if (request.auditStatus() != 2 && request.auditStatus() != 3) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "Invalid audit status");
        }
        Map<String, Object> record = productAuditRecord(auditRecordId);
        Number currentAuditStatus = (Number) record.get("auditStatus");
        if (currentAuditStatus == null || currentAuditStatus.intValue() != 1) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "Product audit record is not pending");
        }
        int updated = jdbcTemplate.update("""
                update merchant_product_audit_record
                set audit_status = ?, audit_remark = ?, audit_time = now()
                where deleted = 0 and id = ? and audit_status = 1
                """, request.auditStatus(), request.auditRemark(), auditRecordId);
        ensureUpdated(updated);
        Long productId = Long.valueOf(String.valueOf(record.get("productId")));
        jdbcTemplate.update("""
                update merchant_product
                set audit_status = ?, audit_remark = ?, audit_time = now(), status = ?
                where deleted = 0 and id = ? and audit_status = 1
                """, request.auditStatus(), request.auditRemark(), request.auditStatus() == 2 ? 1 : 0, productId);
        return productAuditRecord(auditRecordId);
    }
    private void appendProductAuditRecord(Long productId) {
        jdbcTemplate.update("""
                update merchant_product_audit_record
                set audit_status = 4, audit_remark = coalesce(audit_remark, 'superseded by a new submission')
                where product_id = ? and deleted = 0 and audit_status = 1
                """, productId);
        jdbcTemplate.update("""
                insert into merchant_product_audit_record(
                    product_id, merchant_id, product_no, product_type, product_name, subtitle, cover_url,
                    original_price, sale_price, subsidy_amount, is_government_subsidy, stock, sales_count,
                    detail_text, benefit_desc, purchase_notice, valid_start_time, valid_end_time,
                    product_images, rights_config, audit_status, audit_remark, audit_by, submit_time,
                    audit_time, source_created_at
                )
                select id, merchant_id, product_no, product_type, product_name, subtitle, cover_url,
                       original_price, sale_price, subsidy_amount, is_government_subsidy, stock, sales_count,
                       detail_text, benefit_desc, purchase_notice, valid_start_time, valid_end_time,
                       product_images, rights_config, audit_status, audit_remark, audit_by, submit_time,
                       audit_time, created_at
                from merchant_product
                where id = ? and deleted = 0
                """, productId);
    }
    @Transactional(readOnly = true)
@@ -962,7 +1041,7 @@
            countSql.append(" and date(c.created_at) <= ?");
            args.add(createdEnd);
        }
        sql.append(" order by c.id desc");
        sql.append(" order by c.created_at desc, c.id desc");
        return page(sql.toString(), countSql.toString(), page, size, args.toArray());
    }
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementService.java
@@ -22,13 +22,13 @@
    public Map<String, Object> settings() { Map<String, Object> result = new LinkedHashMap<>(); for (Map<String, Object> row : rows("select setting_key, setting_value from admin_setting")) result.put(String.valueOf(row.get("settingKey")), row.get("settingValue")); return result; }
    @Transactional public Map<String, Object> saveSettings(Map<String, String> body) { for (String key : List.of("bookstoreDiscount", "hotelSubsidy", "homeVideoUrl", "miniProgramUrl")) jdbc.update("insert into admin_setting(setting_key, setting_value) values (?, ?) on duplicate key update setting_value = values(setting_value)", key, body.getOrDefault(key, "")); return settings(); }
    public List<Map<String, Object>> roles() { return rows("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role order by id"); }
    public List<Map<String, Object>> roles() { return rows("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role order by created_at desc, id desc"); }
    @Transactional public Map<String, Object> createRole(AdminRoleRequest request) { String code = roleCode(request.roleCode()); jdbc.update("insert into admin_role(role_name,role_code,remark,permission_codes,status) values (?,?,?,?,?)", request.roleName().trim(), code, request.remark(), permissions(request.permissionCodes()), request.status()); return one("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role where id=last_insert_id()"); }
    @Transactional public Map<String, Object> updateRole(Long id, AdminRoleRequest request) { if ("platform_admin".equals(existingRoleCode(id))) throw new BusinessException(ErrorCode.CONFLICT, "超级管理员角色不可编辑"); String oldCode = existingRoleCode(id); String code = request.roleCode() == null || request.roleCode().isBlank() ? oldCode : request.roleCode().trim(); if (jdbc.update("update admin_role set role_name=?,role_code=?,remark=?,permission_codes=?,status=? where id=?", request.roleName().trim(), code, request.remark(), permissions(request.permissionCodes()), request.status(), id) == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "Role does not exist"); jdbc.update("update admin_session s join admin_operator o on o.id=s.operator_id set s.revoked_at=now() where s.revoked_at is null and o.role_code in (?, ?)", oldCode, code); return one("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role where id=?", id); }
    @Transactional public Map<String, Object> updateRoleStatus(Long id, Byte status) { String code = existingRoleCode(id); if (status == 0 && "platform_admin".equals(code)) throw new BusinessException(ErrorCode.CONFLICT, "平台管理员角色不可禁用"); jdbc.update("update admin_role set status=? where id=?", status, id); return one("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role where id=?", id); }
    @Transactional public Map<String, Object> deleteRole(Long id) { String code = existingRoleCode(id); if ("platform_admin".equals(code)) throw new BusinessException(ErrorCode.CONFLICT, "平台管理员角色不可删除"); if (Boolean.TRUE.equals(jdbc.queryForObject("select exists(select 1 from admin_operator where role_code=? and deleted=0)", Boolean.class, code))) throw new BusinessException(ErrorCode.CONFLICT, "该角色仍有员工使用,不能删除"); jdbc.update("delete from admin_role where id=?", id); return Map.of("id", id, "deleted", true); }
    public List<Map<String, Object>> staff() { return rows("select id,account,display_name,mobile,role_code,position_name,customer_wechat,status,created_at from admin_operator where deleted=0 order by id"); }
    public List<Map<String, Object>> staff() { return rows("select id,account,display_name,mobile,role_code,position_name,customer_wechat,status,created_at from admin_operator where deleted=0 order by created_at desc, id desc"); }
    public Map<String, Object> staffAccountAvailable(String account) { String normalized = account == null ? "" : account.trim(); if (!normalized.matches("^[A-Za-z0-9]{1,32}$")) return Map.of("available", false); Long count = jdbc.queryForObject("select count(*) from admin_operator where account=?", Long.class, normalized); return Map.of("available", count == null || count == 0); }
    @Transactional public Map<String, Object> createStaff(AdminStaffRequest request) { if (request.password() == null || request.password().isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "A password is required for a new staff account"); if (!Boolean.TRUE.equals(staffAccountAvailable(request.account()).get("available"))) throw new BusinessException(ErrorCode.CONFLICT, "登录账号已存在,请更换"); jdbc.update("insert into admin_operator(account,password_hash,display_name,mobile,role_code,position_name,customer_wechat,status) values (?,?,?,?,?,?,?,?)", request.account().trim(), MerchantPasswordHasher.encode(request.password()), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status()); return staffOne("last_insert_id()"); }
    @Transactional public Map<String, Object> updateStaff(Long id, AdminStaffRequest request) { if (isDefaultAdmin(id)) throw new BusinessException(ErrorCode.CONFLICT, "超级管理员账号不可编辑"); if (!matchesExistingAccount(id, request.account())) throw new BusinessException(ErrorCode.CONFLICT, "登录账号不可修改"); String sql = request.password() == null || request.password().isBlank() ? "update admin_operator set account=?,display_name=?,mobile=?,role_code=?,position_name=?,customer_wechat=?,status=? where id=?" : "update admin_operator set account=?,password_hash=?,display_name=?,mobile=?,role_code=?,position_name=?,customer_wechat=?,status=? where id=?"; int updated = request.password() == null || request.password().isBlank() ? jdbc.update(sql, request.account().trim(), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status(), id) : jdbc.update(sql, request.account().trim(), MerchantPasswordHasher.encode(request.password()), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status(), id); if (updated == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "Staff account does not exist"); return staffOne("?", id); }
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
@@ -77,7 +77,7 @@
        List<Object> rowArgs = new ArrayList<>(args);
        rowArgs.add(limit);
        rowArgs.add((current - 1) * limit);
        List<Map<String, Object>> values = rows(query + where + " order by " + tableAlias + ".id desc limit ? offset ?", rowArgs.toArray());
        List<Map<String, Object>> values = rows(query + where + " order by " + tableAlias + ".created_at desc, " + tableAlias + ".id desc limit ? offset ?", rowArgs.toArray());
        Long total = jdbc.queryForObject(count + where, Long.class, args.toArray());
        return new PageResult<>(values, current, limit, total == null ? 0 : total);
    }
talent-card-server/src/main/java/com/talentcard/server/business/merchant/BankBranchDirectoryInitializer.java
@@ -34,6 +34,7 @@
    public void run(ApplicationArguments args) {
        try {
            ensureBankCardBranchCodeColumn();
            ensureBankCardHistoryIndex();
            ensureDirectoryTable();
            importDirectoryIfEmpty();
        } catch (Exception exception) {
@@ -46,6 +47,27 @@
        jdbc.execute("alter table merchant_bank_card add column branch_code varchar(12) default null after branch_name");
    }
    private void ensureBankCardHistoryIndex() {
        Integer nonUnique = jdbc.query("""
                select non_unique from information_schema.statistics
                where table_schema = database() and table_name = 'merchant_bank_card'
                  and index_name = 'uk_merchant_bank_card_hash'
                limit 1
                """, result -> result.next() ? result.getInt("non_unique") : null);
        if (nonUnique != null && nonUnique == 0) {
            jdbc.execute("alter table merchant_bank_card drop index uk_merchant_bank_card_hash");
        }
        Integer historyIndex = jdbc.query("""
                select 1 from information_schema.statistics
                where table_schema = database() and table_name = 'merchant_bank_card'
                  and index_name = 'idx_merchant_bank_card_hash'
                limit 1
                """, result -> result.next() ? 1 : null);
        if (historyIndex == null) {
            jdbc.execute("create index idx_merchant_bank_card_hash on merchant_bank_card(merchant_id, card_hash)");
        }
    }
    private void ensureDirectoryTable() {
        jdbc.execute("""
                create table if not exists bank_branch_directory (
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantManagementService.java
@@ -135,9 +135,34 @@
                where id = ? and merchant_id = ? and deleted = 0
                """, request.productName().trim(), request.salePrice(), coverUrl, images,
                value(request.detailText(), ""), jsonObject(notice), productId, principal.merchantId());
        appendProductAuditRecord(productId);
        return product(principal, productId);
    }
    private void appendProductAuditRecord(Long productId) {
        jdbc.update("""
                update merchant_product_audit_record
                set audit_status = 4, audit_remark = coalesce(audit_remark, 'superseded by a new submission')
                where product_id = ? and deleted = 0 and audit_status = 1
                """, productId);
        jdbc.update("""
                insert into merchant_product_audit_record(
                    product_id, merchant_id, product_no, product_type, product_name, subtitle, cover_url,
                    original_price, sale_price, subsidy_amount, is_government_subsidy, stock, sales_count,
                    detail_text, benefit_desc, purchase_notice, valid_start_time, valid_end_time,
                    product_images, rights_config, audit_status, audit_remark, audit_by, submit_time,
                    audit_time, source_created_at
                )
                select id, merchant_id, product_no, product_type, product_name, subtitle, cover_url,
                       original_price, sale_price, subsidy_amount, is_government_subsidy, stock, sales_count,
                       detail_text, benefit_desc, purchase_notice, valid_start_time, valid_end_time,
                       product_images, rights_config, audit_status, audit_remark, audit_by, submit_time,
                       audit_time, created_at
                from merchant_product
                where id = ? and deleted = 0
                """, productId);
    }
    @Transactional
    public Map<String, Object> updateProductStatus(MerchantPrincipal principal, Long productId, MerchantProductStatusRequest request) {
        store(principal);
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -118,16 +118,10 @@
        List<Map<String, Object>> branchRecords = rows("select clearing_code, branch_name, bank_name, head_bank_code from bank_branch_directory where clearing_code = ? limit 1", request.branchCode().trim());
        if (branchRecords.isEmpty()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择真实开户行");
        Map<String, Object> branch = branchRecords.get(0);
        List<Map<String, Object>> same = rows("select * from merchant_bank_card where merchant_id = ? and card_hash = ? limit 1", principal.merchantId(), hash);
        if (!same.isEmpty()) {
            Map<String, Object> existing = same.get(0);
            if (!"rejected".equals(clean(existing.get("status")))) return bankCard(existing);
            jdbc.update("update merchant_bank_card set status='pending', card_no_encrypted=?, reject_reason=null, provider_token_no=null, provider_apply_no=null, provider_request_no=null, provider_response_code=null, provider_response_message=null where id=?", sensitiveDataCrypto.encrypt(number), existing.get("id"));
            submitBankCardToHuifu(principal, existing, number, branch);
            return bankCard(rows("select * from merchant_bank_card where id = ?", existing.get("id")).get(0));
        }
        List<Map<String, Object>> active = rows("select id from merchant_bank_card where merchant_id = ? and card_hash = ? and status in ('pending', 'approved') order by id desc limit 1", principal.merchantId(), hash);
        if (!active.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "该银行卡已有审核中或已通过的申请,请勿重复添加");
        jdbc.update("insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, branch_code, city_code, card_tail, card_masked, card_hash, card_no_encrypted, idempotency_key) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), clean(branch.get("bankName")), clean(branch.get("branchName")), clean(branch.get("clearingCode")), request.cityCode().trim(), number.substring(number.length() - 4), "**** **** **** " + number.substring(number.length() - 4), hash, sensitiveDataCrypto.encrypt(number), key.trim());
        Map<String, Object> card = rows("select * from merchant_bank_card where merchant_id = ? and card_hash = ?", principal.merchantId(), hash).get(0);
        Map<String, Object> card = rows("select * from merchant_bank_card where merchant_id = ? and idempotency_key = ? limit 1", principal.merchantId(), key.trim()).get(0);
        submitBankCardToHuifu(principal, card, number, branch);
        return bankCard(rows("select * from merchant_bank_card where id = ?", card.get("id")).get(0));
    }
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
@@ -58,7 +58,7 @@
                left join talent_card tc on tc.id = o.card_id
                left join benefit b on b.id = o.benefit_id
                left join (select order_id, max(verify_time) verify_time from verify_record where status = 1 group by order_id) verified on verified.order_id = o.id
                """ + conditions + " order by o.id desc limit ? offset ?";
                """ + conditions + " order by o.created_at desc, o.id desc limit ? offset ?";
        List<Object> recordArguments = new ArrayList<>(arguments);
        recordArguments.add(safeSize);
        recordArguments.add((safePage - 1) * safeSize);
talent-card-server/src/main/java/com/talentcard/server/business/order/OrderService.java
@@ -251,7 +251,7 @@
                left join benefit b on b.id = r.benefit_id
                left join merchant_product p on p.id = o.product_id and p.deleted = 0
                left join merchant m on m.id = r.merchant_id
                """.formatted(ORDER_CARD_LEVEL_SQL) + where + " order by r.id desc limit ? offset ?", pageArgs.toArray());
                """.formatted(ORDER_CARD_LEVEL_SQL) + where + " order by r.created_at desc, r.id desc limit ? offset ?", pageArgs.toArray());
        Long total = jdbcTemplate.queryForObject("""
                select count(*)
                from order_refund r
@@ -327,7 +327,7 @@
                left join talent_card tc on tc.id = r.card_id
                left join benefit b on b.id = r.benefit_id
                left join merchant m on m.id = r.merchant_id
                """.formatted(ORDER_CARD_LEVEL_SQL) + where + " order by r.id desc limit ? offset ?", pageArgs.toArray());
                """.formatted(ORDER_CARD_LEVEL_SQL) + where + " order by r.verify_time desc, r.id desc limit ? offset ?", pageArgs.toArray());
        Long total = jdbcTemplate.queryForObject("""
                select count(*)
                from verify_record r
@@ -427,7 +427,7 @@
                left join benefit b on b.id = o.benefit_id
                left join merchant_product p on p.id = o.product_id and p.deleted = 0
                left join merchant m on m.id = o.merchant_id
                """.formatted(ORDER_CARD_LEVEL_SQL) + where + " order by o.id desc limit ? offset ?", pageArgs.toArray());
                """.formatted(ORDER_CARD_LEVEL_SQL) + where + " order by o.created_at desc, o.id desc limit ? offset ?", pageArgs.toArray());
        Long total = jdbcTemplate.queryForObject("""
                select count(*)
                from benefit_order o
talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java
@@ -31,7 +31,7 @@
        result.put("benefitId", benefit.get("id"));
        result.put("serviceId", serviceId);
        result.put("title", benefit.get("title"));
        result.put("applicableCards", benefit.get("applicableCards"));
        result.put("applicableCards", text(matchedRight.get("tag"), ""));
        result.put("heroImage", benefit.get("heroImage"));
        result.put("rightsText", text(matchedRight.get("detail"), text(benefit.get("rightsText"), "暂无使用说明")));
        result.put("available", available);
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalController.java
@@ -74,10 +74,11 @@
            @RequestParam(required = false) String mode,
            @RequestParam(required = false) String sortField,
            @RequestParam(required = false) String sortOrder,
            @RequestParam(required = false) Long talentId,
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int size
    ) {
        return ApiResponse.success(portalService.merchants(type, keyword, categoryId, mode, sortField, sortOrder, page, size));
        return ApiResponse.success(portalService.merchants(type, keyword, categoryId, mode, sortField, sortOrder, talentId, page, size));
    }
    @GetMapping("/topics/food")
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -205,6 +205,7 @@
            String mode,
            String sortField,
            String sortOrder,
            Long talentId,
            int page,
            int size
    ) {
@@ -234,7 +235,13 @@
                from merchant
                """ + merchantWhere + " order by sort_no asc, id asc",
                "select count(*) from merchant" + merchantWhere, page, size, args.toArray());
        List<Map<String, Object>> records = result.records().stream().map(this::enrichMerchantSearchRow).toList();
        List<Map<String, Object>> records = result.records().stream().map(row -> {
            Map<String, Object> item = enrichMerchantSearchRow(row);
            Long merchantId = item.get("id") instanceof Number id ? id.longValue() : null;
            item.put("couponAvailable", merchantId != null && hasActiveMerchantCoupon(merchantId));
            item.put("discountText", merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId));
            return item;
        }).toList();
        return new PageResult<>(records, result.page(), result.size(), result.total());
    }
talent-card-server/src/main/java/com/talentcard/server/business/portal/TalentCardPortalService.java
@@ -103,7 +103,7 @@
        card.put("name", value(row.get("name"), "-"));
        card.put("gender", genderText(row.get("gender")));
        card.put("cardNo", value(row.get("cardNo"), "-"));
        card.put("workUnit", maskWorkUnit((String) row.get("workUnit")));
        card.put("workUnit", value(row.get("workUnit"), "未填写"));
        card.put("status", status);
        card.put("statusText", cardStatusText(status));
        card.put("canShowCode", "ACTIVE".equals(status));
@@ -410,11 +410,6 @@
    private String maskCardNo(String cardNo) {
        if (cardNo == null || cardNo.isBlank()) return "-";
        return cardNo.length() <= 6 ? cardNo : cardNo.substring(0, 2) + "****" + cardNo.substring(cardNo.length() - 4);
    }
    private String maskWorkUnit(String workUnit) {
        if (workUnit == null || workUnit.isBlank()) return "未填写";
        return workUnit.length() <= 4 ? workUnit.substring(0, 1) + "***" : workUnit.substring(0, 2) + "****" + workUnit.substring(workUnit.length() - 2);
    }
    private String genderText(Object gender) {
talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentService.java
@@ -138,7 +138,7 @@
            countSql.append(" and register_time < ?");
            args.add(registerEnd.plusDays(1).atStartOfDay());
        }
        sql.append(" order by id desc limit ? offset ?");
        sql.append(" order by register_time desc, id desc limit ? offset ?");
        Object[] pageArgs = Arrays.copyOf(args.toArray(), args.size() + 2);
        pageArgs[args.size()] = safeSize;
        pageArgs[args.size() + 1] = (safePage - 1) * safeSize;
talent-card-server/src/main/java/com/talentcard/server/mapper/DisplayServiceMapper.java
@@ -47,7 +47,7 @@
                     or m.contact_mobile like concat('%', #{keyword}, '%')
                     or m.address like concat('%', #{keyword}, '%'))
              </if>
            order by m.sort_no desc, m.id desc
            order by m.sort_no desc, m.created_at desc, m.id desc
            limit #{size} offset #{offset}
            </script>
            """)
talent-card-server/src/main/java/com/talentcard/server/mapper/MerchantDisplayPolicyMerchantMapper.java
@@ -55,7 +55,7 @@
                      </foreach>
                )
              </if>
            order by m.sort_no desc, m.id desc
            order by m.sort_no desc, m.created_at desc, m.id desc
            limit #{size} offset #{offset}
            </script>
            """)
@@ -170,7 +170,7 @@
                     or m.contact_mobile like concat('%', #{keyword}, '%')
                     or m.address like concat('%', #{keyword}, '%'))
              </if>
            order by b.sort_no desc, m.sort_no desc, m.id desc
            order by b.sort_no desc, m.sort_no desc, m.created_at desc, m.id desc
            limit #{size} offset #{offset}
            </script>
            """)
talent-card-server/src/main/java/com/talentcard/server/mapper/TicketPackageCodeMapper.java
@@ -245,7 +245,7 @@
                  join merchant mm on mm.id = pm.merchant_id and mm.deleted = 0
                  where pm.package_id = p.id and pm.deleted = 0 and mm.merchant_name like concat('%', #{merchantKeyword}, '%')
              ))
            order by c.id desc
            order by c.created_at desc, c.id desc
            limit #{size} offset #{offset}
            """)
    List<Map<String, Object>> selectAdminPage(@Param("packageId") Long packageId,
talent-card-server/src/main/java/com/talentcard/server/mapper/TicketPackageMapper.java
@@ -84,7 +84,7 @@
                  where mm.id = p.merchant_id and mm.deleted = 0 and mm.merchant_name like concat('%', #{merchantKeyword}, '%')
              ))
            group by p.id
            order by p.sort_no desc, p.id desc
            order by p.sort_no desc, p.created_at desc, p.id desc
            limit #{size} offset #{offset}
            """)
    List<Map<String, Object>> selectAdminPage(@Param("merchantId") Long merchantId,
@@ -231,7 +231,7 @@
                  where pm.package_id = p.id and pm.merchant_id = #{merchantId} and pm.deleted = 0
              ))
            group by p.id
            order by p.sort_no desc, p.id desc
            order by p.sort_no desc, p.created_at desc, p.id desc
            """)
    List<Map<String, Object>> selectPortalPackages(@Param("merchantId") Long merchantId);
talent-card-server/src/main/java/com/talentcard/server/mapper/TransportCarWashServiceMapper.java
@@ -33,7 +33,7 @@
              and (#{keyword} is null or #{keyword} = '' or m.merchant_name like concat('%', #{keyword}, '%')
                   or m.address like concat('%', #{keyword}, '%') or m.contact_mobile like concat('%', #{keyword}, '%'))
              and (#{merchantId} is null or m.id = #{merchantId})
            order by m.sort_no desc, m.id desc
            order by m.sort_no desc, m.created_at desc, m.id desc
            limit #{size} offset #{offset}
            """)
    List<Map<String, Object>> selectConfigPage(@Param("merchantId") Long merchantId,
talent-card-server/src/main/java/com/talentcard/server/mapper/TransportParkingCardLevelRightMapper.java
@@ -43,7 +43,7 @@
              and (#{cardLevel} is null or #{cardLevel} = '' or r.card_level = #{cardLevel})
              and (#{enabled} is null or r.enabled = #{enabled})
              and (#{keyword} is null or #{keyword} = '' or m.merchant_name like concat('%', #{keyword}, '%') or m.address like concat('%', #{keyword}, '%'))
            order by m.sort_no desc, m.id desc,
            order by m.sort_no desc, m.created_at desc, m.id desc,
                     case r.card_level when '卓越人才卡' then 1 when '杰出人才卡' then 2 when '菁英人才卡' then 3 else 99 end
            limit #{size} offset #{offset}
            """)
@@ -132,7 +132,7 @@
              and r.status = 1
              and r.card_level = #{cardLevel}
              and (#{merchantId} is null or r.merchant_id = #{merchantId})
            order by m.sort_no desc, m.id desc
            order by m.sort_no desc, m.created_at desc, m.id desc
            """)
    List<Map<String, Object>> selectAvailableForCard(@Param("cardLevel") String cardLevel, @Param("merchantId") Long merchantId);
}
talent-card-server/src/main/java/com/talentcard/server/request/TicketPackageSaveRequest.java
@@ -5,9 +5,11 @@
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@Schema(name = "后台票包保存请求", description = "后台录入或修改票包信息")
@@ -18,7 +20,8 @@
        String packageName,
        @Schema(description = "票包数量", example = "888")
        @Min(value = 0, message = "票包数量不能小于0")
        @NotNull(message = "票包数量不能为空")
        @Min(value = 1, message = "票包数量必须为正整数")
        Integer totalQuantity,
        @Schema(description = "指定兑换商家ID", example = "1")
@@ -67,7 +70,7 @@
        Integer sortNo,
        @Schema(description = "每个票包按人才等级配置的可领数量和重置周期")
        List<TicketPackageLevelRuleRequest> levelRules
        List<@Valid TicketPackageLevelRuleRequest> levelRules
) {
        public record TicketPackageLevelRuleRequest(
                @Schema(description = "人才卡等级", example = "卓越人才卡")
@@ -78,6 +81,7 @@
                Integer enabled,
                @Schema(description = "可领数量", example = "2")
                @NotNull(message = "可领数量不能为空")
                @Min(value = 1, message = "可领数量必须大于0")
                Integer claimableCount,
talent-card-server/src/main/resources/application.yml
@@ -54,12 +54,12 @@
      # Keep disabled until the Huifu test/production product, merchant and keys are provisioned.
      enabled: true
      environment: prod
      system-id: 6666000212191071
      system-id: 6666000226048687
      product-id: KAZX
      merchant-id: 6666000212191071
      merchant-id: 6666000226048687
      mini-program-app-id: wxcfee477acb54d179
      private-key: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCcsR3R3nUFCv/4l6vczGk6j4UgZhuMm/fTs2MQQqFkIuoISd38TOiIMaLS3KLYL+D+qhUCps56svOK/3M/kdyXfPsBphJyXB5Ar7il8pJ6BDa38StzTrlRm+IXX+4diJvqxUimetjpx0OfnqRkNYQWc/ZVeoEdQbITqLyxogy0R3rkR/wnOTwxHBbkCYsDCxSon4W0SoM5fLs3o947INbPUbUsf0vL5l6NiqlV9YsZ3qAWvvPoPX4HI1s5maMiVKfUwe27P3ARW9n6n3tadBzbB6lrmAEcFoihXpZjNN9UaRKjYfYjTS9JRkDqRyYGhz5cHp9GthEksMvhLP84oEanAgMBAAECggEAKV6d5+Ya8TzpPiIVTxNtCPgRhu9OgL5BHVS0Z/ufjselEJEoGc/YaPTp71AvcnKZrcMMDwopY73RjTZKoFMLcbeesd296xGlLyD12GKGB88K/1+eQDBdv00v0UoTnRqPR9nUh5fRo09n7EG/V3HFsQPm2sSgSDArKwOW5jNJtWLsGIVCW8gg3RQ9YlZ5HW85neBnjBFnrMA9S/hxwZD336M8CiViIa0v9o5CS7qxAqXMzzoOC7Vr3hZ/X0i/5GrSH9DRO7ougSmP4F/zCEc2dViA0rOJ6m3OuS0osh5FulihOBOjI5uLETEt4K/7PuV9xOd4oAz0OBpJ2bhPf3YpGQKBgQDQdEcmnlULa3jTSrbr0vYA3iMpcls/8F8+I/QymMXHAUeVyc8XKuGzblx+/n3x8ZoAvdVg1ZAO5wgLh/P0BEL74iWhfHedjt8xcdk2R5U0RuOnx1TUTKrmLKRZ5gStNFnGlK4yVwpiNqHn/cr+gYYeZSPARFVi00WdXbqVbEEf2wKBgQDAbmo4uHrmHN5GYvt8lVmT9aha1IpKOOLenjd+Y/vKGJcvHzOVCHnOh8OGO/4p7CxOq0sshER713jW2cFGO3xpFEFpIc45pDQ8Ut0XuSoOjKtwPzid8vKX7A1If6FDLMNy7bYR1qqnIJNJnN/f3lsksX+es7QTuRShhA0X0mnEJQKBgBE5OlPHi1FZPO+AYeLUWH0z3YXkLAP5cXUp9Vhk5RqPUYHf3duVMHbp7HyVvIRMKSRciw/oNXH1xuVlA1QrMfmr8t4H8b4z+t8UABWLDpn2LYNEZg7wksJX9/Bxn/tLswtEZ6xDItmAmBoaDza+Lv2975lITCHRQRRClVMZUbw3AoGAVFrSs6SjM2aqRZ60ccuipgrFT3PmX10Kjt46o2w+/zvCYoTB/u3lP5UiMCDybPw7/Ax4+r3Mq6EsNrrkT95ZzlJzzhtkpL4Hq/4DL5gbCJ+/s4fzUMIkeFahCAvy7FieIS51ZuJ8kqDkEBv1w9RcjfxFXpOWOCMlGRyjBLNXbAUCgYEAjcTEuXctlGEjtO1MBaHc4hkkpiPG7gxV/Bq1XXLszv4LTaOfDEpjGGBpVYtb3URYrif/HzX8T1KHELvPziuqP2ghaJQzbztep1TGQ48A5VpOvHdA/w/+k4JJl0PyKBPrFXiHIw1if7rnvOLt27TymwXSaJquSz7Sz2+wXFxzKJk=
      platform-public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAgxOuXt1UEdZNPk6sntqiIlcs7e+0fZ3CVgKMV2Qv4ixwKmha5etL5WyIONmjoGEHePHpanYkXvm0kHkbbDkjpyHDFdEshuP1eGx5tRUTISRvXY/iFuD3xyeWSsvvjW6hS29OvqVCPcnBUC7jcRj15dJS2IpYR0iDGNS+/M0TyOFwRQME07At+qO+MSjKu4aRIjlglukjD7YbE2s35uV066nsBUCZUNpGPuju1pieUW4zyoIW6K70dyXCQy61oRI6JmA4JRCOJV1BE80N3GrLI4Pbq1C3ZBTgOPi5RWYNq1hfu807BDkPLc3dJKYt22Ea1jPJsg6e/B9Xbzh6JaUNAQIDAQAB
      private-key: MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCzPfipd3gxqHSr/ZiZG4hGXXulhXY9PCdf644SiE8IpH2Gf8pmvfTgs77FoyWVAPmYyF3JrQGQTlKjtHzkUGW20FETwopDwh7KP9zUvrHRHsAhCEi+kkD+4OO4BgP6I4sxJRANcVQM/G0woScxdHJB74gxVVnlxr3RpiR2aKyaEX6jCIsYiCXcuNJ7tpjX5H6pnFEldjWQpJgd1QnLrybgf66mEzQ/ym7SvaQ8oNWiILcpmUsAOUMwMgEr4J9MWTW/XH78++GtvrjEH311lmOk7+cM9qhGzvguXUFQkLnckseYnYAoEUaXmioBXu5qrqifMGZYCThLX9pzbJsFgswxAgMBAAECggEAbVdxmJert8dGnSDPdR8Ji7s9UWTQE1zdvDLq8T070U7e0CymoPRiLK0ZxGDY7UOx/1TDeeLTIF/XI8xXn8O3uMTtoLs9M0Z5F/HLWfCWcrZqh/wQvEhl49X2tGzfAjmqkKlGWJz4pA9c4Jx+Vzns9XIUx9uEQppxczIB7kW+fSEHGGIrilMbFPDWlqkjMuMBN7pDNUCMh9zEYCweLAzB1ZM0W3DVrRntCHJj1nLEFmBAsVDDy5J/m1Ew1SQ0252Ky8arYF6FjeOpnzw8WbCAXk6Cb3hjz83KWyoLXMzrHvkzEaR8eSaB5rmDP2Ukz5FIclqK91aMFVL8OQzq7Vh2fQKBgQDkWQIFK8s+YasyqcM+goXeYh+89anwbSbFB3yWrkIJrfh2aCjV0i/71tqh3aGKIZXokjoBR6ASWlHHtYJtAoms5wdvp6W/8phWlpM4clEsb8BHlQhWy+XCvH+KVVZAQrNMsAZPtUp/FhURnXJm7c7A9z3a0O10pSRu0ZuDcWc9OwKBgQDI8qS85p5Z5uy9WWcU676wS7dXyXK6Ev6Z3gMQpj2sGwCDOj0eZr3QZnLkFzLqV87pp5hsqX2xjphOrlVFKQDyWES5VF0hkX6jRMyodRw72FNEBAtEHXTaLY8QuBS6Sdxktywa50KZzbyJJ3RZgdFCpMwLxMakRG090nzhsu/1gwKBgQDA6pHSXJVxNoK0YsgY35zJrIyzz7iq7wx6GUEg7EfOdbQcFROq0dDIK1S6u8vmL0m/Gwr2WdTarIO6/ZYbErHN6cFWi4R1Dje1r7DhV3NM33zK/RX2fny2cRovfLlJwRBKEczDOp6dj8JtB1/HpdGvJTKU0z5J9ds55MxF2GEoUQKBgQCFp44W7rVISqYP0xUS/zIYBGRwCVc7mEKriNU1D71ZaaRPpD7ep6t8n4evWhbNc5X2JzjknV8y6As52g5xL7KnfjpHomtHVoSKh8owCaS70UigpqfvqD/+lergTe/LcDEc9BBr3IGZ1xOdjhZ/Ki9LSl1FPp54x+J5CjVx0Rh1jQKBgGfyr/mG9BMoXu6tHUoxxY5xw0Q8IA2N0kcSoilp233QQRBnxnO4zm7FITpnovaGvTj9LT6K+kDWxDTGG/Da+qzxtAMquJ5ZRpWv3fGEu6jzsJWcxTLMTiNroezq8oynNQbVqLlst1hl0LP6hX60b4jjEKHcQQKCerGav19Ay2Hj
      platform-public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhRV2nN2xnEGw4giX8O/K3j3UdD80QKeigZqkPaVPULBc8D/Ov9bzioz/eMsprbI1grU8M6LCcGC0eFgBpA5YHxKzL4bjiAxzYbbzK/l+LaqD1Xzg4/k5qYiPe7I8BUU5FcpR37sRgO+14dwUYtFyjuIDENcsiUcadEpt45xxfFDKm+xaKtEBGDYS4fRQoVd/QvXTf6dZwWyK15uB84gqyjQFwJF+r4FtmSwe11bLf83D9M5nHpfZ8xiRNPGUXWaoeX6TT/QMY94Ep5hdkqGiNYJsk8Fdvdqkpelqqk4EGeGNBoHcvEZ6pIorFNUTZ0L99BI8poOGia+71W82xd20vwIDAQAB
      notify-url: ${HUIFU_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/notify}
      withdrawal-product-id: ${HUIFU_WITHDRAWAL_PRODUCT_ID:KAZX}
      withdrawal-notify-url: ${HUIFU_WITHDRAWAL_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/withdrawal-notify}
talent-card-server/src/main/resources/db/migration/V122__merchant_bank_card_application_history.sql
New file
@@ -0,0 +1,5 @@
alter table merchant_bank_card
    drop index uk_merchant_bank_card_hash;
create index idx_merchant_bank_card_hash
    on merchant_bank_card(merchant_id, card_hash);
talent-card-server/src/main/resources/db/migration/V123__merchant_product_audit_history.sql
New file
@@ -0,0 +1,53 @@
create table if not exists merchant_product_audit_record (
    id bigint unsigned not null auto_increment,
    product_id bigint unsigned not null,
    merchant_id bigint unsigned not null,
    product_no varchar(64) not null,
    product_type varchar(64) not null,
    product_name varchar(128) not null,
    subtitle varchar(255) default null,
    cover_url varchar(500) default null,
    original_price decimal(10,2) not null default 0.00,
    sale_price decimal(10,2) not null default 0.00,
    subsidy_amount decimal(10,2) not null default 0.00,
    is_government_subsidy tinyint not null default 0,
    stock int not null default -1,
    sales_count int not null default 0,
    detail_text text,
    benefit_desc text,
    purchase_notice json default null,
    valid_start_time datetime default null,
    valid_end_time datetime default null,
    product_images json default null,
    rights_config json default null,
    audit_status tinyint not null default 0,
    audit_remark varchar(500) default null,
    audit_by bigint unsigned default null,
    submit_time datetime default null,
    audit_time datetime default null,
    source_created_at datetime default null,
    created_at datetime not null default current_timestamp,
    deleted tinyint not null default 0,
    primary key (id),
    key idx_product_audit_record_product (product_id, id),
    key idx_product_audit_record_status (audit_status),
    key idx_product_audit_record_submit_time (submit_time),
    key idx_product_audit_record_merchant (merchant_id)
) engine=InnoDB default charset=utf8mb4 comment='merchant product audit history';
insert into merchant_product_audit_record(
    product_id, merchant_id, product_no, product_type, product_name, subtitle, cover_url,
    original_price, sale_price, subsidy_amount, is_government_subsidy, stock, sales_count,
    detail_text, benefit_desc, purchase_notice, valid_start_time, valid_end_time,
    product_images, rights_config, audit_status, audit_remark, audit_by, submit_time,
    audit_time, source_created_at
)
select p.id, p.merchant_id, p.product_no, p.product_type, p.product_name, p.subtitle, p.cover_url,
       p.original_price, p.sale_price, p.subsidy_amount, p.is_government_subsidy, p.stock, p.sales_count,
       p.detail_text, p.benefit_desc, p.purchase_notice, p.valid_start_time, p.valid_end_time,
       p.product_images, p.rights_config, p.audit_status, p.audit_remark, p.audit_by, p.submit_time,
       p.audit_time, p.created_at
from merchant_product p
where not exists (
    select 1 from merchant_product_audit_record r where r.product_id = p.id
);