yeg
2026-09-17 75e20ed49063feac161beca13aba24147424d5a7
Merge remote-tracking branch 'origin/main'
4个文件已添加
33个文件已修改
990 ■■■■■ 已修改文件
rencaika/config/development-server.uts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/external-webview/external-webview.uvue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/index/index.uvue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/merchant/merchant.uvue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/news-detail/news-detail.uvue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/news/news.uvue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/plate-info/plate-info.uvue 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/travel-benefits/travel-benefits.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/config/development-server.uts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/free-stay-confirm/free-stay-confirm.uvue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/api/admin.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/layouts/AdminLayout.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/style.css 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/types/admin.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/EquityImageView.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/FamilyCardView.vue 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MovieTicketView.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/PlatformSettingsView.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentPolicyEditView.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentPolicyPublishView.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/vite.config.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessController.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java 178 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantCreateRequest.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantUpdateRequest.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/ServiceHeroImageRequest.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java 189 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/application.yml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V148__policy_article_top_time.sql 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V149__clear_service_hero_placeholders.sql 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V150__merchant_hero_image.sql 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/config/development-server.uts
@@ -16,8 +16,13 @@
 * 清了这条 key 就回到开发者工具适用的默认值。
 */
/** 开发者工具与本机后台:回环地址,跟网络怎么变无关。 */
const loopbackDevelopmentBaseUrl = 'http://192.168.110.26:8080'
/**
 * 开发者工具与本机后台:回环地址,跟网络怎么变无关。
 *
 * 不要写具体局域网 IP(曾经写成 192.168.110.26:8080,别人拉到代码后所有请求都打到那台机器上,
 * 表现成"小程序读不到数据")。127.0.0.1 在开发者工具里永远指向本机后台。
 */
const loopbackDevelopmentBaseUrl = 'http://127.0.0.1:8080'
/** 覆盖用的 storage key,真机调试时写入开发机局域网地址。 */
const overrideStorageKey = 'development-api-base-url'
rencaika/pages/external-webview/external-webview.uvue
@@ -78,9 +78,12 @@
    }
    const loadHomeMiniProgram = () => {
        getAppHome((data: any) => {
            const appId = data != null && data.miniProgramAppId != null ? data.miniProgramAppId as string : ''
            const path = data != null && data.miniProgramPath != null ? data.miniProgramPath as string : ''
            if (appId != '') { openTargetMiniProgram(appId, path); return }
            const url = data != null && data.miniProgramUrl != null ? data.miniProgramUrl as string : ''
            if (!isSafeHomeLinkUrl(url)) {
                showInvalid('首页跳转链接不可用', '请在后台配置有效链接;生产环境请使用 HTTPS')
                showInvalid('首页跳转链接不可用', '请在后台配置目标小程序 AppID,或配置 HTTPS 链接')
                return
            }
            targetUrl.value = url
@@ -91,6 +94,22 @@
            stateDescription.value = '请检查网络后重新加载'
        })
    }
    const openTargetMiniProgram = (appId: string, path: string) => {
        stateTitle.value = '正在打开小程序'
        stateDescription.value = '请在微信弹窗中确认'
        uni.navigateToMiniProgram({
            appId: appId,
            path: path,
            success: () => {
                pageState.value = 'invalid'
                stateTitle.value = '已打开目标小程序'
                stateDescription.value = '返回后可继续浏览人才服务平台'
            },
            fail: () => {
                showInvalid('跳转小程序失败', '请确认已在 app.json 声明目标小程序后再试')
            }
        })
    }
    const loadAdvisorService = () => {
        getTalentCardProfile(talentId.value, (data: any) => {
            const url = data != null && data.advisor != null && data.advisor.consultUrl != null ? data.advisor.consultUrl as string : ''
rencaika/pages/index/index.uvue
@@ -56,6 +56,9 @@
    const homeError = ref('')
    const homeVideoUrl = ref('')
    const miniProgramUrl = ref('')
    // 后台可配置目标小程序 AppID,配置后"幸福东区"等入口直接用小程序跳转,不再走 web-view。
    const miniProgramAppId = ref('')
    const miniProgramPath = ref('')
    onLoad(() => {
        // #ifdef MP-WEIXIN
@@ -89,6 +92,8 @@
            homeLoading.value = false
            homeVideoUrl.value = data != null && data.homeVideoUrl != null ? data.homeVideoUrl as string : ''
            miniProgramUrl.value = data != null && data.miniProgramUrl != null ? data.miniProgramUrl as string : ''
            miniProgramAppId.value = data != null && data.miniProgramAppId != null ? data.miniProgramAppId as string : ''
            miniProgramPath.value = data != null && data.miniProgramPath != null ? data.miniProgramPath as string : ''
            const articles: any[] = data != null && data.news instanceof Array ? data.news as any[] : []
            if (articles.length == 0) return
            const first: any = articles[0]
@@ -118,6 +123,16 @@
        uni.navigateTo({ url: '/pages/home-video/home-video?url=' + encodeURIComponent(resolveApiUrl(homeVideoUrl.value)) })
    }
    const openIntro = () => {
        if (miniProgramAppId.value != '') {
            uni.navigateToMiniProgram({
                appId: miniProgramAppId.value,
                path: miniProgramPath.value,
                fail: () => {
                    uni.showToast({ title: '跳转小程序失败,请稍后重试', icon: 'none' })
                }
            })
            return
        }
        if (miniProgramUrl.value == '') {
            uni.showToast({ title: '首页跳转链接暂未配置', icon: 'none' })
            return
rencaika/pages/merchant/merchant.uvue
@@ -270,6 +270,19 @@
        return level
    }
    const sameCardLevel = (configured: any, actual: string): boolean => normalizeCardLevel(configured) == normalizeCardLevel(actual)
    // 商家可以按“卓越人才卡(本地)/(外地)”分开配置权益;人才地区已知时不能拿地区对不上的配置来展示。
    const levelAreaOf = (value: any): string => {
        const level = value == null ? '' : ('' + value).trim().toUpperCase()
        if (level.indexOf('外地') >= 0 || level.indexOf('OUTSIDE') >= 0) return 'OUTSIDE'
        if (level.indexOf('本地') >= 0 || level.indexOf('LOCAL') >= 0) return 'LOCAL'
        return ''
    }
    const talentAreaOf = (data: any): string => {
        const raw = data != null && data.sourceAreaType != null ? ('' + data.sourceAreaType).trim().toUpperCase() : ''
        if (raw.indexOf('外地') >= 0 || raw.indexOf('OUTSIDE') >= 0) return 'OUTSIDE'
        if (raw.indexOf('本地') >= 0 || raw.indexOf('LOCAL') >= 0) return 'LOCAL'
        return ''
    }
    const isRightEnabled = (value: any): boolean => value === true || Number(value) == 1 || ('' + value).trim().toLowerCase() == 'true'
    const parseRightsConfig = (value: any): any[] => {
        if (value == null || value == '') return []
@@ -306,9 +319,12 @@
        if (cardLevel == '') return
        let matched: any = null
        let disabledMatched: any = null
        const talentArea = talentAreaOf(data)
        for (let index = 0; index < rights.length; index++) {
            const item: any = rights[index]
            if (!sameCardLevel(rightLevel(item), cardLevel)) continue
            const configuredArea = levelAreaOf(rightLevel(item))
            if (talentArea != '' && configuredArea != '' && configuredArea != talentArea) continue
            if (isRightEnabled(item != null ? item.enabled : null)) {
                matched = item
                break
@@ -507,7 +523,12 @@
            groupBuyingEnabled.value = false
            merchantType.value = 'fuel'
            const heroes: any[] = data != null && data.heroImages instanceof Array ? data.heroImages as any[] : []
            if (heroes.length > 0 && heroes[0].imageUrl != null && heroes[0].imageUrl != '') merchantHero.value = resolveApiUrl(heroes[0].imageUrl as string)
            if (heroes.length > 0 && heroes[0].imageUrl != null && heroes[0].imageUrl != '') {
                merchantHero.value = resolveApiUrl(heroes[0].imageUrl as string)
            } else if (data != null && data.coverUrl != null && data.coverUrl != '') {
                // 没配置详情页图片时用商家封面图兜底,避免顶部大图空白。
                merchantHero.value = resolveApiUrl(data.coverUrl as string)
            }
            fuelBenefitId.value = data != null && data.benefit != null && data.benefit.benefitId != null ? '' + data.benefit.benefitId : ''
            merchantLocation.value = {
                latitude: data != null && data.latitude != null ? Number(data.latitude) : 0,
rencaika/pages/news-detail/news-detail.uvue
@@ -3,7 +3,7 @@
        <view class="nav-shell">
            <view class="safe-space" :style="'height:' + navTop + 'px'"></view>
            <view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'">
                <view class="back" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view>
                <view class="nav-back" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view>
                <text class="nav-title">资讯详情</text>
                <view class="capsule-reserve" :style="'width:' + navWidth + 'px'"></view>
            </view>
@@ -243,9 +243,9 @@
    .page { display: flex; flex-direction: column; width: 100%; height: 100vh; background-color: #FFFFFF; }
    .nav-shell { flex-shrink: 0; background-image: linear-gradient(180deg, #FFF0B8 0%, #FFF8E4 58%, #FFFFFF 100%); }
    .safe-space { height: 28px; }
    .nav-row { display: flex; position: relative; height: 32px; align-items: center; justify-content: flex-end; box-sizing: border-box; }
    .back { display: flex; position: absolute; z-index: 2; left: 24rpx; top: 0; width: 72rpx; height: 100%; justify-content: center; align-items: center; }
    .back image { width: 38rpx; height: 38rpx; }
    .nav-row { display: flex; position: relative; height: 32px; box-sizing: border-box; align-items: center; justify-content: flex-end; }
    .nav-back { display: flex; position: absolute; z-index: 9; left: 16rpx; top: 0; bottom: 0; width: 88rpx; min-height: 64rpx; justify-content: center; align-items: center; }
    .nav-back image { width: 40rpx; height: 40rpx; }
    .nav-title { position: absolute; left: 190rpx; right: 190rpx; color: #171717; font-size: 35rpx; font-weight: 700; text-align: center; white-space: nowrap; }
    .capsule-reserve { width: 88px; height: 100%; flex-shrink: 0; }
    .nav-bottom-space { height: 34rpx; }
rencaika/pages/news/news.uvue
@@ -3,7 +3,7 @@
        <view class="nav-shell">
            <view class="safe-space" :style="'height:' + navTop + 'px'"></view>
            <view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'">
                <view class="back" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view>
                <view class="nav-back" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view>
                <text class="nav-title">人才资讯</text>
                <view class="capsule-reserve" :style="'width:' + navWidth + 'px'"></view>
            </view>
@@ -189,8 +189,9 @@
    .page { display: flex; flex-direction: column; width: 100%; height: 100vh; background-color: #FFFFFF; }
    .nav-shell { flex-shrink: 0; background-image: linear-gradient(180deg, #FFF0B8 0%, #FFF8E4 58%, #FFFFFF 100%); }
    .safe-space { height: 28px; }
    .nav-row { display: flex; position: relative; height: 32px; align-items: center; justify-content: flex-end; box-sizing: border-box; }
    .back { display: flex; position: absolute; z-index: 2; left: 24rpx; top: 0; width: 72rpx; height: 100%; justify-content: center; align-items: center; } .back image { width: 38rpx; height: 38rpx; }
    .nav-row { display: flex; position: relative; height: 32px; box-sizing: border-box; align-items: center; justify-content: flex-end; }
    .nav-back { display: flex; position: absolute; z-index: 9; left: 16rpx; top: 0; bottom: 0; width: 88rpx; min-height: 64rpx; justify-content: center; align-items: center; }
    .nav-back image { width: 40rpx; height: 40rpx; }
    .nav-title { position: absolute; left: 190rpx; right: 190rpx; color: #171717; font-size: 35rpx; font-weight: 700; text-align: center; white-space: nowrap; }
    .capsule-reserve { width: 88px; height: 100%; flex-shrink: 0; }
    .nav-bottom-space { height: 34rpx; }
rencaika/pages/plate-info/plate-info.uvue
@@ -12,15 +12,19 @@
        <view class="form-content">
            <view v-if="requiresParking" class="field-group parking-field" @tap.stop>
                <text class="field-label">停车场</text>
                <view class="input-box"><input v-model="parkingKeyword" placeholder="搜索停车场" placeholder-class="input-placeholder" confirm-type="search" @focus="openParkingResults" @input="handleParkingInput" /><image src="/static/benefits-assets/search.svg" mode="aspectFit" @tap="openParkingResults"></image></view>
                <view v-if="showParkingResults" class="parking-results">
                    <view v-if="parkingSearchLoading" class="parking-result-empty"><text>正在搜索停车场</text></view>
                    <scroll-view v-else-if="filteredParkingLots.length > 0" class="parking-result-scroll" scroll-y>
                        <view class="parking-result-item" v-for="item in filteredParkingLots" :key="item.id" @tap.stop="selectParking(item)"><text class="parking-result-name">{{item.name}}</text><text class="parking-result-address">{{item.address}}</text></view>
                    </scroll-view>
                    <view v-else class="parking-result-empty"><text>暂无匹配停车场</text></view>
                </view>
                <view class="input-box" @tap.stop="openParkingResults"><input v-model="parkingKeyword" placeholder="搜索停车场" placeholder-class="input-placeholder" confirm-type="search" @focus="openParkingResults" @confirm="openParkingResults" @input="handleParkingInput" /><view class="input-search-button"><image src="/static/benefits-assets/search.svg" mode="aspectFit"></image></view></view>
                <text v-if="parkingError != ''" class="field-error">{{parkingError}}</text>
            </view>
            <!-- 搜索结果必须挂在 .form-content 上(它 position:relative 且足够高)。
                 如果放在“停车场”字段里面,会被该字段自带的 overflow:hidden 裁掉,只会露出半行文字。 -->
            <view v-if="requiresParking && showParkingResults" class="parking-results" :style="'height:' + parkingResultsHeight">
                <view v-if="parkingSearchLoading" class="parking-result-empty"><text>正在搜索停车场</text></view>
                <view v-else-if="filteredParkingLots.length > 0" class="parking-result-list">
                    <view class="parking-result-item" v-for="item in visibleParkingLots" :key="item.id" @tap.stop="selectParking(item)"><text class="parking-result-name">{{item.name}}</text><text class="parking-result-address">{{item.address}}</text></view>
                    <view v-if="filteredParkingLots.length > visibleParkingLots.length" class="parking-result-more"><text>还有 {{filteredParkingLots.length - visibleParkingLots.length}} 个停车场,请继续输入关键词</text></view>
                </view>
                <view v-else class="parking-result-empty"><text>暂无匹配停车场</text></view>
            </view>
            <view class="field-group plate-field" :class="requiresParking ? 'with-parking' : ''">
@@ -84,6 +88,22 @@
        { id: 8, name: '五十四交通银行旁停车场', address: '供销社背后金星巷3号' }
    ]
    const filteredParkingLots = ref(parkingLots)
    // 下拉最多展示 5 条:再多就提示继续输入关键词,避免下拉把页面撑爆或需要内部滚动。
    const parkingResultLimit = 5
    const visibleParkingLots = computed((): any[] => {
        const all: any[] = filteredParkingLots.value as any[]
        return all.slice(0, parkingResultLimit)
    })
    // 下拉高度由 JS 明确算出来(列表 + 可能出现的“还有 N 个”提示行 + 上下边框),
    // 不依赖容器自动撑高,避免部分机型上下拉被压扁只露出半行。
    const parkingResultsHeight = computed((): string => {
        if (parkingSearchLoading.value) return '116rpx'
        const list: any[] = visibleParkingLots.value as any[]
        if (list.length == 0) return '116rpx'
        let height = list.length * 92
        if (filteredParkingLots.value.length > list.length) height += 64
        return '' + (height + 4) + 'rpx'
    })
    onLoad((options: any) => {
        // #ifdef MP-WEIXIN
@@ -152,8 +172,18 @@
            })
        }, 250)
    }
    const openParkingResults = () => { showParkingResults.value = true; scheduleParkingSearch(parkingKeyword.value.trim()) }
    const closeParkingResults = () => { showParkingResults.value = false }
    // 打开下拉的瞬间,同一次点击可能会冒泡到页面根的关闭处理;
    // 记录打开时间,短时间内到达的关闭请求忽略,避免"点了搜索没反应"。
    let parkingResultsOpenedAt = 0
    const openParkingResults = () => {
        parkingResultsOpenedAt = Date.now()
        showParkingResults.value = true
        scheduleParkingSearch(parkingKeyword.value.trim())
    }
    const closeParkingResults = () => {
        if (Date.now() - parkingResultsOpenedAt < 250) return
        showParkingResults.value = false
    }
    const handleParkingInput = (event: any) => {
        const keyword = event.detail.value as string
        parkingKeyword.value = keyword
@@ -222,14 +252,18 @@
    .back-button image { width: 36rpx; height: 36rpx; }
    .nav-title { position: absolute; left: 50%; color: #171B22; font-size: 37rpx; line-height: 48rpx; font-weight: 700; transform: translateX(-50%); white-space: nowrap; }
    .capsule-space { height: 100%; flex-shrink: 0; }
    .form-content { padding: 47rpx 54rpx 0; }
    /* relative 供搜索结果下拉定位;overflow 放开,避免把下拉裁掉。 */
    .form-content { position: relative; padding: 47rpx 54rpx 0; overflow: visible; }
    .field-group { display: flex; position: relative; flex-direction: column; }
    .parking-field { z-index: 20; }
    .parking-field { z-index: 20; overflow: visible; }
    .plate-field { z-index: 1; }
    .field-label { color: #70798A; font-size: 25rpx; line-height: 36rpx; font-weight: 600; }
    .input-box { display: flex; height: 86rpx; margin-top: 17rpx; box-sizing: border-box; padding: 0 24rpx; border-width: 2rpx; border-style: solid; border-color: #E1E5EB; border-radius: 14rpx; background-color: #FFFFFF; flex-direction: row; align-items: center; }
    .input-box input { height: 82rpx; min-width: 0; flex: 1; color: #2D333C; font-size: 28rpx; }
    .input-box image { width: 39rpx; height: 39rpx; margin-left: 16rpx; flex-shrink: 0; }
    /* 放大镜的点击区域放大到整块右侧区域,避免小图标点不中。 */
    .input-search-button { display: flex; width: 72rpx; height: 86rpx; margin-left: 6rpx; margin-right: -14rpx; flex-shrink: 0; justify-content: center; align-items: center; }
    .input-search-button image { width: 39rpx; height: 39rpx; }
    .input-placeholder { color: #BBC1CA; font-size: 27rpx; }
    .field-error { margin-top: 9rpx; color: #F05C3A; font-size: 22rpx; }
    .plate-field { margin-top: 83rpx; }
@@ -250,11 +284,16 @@
    .submit-button { display: flex; height: 86rpx; border-radius: 44rpx; background-image: linear-gradient(90deg, #FF4B32 0%, #FF8A1E 100%); justify-content: center; align-items: center; }
    .submit-button text { color: #FFFFFF; font-size: 29rpx; line-height: 40rpx; font-weight: 600; }
    .submit-tip { display: block; margin-top: 31rpx; color: #A6ADB8; font-size: 21rpx; line-height: 32rpx; text-align: center; }
    .parking-results { position: absolute; z-index: 30; left: 0; right: 0; top: 140rpx; overflow: hidden; border-width: 2rpx; border-style: solid; border-color: #E1E5EB; border-radius: 14rpx; background-color: #FFFFFF; box-shadow: 0 12rpx 30rpx rgba(62,73,90,.12); }
    .parking-result-scroll { width: 100%; height: 312rpx; }
    .parking-result-item { display: flex; min-height: 78rpx; box-sizing: border-box; padding: 13rpx 22rpx; border-bottom-width: 1rpx; border-bottom-style: solid; border-bottom-color: #EEF0F4; flex-direction: column; justify-content: center; }
    .parking-result-name { color: #333A45; font-size: 25rpx; line-height: 34rpx; }
    .parking-result-address { margin-top: 3rpx; color: #929AA7; font-size: 20rpx; line-height: 29rpx; }
    /* 挂在 .form-content 上:left/right 用表单左右内边距,正好和输入框等宽;
       top = 47rpx(表单上边距) + 36rpx(标签) + 17rpx(间距) + 86rpx(输入框) = 186rpx,即输入框正下方。 */
    .parking-results { position: absolute; z-index: 30; left: 54rpx; right: 54rpx; top: 186rpx; overflow: visible; border-width: 2rpx; border-style: solid; border-color: #E1E5EB; border-radius: 14rpx; background-color: #FFFFFF; box-shadow: 0 12rpx 30rpx rgba(62,73,90,.12); }
    /* 高度由 parkingResultsHeight 内联给出;列表只负责把条目排开。 */
    .parking-result-list { display: flex; flex-direction: column; }
    .parking-result-item { display: flex; height: 92rpx; min-height: 92rpx; box-sizing: border-box; padding: 13rpx 22rpx; border-bottom-width: 1rpx; border-bottom-style: solid; border-bottom-color: #EEF0F4; flex-direction: column; flex-shrink: 0; justify-content: center; }
    .parking-result-name { color: #333A45; font-size: 25rpx; line-height: 34rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .parking-result-address { margin-top: 3rpx; color: #929AA7; font-size: 20rpx; line-height: 29rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .parking-result-more { display: flex; height: 64rpx; justify-content: center; align-items: center; }
    .parking-result-more text { color: #A4ABB5; font-size: 21rpx; }
    .parking-result-empty { display: flex; height: 112rpx; justify-content: center; align-items: center; }
    .parking-result-empty text { color: #A4ABB5; font-size: 23rpx; }
</style>
rencaika/pages/travel-benefits/travel-benefits.uvue
@@ -174,4 +174,8 @@
    .service-section { padding: 48rpx 31rpx 0; } .section-heading { display: flex; width: 132rpx; height: 72rpx; flex-direction: column; align-items: center; } .section-heading text { color: #363636; font-size: 32rpx; font-weight: 700; } .section-heading view { width: 61rpx; height: 7rpx; margin-top: 12rpx; border-radius: 4rpx; background-image: linear-gradient(90deg,#FF4D36,#FFBF3A); }
    .service-item { display: flex; min-height: 205rpx; margin-bottom: 20rpx; flex-direction: row; } .service-item.is-unavailable { opacity: .56; } .service-image { width: 184rpx; height: 184rpx; border-radius: 17rpx; flex-shrink: 0; background-color: #EEE; } .service-info { display: flex; min-width: 0; min-height: 184rpx; flex: 1; padding: 4rpx 0 4rpx 24rpx; flex-direction: column; justify-content: space-between; } .service-name { color: #292929; font-size: 29rpx; font-weight: 700; } .service-tags { display: flex; flex-direction: row; align-items: center; } .coupon-tag { padding: 6rpx 8rpx; border-radius: 6rpx; background-color: #FFF0EC; color: #FF5D37; font-size: 21rpx; } .service-discount { display: block; width: fit-content; margin-left: 0; padding: 6rpx 9rpx; border-radius: 6rpx; background-color: #FFF5E7; color: #FF6A34; font-size: 21rpx; } .service-tags .service-discount { margin-left: 11rpx; } .service-tags .coupon-tag + .service-discount { margin-left: 11rpx; } .address { display: flex; min-width: 0; padding: 7rpx 10rpx; border-radius: 7rpx; background-color: #F1F2F3; } .address image { width: 27rpx; height: 27rpx; margin-right: 8rpx; } .address text { min-width: 0; flex: 1; color: #7E8490; font-size: 21rpx; line-height: 31rpx; }
    .list-state, .no-more { display: flex; height: 150rpx; color: #989DA5; font-size: 23rpx; justify-content: center; align-items: center; } .list-state.compact { height: 90rpx; } .error-state { flex-direction: column; } .retry-text, .load-error text { margin-top: 18rpx; color: #F26A38; } .loading-spinner { width: 26rpx; height: 26rpx; margin-right: 12rpx; box-sizing: border-box; border: 3rpx solid #FFD6C4; border-top-color: #FF6C2B; border-radius: 50%; animation: loading 720ms linear infinite; } @keyframes loading { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .bottom-space { height: calc(30rpx + env(safe-area-inset-bottom)); }
    /* 定位图标与地址必须同一行:uni-app x 的 view 默认纵向排列,这里显式改成横向并做单行省略。 */
    .address { flex-direction: row; align-items: center; }
    .address image { flex-shrink: 0; }
    .address text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>
rencaika_merchant/config/development-server.uts
@@ -16,8 +16,13 @@
 * 清了这条 key 就回到开发者工具适用的默认值。
 */
/** 开发者工具与本机后台:回环地址,跟网络怎么变无关。 */
const loopbackDevelopmentBaseUrl = 'http://192.168.110.26:8080'
/**
 * 开发者工具与本机后台:回环地址,跟网络怎么变无关。
 *
 * 不要写具体局域网 IP(曾经写成 192.168.110.26:8080,别人拉到代码后所有请求都打到那台机器上,
 * 表现成"小程序读不到数据")。127.0.0.1 在开发者工具里永远指向本机后台。
 */
const loopbackDevelopmentBaseUrl = 'http://127.0.0.1:8080'
/** 覆盖用的 storage key,真机调试时写入开发机局域网地址。 */
const overrideStorageKey = 'development-api-base-url'
rencaika_merchant/pages/free-stay-confirm/free-stay-confirm.uvue
@@ -87,7 +87,8 @@
    page { background-color: #F7F8FA; }
    .page { display: flex; width: 100%; height: 100vh; flex-direction: column; overflow: hidden; background-color: #F7F8FA; }
    .navigation-bar { display: flex; height: 174rpx; flex: none; justify-content: center; align-items: flex-end; padding-bottom: 25rpx; box-sizing: border-box; position: relative; }
    .navigation-title { color: #1F2937; font-size: 35rpx; line-height: 49rpx; font-weight: 700; }
    /* 标题绝对定位居中:bottom 取值让标题中线与返回按钮/胶囊中线对齐(与核销输入页保持一致)。 */
    .navigation-title { position: absolute; right: 0; bottom: 30rpx; left: 0; z-index: 1; color: #1F2937; font-size: 35rpx; line-height: 49rpx; font-weight: 700; text-align: center; }
    .back-button { display: flex; width: 72rpx; height: 72rpx; justify-content: center; align-items: center; position: absolute; top: 82rpx; left: 28rpx; }
    .back-icon { width: 40rpx; height: 40rpx; }
    .content { height: 0; flex: 1; padding: 20rpx 34rpx 0; box-sizing: border-box; }
talent-card-admin/src/api/admin.ts
@@ -257,6 +257,11 @@
  return http.get<PageResult<Product>, PageResult<Product>>('/api/admin/business/products', { params })
}
// 商家编辑页面的团单列表按商品维度返回(一条团单一行),修改团单不会多出一行待审核记录。
export function getAdminMerchantProducts(merchantId: number | string, params: ProductQuery = {}) {
  return http.get<PageResult<Product>, PageResult<Product>>(`/api/admin/business/merchants/${merchantId}/products`, { params })
}
export function getAdminProductDetail(id: number | string) {
  return http.get<Product, Product>(`/api/admin/business/products/${id}`)
}
talent-card-admin/src/layouts/AdminLayout.vue
@@ -131,6 +131,19 @@
  .map((group) => ({ ...group, items: group.items.filter((item) => canAccess(item.path)) }))
  .filter((group) => group.items.length > 0))
// 菜单里出现的所有路径,用于把"列表路径 + /:id"这类详情页路由归到对应菜单项。
const menuPaths = (() => {
  const paths: string[] = []
  const walk = (items: MenuItem[]) => {
    for (const item of items) {
      if (item.path) paths.push(item.path)
      if (item.children) walk(item.children)
    }
  }
  menuGroups.forEach((group) => walk(group.items))
  return paths
})()
const defaultOpeneds = computed(() => {
  const currentGroup = menuGroups.find((item) => findMenuLabel(item.items, activeMenu.value))
  return currentGroup ? [currentGroup.index] : []
@@ -148,6 +161,9 @@
    return '/talents'
  }
  if (route.path === '/merchants/new') {
    return '/merchants'
  }
  if (route.path.startsWith('/merchant-onboardings')) {
    return '/merchants'
  }
  if (route.path === '/roles/new' || /^\/roles\/[^/]+(?:\/view)?$/.test(route.path)) {
@@ -180,16 +196,21 @@
  if (route.path.startsWith('/merchant-settlement/')) {
    return '/merchant-settlement'
  }
  if (route.path === '/talent-news' || route.path.startsWith('/talent-news-categories/')) {
  if (route.path === '/talent-news' || route.path.startsWith('/talent-news-categories')) {
    return '/talent-news-management'
  }
  if (route.path === '/talent-policies' || route.path === '/talent-policy-categories') {
  if (route.path === '/talent-policies' || route.path.startsWith('/talent-policy-categories')) {
    return '/talent-policy-management'
  }
  if (route.path === '/movie-ticket-records') {
  if (route.path.startsWith('/movie-ticket-records')) {
    return '/movie-tickets'
  }
  return route.path
  // 通用兜底:详情/编辑页的路由是"列表路径 + /:id",按最长前缀归到对应菜单项,
  // 这样左侧分组会跟着保持展开(例如 /family-card-applications/12 → /family-card-applications)。
  const matched = menuPaths
    .filter((path) => route.path === path || route.path.startsWith(path + '/'))
    .sort((left, right) => right.length - left.length)[0]
  return matched || route.path
})
function currentMenuGroupIndex() {
@@ -212,6 +233,8 @@
watch(activeMenu, () => {
  const groupIndex = currentMenuGroupIndex()
  // 找不到归属(例如从其它模块跳进来的子页面)时保持当前展开状态,不要把左侧菜单收起来。
  if (!groupIndex) return
  nextTick(() => {
    closeOtherMenuGroups(groupIndex)
    if (groupIndex) adminMenuRef.value?.open(groupIndex)
talent-card-admin/src/style.css
@@ -855,6 +855,22 @@
  text-align: left;
}
.merchant-fuel-hero-tip {
  margin: -6px 0 14px;
  color: #8a94a6;
  font-size: 12px;
  line-height: 18px;
}
.merchant-fuel-hero-preview {
  width: 108px;
  height: 84px;
  border: 1px solid #e5e7eb;
  border-radius: 6px;
  object-fit: cover;
  background-color: #f3f4f6;
}
.merchant-school-address-actions,
.merchant-parking-address-actions {
  display: flex;
@@ -895,10 +911,15 @@
.movie-merchant-rights-panel .merchant-rights-level-row {
  grid-column: 1 / 3;
  grid-template-columns: 82px 38px 32px 160px;
  /* 第三列放"*标签",32px 放不下两个字,会折成两行;留出足够宽度。 */
  grid-template-columns: 82px 38px 54px 160px;
  gap: 10px;
  min-width: 0;
  padding-left: 0;
}
.merchant-rights-level-row > span {
  white-space: nowrap;
}
.movie-merchant-rights-panel .merchant-rights-level-row > .el-switch {
@@ -2416,7 +2437,8 @@
}
.coupon-switch-row {
  grid-template-columns: 130px 44px;
  /* 内容列要留足宽度:开关本身靠左对齐,不受影响;而"适用范围"这类纯文案不会被挤成两行。 */
  grid-template-columns: 130px minmax(0, 1fr);
  gap: 6px;
  margin-bottom: 18px;
}
@@ -2429,6 +2451,11 @@
  justify-content: flex-start;
  white-space: nowrap;
  word-break: keep-all;
}
.coupon-switch-row > span {
  line-height: 30px;
  white-space: nowrap;
}
.coupon-switch-row .el-switch {
@@ -3656,12 +3683,19 @@
.equity-upload-row {
  display: grid;
  grid-template-columns: 42px 180px repeat(2, 72px);
  /* 最后一列放推荐尺寸提示,跟随上传按钮显示。 */
  grid-template-columns: 42px 180px repeat(2, 72px) minmax(0, 1fr);
  gap: 12px 24px;
  align-items: center;
  margin: 24px 0 0 18px;
}
.equity-upload-tip {
  color: #8a94a6;
  font-size: 12px;
  line-height: 18px;
}
.equity-upload-row .el-button {
  width: 72px;
}
talent-card-admin/src/types/admin.ts
@@ -65,6 +65,9 @@
export interface PolicyArticle extends NewsArticle {
  policyNo?: string
  subtitle?: string
  // 后台记录的操作人(管理员 ID 由服务端解析成姓名返回,历史数据回落为 author)。
  createdByName?: string
  updatedByName?: string
}
export interface PolicyArticlePayload {
@@ -205,6 +208,12 @@
    latitude?: number
    canNavigate?: boolean | number
  }>
  heroImages?: Array<{
    id?: number | string
    imageUrl?: string
    sortNo?: number
    enabled?: number | boolean
  }>
  usageInstructions?: string
  rightsText?: string
  miniProgramJumpType?: string
@@ -264,6 +273,12 @@
    longitude?: number
    latitude?: number
  }>
  // 加油服务详情页轮播图(按顺序提交,服务端整表替换)。
  heroImages?: Array<{
    id?: number | string
    imageUrl: string
    enabled: boolean
  }>
  usageInstructions?: string
  rightsText?: string
  miniProgramJumpType?: string
talent-card-admin/src/views/EquityImageView.vue
@@ -188,6 +188,7 @@
          </div>
          <el-button type="primary" :loading="uploadingImageId === item.id" @click="uploadImage(item)">上传</el-button>
          <el-button v-if="currentConfig().images.length > 1" @click="removeImage(index)">移除</el-button>
          <span class="equity-upload-tip">推荐尺寸 750 × 730 px(宽高比约 1:1,与小程序顶部大图一致)</span>
        </div>
      </div>
talent-card-admin/src/views/FamilyCardView.vue
@@ -72,10 +72,6 @@
  return row.relationship || row.relation || '-'
}
function familyIdCard(row: FamilyCardApplication) {
  return row.idCardNo || row.dependentIdCardNo || '-'
}
async function loadData() {
  loading.value = true
  try {
@@ -141,18 +137,14 @@
      { title: '状态', value: row => statusText(row) },
    ] : [
      { title: '申请编号', value: row => row.applicationNo || row.applyNo },
      { title: '持卡人姓名', value: row => row.talentName },
      { title: '持卡人名称', value: row => row.talentName },
      { title: '持卡人手机号', value: row => row.talentMobile },
      { title: '持卡人身份证号', value: row => row.talentIdCardNo },
      { title: '人才卡号', value: row => row.talentCardNo },
      { title: '卡片等级', value: row => row.cardLevel },
      { title: '家属姓名', value: row => familyName(row) },
      { title: '家属名称', value: row => familyName(row) },
      { title: '家属手机号', value: row => familyMobile(row) },
      { title: '家属身份证号', value: row => familyIdCard(row) },
      { title: '关系', value: row => relationship(row) },
      { title: '家属关系', value: row => relationship(row) },
      { title: '申请时间', value: row => registerTime(row) },
      { title: '状态', value: row => statusText(row) },
      { title: '审核备注', value: row => row.auditRemark || '-' },
    ],
    result.records,
  )
@@ -178,11 +170,12 @@
      </div>
      <el-form class="family-card-filter-form" :model="filters" label-width="84px">
        <el-form-item :label="isManagement ? '家属' : '家属信息'">
          <el-input v-model="filters.familyKeyword" :placeholder="isManagement ? '家属姓名、手机号' : '请输入家属姓名、手机号或身份证号'" clearable />
        <!-- 家属卡申请按原型只保留「持卡人 / 卡片等级 / 状态」三个筛选;家属与时间筛选只在家属卡管理页使用。 -->
        <el-form-item v-if="isManagement" label="家属">
          <el-input v-model="filters.familyKeyword" placeholder="家属姓名、手机号" clearable />
        </el-form-item>
        <el-form-item label="持卡人" :class="{ 'family-card-filter-talent': isManagement }">
          <el-input v-model="filters.talentKeyword" placeholder="持卡人姓名、手机号、身份证号或卡号" clearable />
          <el-input v-model="filters.talentKeyword" :placeholder="isManagement ? '持卡人姓名、手机号、身份证号或卡号' : '持卡人姓名、手机号、卡号'" clearable />
        </el-form-item>
        <el-form-item label="卡片等级">
          <el-select v-model="filters.cardLevel" placeholder="全部" clearable>
@@ -196,7 +189,7 @@
            <el-option v-for="item in statusOptions" :key="item.label" :label="item.label" :value="item.value" />
          </el-select>
        </el-form-item>
        <el-form-item :label="isManagement ? '注册时间' : '申请时间'" :class="{ 'family-card-filter-date': isManagement }">
        <el-form-item v-if="isManagement" label="注册时间" :class="{ 'family-card-filter-date': isManagement }">
          <el-date-picker
            v-model="filters.applyTime"
            type="daterange"
@@ -250,33 +243,26 @@
        table-layout="fixed"
        scrollbar-always-on
      >
        <el-table-column prop="id" label="编号" width="86" align="center" />
        <el-table-column prop="applicationNo" label="申请编号" min-width="160" />
        <el-table-column label="家属姓名" min-width="110">
        <el-table-column prop="talentName" label="持卡人名称" min-width="110" />
        <el-table-column prop="talentMobile" label="持卡人手机号" min-width="130" />
        <el-table-column prop="cardLevel" label="卡片等级" min-width="108" />
        <el-table-column label="家属名称" min-width="106">
          <template #default="{ row }">{{ familyName(row) }}</template>
        </el-table-column>
        <el-table-column label="家属手机号" min-width="130">
          <template #default="{ row }">{{ familyMobile(row) }}</template>
        </el-table-column>
        <el-table-column label="家属身份证号" min-width="180">
          <template #default="{ row }">{{ familyIdCard(row) }}</template>
        </el-table-column>
        <el-table-column prop="talentName" label="持卡人姓名" min-width="110" />
        <el-table-column prop="talentMobile" label="持卡人手机号" min-width="130" />
        <el-table-column prop="talentIdCardNo" label="持卡人身份证号" min-width="180" />
        <el-table-column prop="talentCardNo" label="人才卡号" min-width="120" />
        <el-table-column prop="cardLevel" label="卡片等级" min-width="108" />
        <el-table-column label="关系" min-width="90">
        <el-table-column label="家属关系" min-width="90">
          <template #default="{ row }">{{ relationship(row) }}</template>
        </el-table-column>
        <el-table-column prop="auditRemark" label="审核备注" min-width="160" show-overflow-tooltip />
        <el-table-column label="申请时间" min-width="150">
          <template #default="{ row }">{{ registerTime(row) }}</template>
        </el-table-column>
        <el-table-column label="状态" width="90">
          <template #default="{ row }">{{ statusText(row) }}</template>
        </el-table-column>
        <el-table-column label="操作" width="180" fixed="right">
        <el-table-column label="操作" width="90" fixed="right">
          <template #default="{ row }">
            <el-button link type="primary" @click="viewDetail(row)">查看</el-button>
          </template>
talent-card-admin/src/views/MerchantCreateView.vue
@@ -15,7 +15,7 @@
  deleteProduct as deleteProductApi,
  getAdminCouponDetail,
  getAdminCoupons,
  getAdminProducts,
  getAdminMerchantProducts,
  updateCoupon,
  updateCouponStatus,
  updateMerchant,
@@ -70,7 +70,7 @@
const merchantHasLoginAccount = ref(false)
const imageInputRef = ref<HTMLInputElement>()
const imageUploading = ref(false)
const imageUploadTarget = ref<'merchant' | 'productCover' | 'productImage' | 'approvalWindow'>('merchant')
const imageUploadTarget = ref<'merchant' | 'productCover' | 'productImage' | 'approvalWindow' | 'fuelHero'>('merchant')
const merchantCoverPreviewFailed = ref(false)
const productCoverPreviewFailed = ref(false)
const routeTab = typeof route.query.tab === 'string' ? route.query.tab : 'settings'
@@ -303,7 +303,7 @@
const merchantRules = computed<FormRules>(() => ({
  merchantName: [{ required: true, message: '请输入商家名称', trigger: 'blur' }],
  contactMobile: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
  address: isApartmentMerchant.value ? [] : [{ required: true, message: '请输入商家地址', trigger: 'blur' }],
  address: isApartmentMerchant.value ? [] : [{ required: true, message: '请输入商家地址', trigger: ['blur', 'change'] }],
  coverUrl: [{ required: true, message: '请上传商家封面图', trigger: 'change' }],
  sortNo: [{
    validator: (_rule, value, callback) => {
@@ -321,8 +321,13 @@
  loginPassword: '',
  confirmPassword: '',
})
// 编辑已有登录账号的商家时,密码可以留空(留空表示不修改),此时不标必填、也不校验。
const passwordFieldsRequired = computed(() => requiresMerchantOperator.value || (isEditMode.value && accountForm.loginPassword.length > 0))
const accountRules = computed<FormRules>(() => ({
  loginAccount: [{
    // 规则里必须显式声明 required:否则 el-form-item 上的 required 会让 Element Plus
    // 追加一条只有 required 的空规则,报出框架默认的英文 "loginAccount is required"。
    required: true,
    validator: (_rule, value, callback) => {
      if (!requiresMerchantOperator.value) return callback()
      if (!String(value || '').trim()) return callback(new Error('请输入登录账号'))
@@ -332,6 +337,7 @@
    trigger: 'blur',
  }],
  loginPassword: [{
    required: true,
    validator: (_rule, value, callback) => {
      const updatingPassword = isEditMode.value && String(value || '').length > 0
      if (!requiresMerchantOperator.value && !updatingPassword) return callback()
@@ -342,6 +348,7 @@
    trigger: 'blur',
  }],
  confirmPassword: [{
    required: true,
    validator: (_rule, value, callback) => {
      const needsConfirmation = requiresMerchantOperator.value || (isEditMode.value && accountForm.loginPassword.length > 0)
      if (!needsConfirmation) return callback()
@@ -1107,6 +1114,11 @@
const parkingAddresses = ref<ParkingAddress[]>([])
const activeParkingAddress = ref<ParkingAddress>()
const fuelStores = ref<FuelStore[]>([])
// 加油服务详情页轮播图(后台维护,保存时整表替换)。
const fuelHeroImages = ref<Array<{ id?: number | string; imageUrl: string; enabled: boolean }>>([])
// 可配置详情页轮播图的商家类型:加油服务、子女托管、行政审批。
const supportsHeroImages = computed(() => isFuelMerchant.value || isChildcareMerchant.value || isApprovalMerchant.value)
const merchantHeroImageTip = computed(() => isFuelMerchant.value ? '加油服务' : (isChildcareMerchant.value ? '子女托管' : '行政审批'))
const activeFuelStore = ref<FuelStore>()
const schoolAddresses = ref<SchoolAddress[]>([])
const activeSchoolAddress = ref<SchoolAddress>()
@@ -1279,13 +1291,15 @@
  return new Promise((resolve) => {
    const geolocation = window.navigator?.geolocation
    if (!geolocation) {
      ElMessage.info('当前浏览器不支持定位,请直接搜索或点击地图标记')
      console.info('browser geolocation is unavailable, fall back to the default map center')
      resolve(undefined)
      return
    }
    const hostname = window.location.hostname
    // 浏览器只允许在安全上下文(HTTPS 或 localhost)里定位,局域网 IP + HTTP 必然被拒。
    // 定位只是"把地图居中"的便利功能,这里静默跳过,不再弹提示打断选点流程。
    if (!window.isSecureContext && hostname !== 'localhost' && hostname !== '127.0.0.1') {
      ElMessage.warning('浏览器定位需要使用 localhost 或 HTTPS 地址访问')
      console.info('browser geolocation requires a secure context; keeping the default map center')
      resolve(undefined)
      return
    }
@@ -1296,7 +1310,7 @@
        resolve(Number.isFinite(longitude) && Number.isFinite(latitude) ? { longitude, latitude } : undefined)
      },
      (error) => {
        if (error.code === error.PERMISSION_DENIED) ElMessage.info('未获得浏览器定位权限,请直接搜索或点击地图标记')
        console.info(`browser geolocation unavailable: ${error.code}`)
        resolve(undefined)
      },
      { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 },
@@ -1394,10 +1408,14 @@
    activeParkingAddress.value.address = normalizedAddress
  } else if (activeSchoolAddress.value) {
    activeSchoolAddress.value.address = normalizedAddress
    // 地图选点/反查地址是程序写入,不会触发 blur/change,旧的"请输入地址"提示会一直留着。
    schoolFormRef.value?.clearValidate('address')
  } else if (activeFuelStore.value) {
    activeFuelStore.value.address = normalizedAddress
  } else {
    form.address = normalizedAddress
    // 同理:选完地址立刻清掉"请输入商家地址",避免看起来像没选成功。
    merchantFormRef.value?.clearValidate('address')
  }
  locationSearch.value = normalizedAddress
}
@@ -1461,7 +1479,7 @@
  ElMessage.success('地图位置已标记')
}
function changeCover(target: 'merchant' | 'productCover' | 'productImage' | 'approvalWindow') {
function changeCover(target: 'merchant' | 'productCover' | 'productImage' | 'approvalWindow' | 'fuelHero') {
  imageUploadTarget.value = target
  if (imageInputRef.value) imageInputRef.value.accept = target === 'approvalWindow' ? 'application/pdf,.pdf' : 'image/jpeg,image/png,image/webp'
  imageInputRef.value?.click()
@@ -1530,6 +1548,10 @@
      productForm.coverUrl = result.url
      resetProductCoverPreview()
      productFormRef.value?.validateField('coverUrl').catch(() => undefined)
    } else if (imageUploadTarget.value === 'fuelHero') {
      if (!fuelHeroImages.value.some((image) => image.imageUrl === result.url)) {
        fuelHeroImages.value.push({ imageUrl: result.url, enabled: true })
      }
    } else if (!productImages.value.includes(result.url)) {
      productImages.value.push(result.url)
    }
@@ -1736,6 +1758,7 @@
      canLoginMerchantApp: Number(form.canLoginMerchantApp ?? 1),
      groupBuyingEnabled: form.groupBuyingEnabled,
      applicableStores: isFuelMerchant.value ? fuelStores.value : undefined,
      heroImages: supportsHeroImages.value ? fuelHeroImages.value : undefined,
      usageInstructions: isParkingMerchant.value
        ? JSON.stringify(parkingAddresses.value)
        : isChildcareMerchant.value
@@ -1857,10 +1880,9 @@
  productLoading.value = true
  try {
    const result = await getAdminProducts({
    const result = await getAdminMerchantProducts(merchantId, {
      page: productPagination.page,
      size: productPagination.size,
      merchantId,
      keyword: productFilters.productName || undefined,
      status: productFilters.status,
      createdStart: productFilters.createdTime?.[0],
@@ -2102,7 +2124,7 @@
    couponProducts.value = []
    return
  }
  const result = await getAdminProducts({ page: 1, size: 100, merchantId })
  const result = await getAdminMerchantProducts(merchantId, { page: 1, size: 100 })
  couponProducts.value = result.records
}
@@ -2367,6 +2389,7 @@
        loginPassword: !isEditMode.value || updatingMerchantPassword || !merchantHasLoginAccount.value ? accountForm.loginPassword : undefined,
        groupBuyingEnabled: form.groupBuyingEnabled,
        applicableStores: isFuelMerchant.value ? fuelStores.value : undefined,
        heroImages: supportsHeroImages.value ? fuelHeroImages.value : undefined,
        usageInstructions: isParkingMerchant.value
            ? JSON.stringify(parkingAddresses.value)
            : isChildcareMerchant.value
@@ -2476,6 +2499,16 @@
        schoolAddresses.value = parseJsonValue<SchoolAddress[]>(merchant.usageInstructions, [])
      } else if (isApprovalMerchant.value) {
        approvalItems.value = parseJsonValue<ApprovalItem[]>(merchant.usageInstructions, [])
      }
      // 详情页轮播图(加油 / 子女托管 / 行政审批)统一从 heroImages 读。
      if (supportsHeroImages.value) {
        fuelHeroImages.value = Array.isArray(merchant.heroImages)
          ? merchant.heroImages.map((image: any) => ({
            id: image.id,
            imageUrl: String(image.imageUrl ?? image.image_url ?? ''),
            enabled: Number(image.enabled ?? 1) === 1,
          })).filter((image: any) => image.imageUrl !== '')
          : []
      }
            resetBusinessHours(merchant.businessHours)
            resetMerchantRights(merchant.merchantRightsConfig)
@@ -2629,10 +2662,10 @@
          <el-form-item class="merchant-account-field" label="登录账号" prop="loginAccount" :required="merchantOperatorEnabled" :error="requiresMerchantOperator && accountTaken ? '登录账号已存在,请更换' : ''">
            <el-input v-model="accountForm.loginAccount" maxlength="32" :disabled="isEditMode && merchantHasLoginAccount" :placeholder="isEditMode && !merchantHasLoginAccount ? '请输入登录账号' : (isEditMode ? '已配置登录账号' : '仅支持英文和数字')" @input="normalizeLoginAccount" @blur="checkLoginAccountAvailable" />
          </el-form-item>
          <el-form-item class="merchant-account-field" label="登录密码" prop="loginPassword" :required="merchantOperatorEnabled">
          <el-form-item class="merchant-account-field" label="登录密码" prop="loginPassword" :required="passwordFieldsRequired">
            <el-input v-model="accountForm.loginPassword" type="password" show-password :placeholder="isEditMode ? '留空则不修改,6至64位' : ''" />
          </el-form-item>
          <el-form-item class="merchant-account-field" label="确认密码" prop="confirmPassword" :required="merchantOperatorEnabled">
          <el-form-item class="merchant-account-field" label="确认密码" prop="confirmPassword" :required="passwordFieldsRequired">
            <el-input v-model="accountForm.confirmPassword" type="password" show-password :placeholder="isEditMode ? '再次输入新密码' : ''" />
          </el-form-item>
        </el-form>
@@ -2973,6 +3006,27 @@
        <div class="merchant-parking-address-actions"><el-button plain @click="addFuelStore">新增</el-button></div>
      </section>
      <section v-if="supportsHeroImages" class="merchant-parking-address-panel">
        <h3 class="merchant-parking-address-title">详情页图片</h3>
        <p class="merchant-fuel-hero-tip">显示在人才端{{ merchantHeroImageTip }}详情页顶部,按顺序展示、只展示已启用的图片;不配置时自动使用商家封面图。推荐 750 × 730 px。</p>
        <el-table :data="fuelHeroImages" border>
          <el-table-column label="图片" width="140" align="center">
            <template #default="{ row }">
              <img v-if="row.imageUrl" class="merchant-fuel-hero-preview" :src="row.imageUrl" alt="详情页图片" />
            </template>
          </el-table-column>
          <el-table-column label="启用" width="120" align="center">
            <template #default="{ row }"><el-switch v-model="row.enabled" /></template>
          </el-table-column>
          <el-table-column label="操作" width="100" fixed="right" align="center">
            <template #default="{ $index }"><el-button link type="danger" @click="fuelHeroImages.splice($index, 1)">删除</el-button></template>
          </el-table-column>
        </el-table>
        <div class="merchant-parking-address-actions">
          <el-button plain :loading="imageUploading" @click="changeCover('fuelHero')">上传图片</el-button>
        </div>
      </section>
      <section v-if="isChildcareMerchant" class="merchant-school-address-panel">
        <h3 class="merchant-school-address-title">学校地址</h3>
        <el-table :data="schoolAddresses" border>
talent-card-admin/src/views/MovieTicketView.vue
@@ -35,7 +35,7 @@
const activePackage = ref<Record<string, any> | null>(null)
const selectedPackage = ref<Record<string, any> | null>(null)
const cardLevels = TALENT_CARD_LEVELS
// 电影票只面向三种人才卡开放,筛选与票包权益都不出现"普通游客"。
const ticketRuleLevels = TALENT_CARD_LEVELS.filter(level => level !== '普通游客')
const resetCycles = ['每天', '每周', '每月', '每年']
const packageStatuses = [{ label: '全部', value: '' }, { label: '正常', value: 1 }, { label: '禁用', value: 0 }]
@@ -504,7 +504,7 @@
          <el-form-item label="卡片等级">
            <el-select v-model="claimFilters.cardLevel" placeholder="全部" clearable>
              <el-option label="全部" value="" />
              <el-option v-for="level in cardLevels" :key="level" :label="level" :value="level" />
              <el-option v-for="level in ticketRuleLevels" :key="level" :label="level" :value="level" />
            </el-select>
          </el-form-item>
          <el-form-item label="领取时间">
talent-card-admin/src/views/PlatformSettingsView.vue
@@ -9,6 +9,8 @@
  videoName: '',
  homeVideoUrl: '',
  miniProgramUrl: '',
  miniProgramAppId: '',
  miniProgramPath: '',
})
const saving = ref(false)
@@ -25,7 +27,7 @@
}
async function saveSettings() {
  saving.value = true
  try { await savePlatformSettings({ bookstoreDiscount: form.bookstoreDiscount, hotelSubsidy: form.hotelSubsidy, homeVideoUrl: form.homeVideoUrl, miniProgramUrl: form.miniProgramUrl }); ElMessage.success('平台设置已保存') } finally { saving.value = false }
  try { await savePlatformSettings({ bookstoreDiscount: form.bookstoreDiscount, hotelSubsidy: form.hotelSubsidy, homeVideoUrl: form.homeVideoUrl, miniProgramUrl: form.miniProgramUrl, miniProgramAppId: form.miniProgramAppId, miniProgramPath: form.miniProgramPath }); ElMessage.success('平台设置已保存') } finally { saving.value = false }
}
onMounted(async () => { Object.assign(form, await getPlatformSettings()) })
</script>
@@ -53,6 +55,15 @@
        <el-input v-model="form.miniProgramUrl" placeholder="请输入" />
      </el-form-item>
      <el-form-item class="platform-mini-program-link" label="目标小程序 AppID">
        <el-input v-model="form.miniProgramAppId" placeholder="如 wx1234567890abcdef,填写后首页入口直接跳转该小程序" />
      </el-form-item>
      <el-form-item class="platform-mini-program-link" label="目标小程序路径">
        <el-input v-model="form.miniProgramPath" placeholder="选填,如 pages/index/index" />
      </el-form-item>
      <el-form-item class="platform-settings-save">
        <el-button type="primary" :loading="saving" @click="saveSettings">保存</el-button>
      </el-form-item>
talent-card-admin/src/views/TalentPolicyEditView.vue
@@ -34,13 +34,14 @@
async function uploadCover(file: UploadFile) { if (!file.raw) return; const result = await uploadAdminImage(file.raw); form.coverUrl = result.url; await formRef.value?.validateField('coverUrl'); ElMessage.success('封面图上传成功') }
function validateContent() { void formRef.value?.validateField('content') }
function formatTime(value?: string) { return value ? value.replace('T', ' ').slice(0, 19) : '-' }
const createdAt = ref('-'); const updatedAt = ref('-')
async function load() { loading.value = true; try { const item = await getAdminPolicyDetail(String(route.params.id)); form.title = item.title; form.subtitle = item.subtitle || ''; form.author = item.author || ''; form.source = item.source || ''; form.content = item.content || ''; form.coverUrl = item.coverUrl || ''; form.isTop = Boolean(item.isTop); form.sortNo = Number(item.sortNo || 0); form.status = Number(item.status ?? 0); createdAt.value = formatTime(item.createdAt); updatedAt.value = formatTime(item.updatedAt) } finally { loading.value = false } }
// 发布人取创建该政策的管理员,修改人取最近一次保存的管理员;历史数据回落为发布人字段。
const publisher = ref('-'); const editor = ref('-'); const createdAt = ref('-'); const updatedAt = ref('-')
async function load() { loading.value = true; try { const item = await getAdminPolicyDetail(String(route.params.id)); form.title = item.title; form.subtitle = item.subtitle || ''; form.author = item.author || ''; form.source = item.source || ''; form.content = item.content || ''; form.coverUrl = item.coverUrl || ''; form.isTop = Boolean(item.isTop); form.sortNo = Number(item.sortNo || 0); form.status = Number(item.status ?? 0); publisher.value = item.createdByName || item.author || '-'; editor.value = item.updatedByName || item.author || '-'; createdAt.value = formatTime(item.createdAt); updatedAt.value = formatTime(item.updatedAt) } finally { loading.value = false } }
async function save() { const valid = await formRef.value?.validate().catch(() => false); if (!valid) return; await updateAdminPolicy(String(route.params.id), { ...form, title: form.title.trim(), subtitle: form.subtitle.trim(), author: form.author.trim(), source: form.source.trim(), coverUrl: form.coverUrl.trim() }); ElMessage.success('政策已保存'); router.push('/talent-policy-management') }
onMounted(() => void load())
</script>
<template>
  <div class="policy-edit-page" v-loading="loading"><section class="news-meta-panel"><h3>发布信息</h3><div class="news-meta-grid"><div><span>发布时间</span><strong>{{ createdAt }}</strong></div><div><span>修改时间</span><strong>{{ updatedAt }}</strong></div></div></section><section class="policy-publish-panel"><h3>政策内容</h3><el-form ref="formRef" class="policy-publish-form" :model="form" :rules="rules" label-width="88px">
    <el-form-item label="政策标题" prop="title"><el-input v-model="form.title" /></el-form-item><el-form-item label="政策标签" prop="subtitle"><el-input v-model="form.subtitle" placeholder="请输入政策标签" /></el-form-item><el-form-item class="news-editor-form-item" label="政策内容" prop="content"><AdminRichTextEditor v-model="form.content" @update:model-value="validateContent" /></el-form-item><el-form-item label="封面图" prop="coverUrl"><el-upload class="article-cover-upload" accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"><el-image v-if="form.coverUrl" class="article-cover-preview" :src="form.coverUrl" fit="cover" /><div v-else class="article-cover-upload-card"><span>+</span><small>上传图片</small></div></el-upload></el-form-item><el-form-item label="排序" prop="sortNo"><el-input v-model.number="form.sortNo" type="number" placeholder="请输入非负整数" /></el-form-item><el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
  <div class="policy-edit-page" v-loading="loading"><section class="news-meta-panel"><h3>发布信息</h3><div class="news-meta-grid"><div><span>发布人</span><strong>{{ publisher }}</strong></div><div><span>发布时间</span><strong>{{ createdAt }}</strong></div><div><span>修改人</span><strong>{{ editor }}</strong></div><div><span>修改时间</span><strong>{{ updatedAt }}</strong></div></div></section><section class="policy-publish-panel"><h3>政策内容</h3><el-form ref="formRef" class="policy-publish-form" :model="form" :rules="rules" label-width="88px">
    <el-form-item label="政策标题" prop="title"><el-input v-model="form.title" /></el-form-item><el-form-item label="政策标签" prop="subtitle"><el-input v-model="form.subtitle" placeholder="请输入政策标签" /></el-form-item><el-form-item class="news-editor-form-item" label="政策内容" prop="content"><AdminRichTextEditor v-model="form.content" @update:model-value="validateContent" /></el-form-item><el-form-item label="封面图" prop="coverUrl"><el-upload class="article-cover-upload" accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"><el-image v-if="form.coverUrl" class="article-cover-preview" :src="form.coverUrl" fit="cover" /><div v-else class="article-cover-upload-card"><span>+</span><small>上传图片</small></div></el-upload></el-form-item><el-form-item label="是否置顶"><el-switch v-model="form.isTop" /></el-form-item><el-form-item label="排序" prop="sortNo"><el-input v-model.number="form.sortNo" type="number" placeholder="请输入非负整数" /></el-form-item><el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
  </el-form></section></div>
</template>
talent-card-admin/src/views/TalentPolicyPublishView.vue
@@ -55,6 +55,7 @@
    <el-form-item label="政策标签" prop="subtitle" required><el-input v-model="form.subtitle" placeholder="请输入政策标签" /></el-form-item>
    <el-form-item class="news-editor-form-item" label="政策内容" prop="content" required><AdminRichTextEditor v-model="form.content" @update:model-value="validateContent" /></el-form-item>
    <el-form-item label="封面图" prop="coverUrl" required><el-upload class="article-cover-upload" accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"><el-image v-if="form.coverUrl" class="article-cover-preview" :src="form.coverUrl" fit="cover" /><div v-else class="article-cover-upload-card"><span>+</span><small>上传图片</small></div></el-upload></el-form-item>
    <el-form-item label="是否置顶"><el-switch v-model="form.isTop" /></el-form-item>
    <el-form-item label="排序" prop="sortNo" required><el-input v-model.number="form.sortNo" type="number" placeholder="请输入非负整数" /></el-form-item>
    <el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
  </el-form></section></div>
talent-card-admin/vite.config.ts
@@ -2,6 +2,15 @@
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
// 开发时代理到本机后端:浏览器同源请求不会经过 CORS 校验,但代理会把页面的
// Origin(例如局域网 IP)原样带过去,后端白名单没列到就直接 403。这里统一去掉
// Origin,让开发环境无论用 localhost 还是局域网地址访问都能登录。
const stripOrigin = (proxy: any) => {
  proxy.on('proxyReq', (proxyReq: any) => {
    proxyReq.removeHeader('origin')
  })
}
export default defineConfig({
  plugins: [vue()],
  server: {
@@ -12,14 +21,17 @@
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        configure: stripOrigin,
      },
      '/uploads': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        configure: stripOrigin,
      },
      '/static': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        configure: stripOrigin,
      },
    },
  },
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessController.java
@@ -104,12 +104,14 @@
    @ResponseStatus(HttpStatus.CREATED)
    public ApiResponse<Map<String, Object>> createPolicy(@Valid @RequestBody PolicyArticleRequest request, HttpServletRequest servletRequest) {
        String operatorName = String.valueOf(servletRequest.getAttribute(AdminAuthInterceptor.OPERATOR_NAME_ATTRIBUTE));
        return ApiResponse.success(adminBusinessService.createPolicy(request, operatorName));
        Long operatorId = (Long) servletRequest.getAttribute(AdminAuthInterceptor.OPERATOR_ID_ATTRIBUTE);
        return ApiResponse.success(adminBusinessService.createPolicy(request, operatorId, operatorName));
    }
    @PutMapping("/policies/{id}")
    public ApiResponse<Map<String, Object>> updatePolicy(@PathVariable Long id, @Valid @RequestBody PolicyArticleRequest request) {
        return ApiResponse.success(adminBusinessService.updatePolicy(id, request));
    public ApiResponse<Map<String, Object>> updatePolicy(@PathVariable Long id, @Valid @RequestBody PolicyArticleRequest request, HttpServletRequest servletRequest) {
        Long operatorId = (Long) servletRequest.getAttribute(AdminAuthInterceptor.OPERATOR_ID_ATTRIBUTE);
        return ApiResponse.success(adminBusinessService.updatePolicy(id, request, operatorId));
    }
    @PatchMapping("/policies/{id}/status")
@@ -189,6 +191,16 @@
        return ApiResponse.success(adminBusinessService.createBenefit(request));
    }
    @GetMapping("/service-hero-images")
    public ApiResponse<Map<String, Object>> serviceHeroImages() {
        return ApiResponse.success(adminBusinessService.serviceHeroImages());
    }
    @PutMapping("/service-hero-images")
    public ApiResponse<Map<String, Object>> saveServiceHeroImage(@Valid @RequestBody ServiceHeroImageRequest request) {
        return ApiResponse.success(adminBusinessService.saveServiceHeroImage(request.type(), request.imageUrl()));
    }
    @GetMapping("/products")
    public ApiResponse<PageResult<Map<String, Object>>> products(
            @RequestParam(required = false) Long merchantId,
@@ -218,10 +230,14 @@
    @GetMapping("/merchants/{id}/products")
    public ApiResponse<PageResult<Map<String, Object>>> merchantProducts(
            @PathVariable Long id,
            @RequestParam(required = false) String keyword,
            @RequestParam(required = false) Integer status,
            @RequestParam(required = false) String createdStart,
            @RequestParam(required = false) String createdEnd,
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int size
    ) {
        return ApiResponse.success(adminBusinessService.products(id, null, null, null, null, null, null, null, page, size));
        return ApiResponse.success(adminBusinessService.merchantProducts(id, keyword, status, createdStart, createdEnd, page, size));
    }
    @GetMapping("/products/{id}")
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -180,28 +180,45 @@
        if (status != null) { sql.append(" and status = ?"); countSql.append(" and status = ?"); args.add(status); }
        if (publishStart != null && !publishStart.isBlank()) { sql.append(" and date(publish_time) >= ?"); countSql.append(" and date(publish_time) >= ?"); args.add(publishStart); }
        if (publishEnd != null && !publishEnd.isBlank()) { sql.append(" and date(publish_time) <= ?"); countSql.append(" and date(publish_time) <= ?"); args.add(publishEnd); }
        sql.append(" order by is_top desc, sort_no desc, publish_time desc, id desc");
        // 置顶优先,置顶组内按置顶时间倒序(越后置顶越靠前);非置顶按排序值倒序。
        sql.append(" order by is_top desc, case when is_top = 1 then top_time end desc, sort_no desc, publish_time desc, id desc");
        return page(sql.toString(), countSql.toString(), page, size, args.toArray());
    }
    @Transactional(readOnly = true)
    public Map<String, Object> policyDetail(Long id) {
        return one("select id, policy_no, title, subtitle, cover_url, author, source, content, view_count, is_top, sort_no, publish_time, status, created_at, updated_at from policy_article where deleted = 0 and id = ?", id);
        // created_by/updated_by 存的是管理员 ID,这里一并查出姓名,前端直接展示发布人/修改人。
        // 历史数据没有记录操作人时回落到 author(创建人)。
        return one("""
                select p.id, p.policy_no, p.title, p.subtitle, p.cover_url, p.author,
                       p.created_by, p.updated_by, p.is_top, p.top_time,
                       coalesce(creator.display_name, p.author) as created_by_name,
                       coalesce(updater.display_name, p.author) as updated_by_name,
                       p.source, p.content, p.view_count, p.sort_no, p.publish_time, p.status,
                       p.created_at, p.updated_at
                from policy_article p
                left join admin_operator creator on creator.id = p.created_by and creator.deleted = 0
                left join admin_operator updater on updater.id = p.updated_by and updater.deleted = 0
                where p.deleted = 0 and p.id = ?
                """, id);
    }
    @Transactional
    public Map<String, Object> createPolicy(PolicyArticleRequest request, String operatorName) {
    public Map<String, Object> createPolicy(PolicyArticleRequest request, Long operatorId, String operatorName) {
        int status = defaultNewsStatus(request.status());
        jdbcTemplate.update("insert into policy_article(policy_no, title, subtitle, cover_url, author, source, content, is_top, sort_no, publish_time, status) values (?, ?, ?, ?, ?, ?, ?, ?, ?, case when ? = 1 then now() else null end, ?)",
                BusinessIdGenerator.next("POLICY"), request.title().trim(), blankToNull(request.subtitle()), blankToNull(request.coverUrl()), blankToNull(operatorName), blankToNull(request.source()), sanitizeNewsContent(request.content()), Boolean.TRUE.equals(request.isTop()) ? 1 : 0, request.sortNo() == null ? 0 : request.sortNo(), status, status);
        // top_time 只在勾选置顶时写入,列表用它做置顶组内的排序依据。
        jdbcTemplate.update("insert into policy_article(policy_no, title, subtitle, cover_url, author, created_by, updated_by, source, content, is_top, top_time, sort_no, publish_time, status) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, case when ? = 1 then now() else null end, ?)",
                BusinessIdGenerator.next("POLICY"), request.title().trim(), blankToNull(request.subtitle()), blankToNull(request.coverUrl()), blankToNull(operatorName), operatorId, operatorId, blankToNull(request.source()), sanitizeNewsContent(request.content()), Boolean.TRUE.equals(request.isTop()) ? 1 : 0, Boolean.TRUE.equals(request.isTop()) ? 1 : 0, request.sortNo() == null ? 0 : request.sortNo(), status, status);
        return policyDetail(jdbcTemplate.queryForObject("select last_insert_id()", Long.class));
    }
    @Transactional
    public Map<String, Object> updatePolicy(Long id, PolicyArticleRequest request) {
    public Map<String, Object> updatePolicy(Long id, PolicyArticleRequest request, Long operatorId) {
        int status = defaultNewsStatus(request.status());
        int updated = jdbcTemplate.update("update policy_article set title = ?, subtitle = ?, cover_url = ?, author = ?, source = ?, content = ?, is_top = ?, sort_no = ?, status = ?, publish_time = case when ? = 1 then coalesce(publish_time, now()) else publish_time end where deleted = 0 and id = ?",
                request.title().trim(), blankToNull(request.subtitle()), blankToNull(request.coverUrl()), blankToNull(request.author()), blankToNull(request.source()), sanitizeNewsContent(request.content()), Boolean.TRUE.equals(request.isTop()) ? 1 : 0, request.sortNo() == null ? 0 : request.sortNo(), status, status, id);
        // author 保持原样(发布人只在创建时确定);updated_by 记录本次保存的管理员,空值不覆盖。
        // 新勾选置顶时记录置顶时间;取消置顶时清空,保证置顶组内按"最近置顶"排序。
        int updated = jdbcTemplate.update("update policy_article set title = ?, subtitle = ?, cover_url = ?, author = coalesce(nullif(?, ''), author), updated_by = coalesce(?, updated_by), source = ?, content = ?, is_top = ?, top_time = case when ? = 1 and coalesce(is_top, 0) = 0 then now() when ? = 0 then null else top_time end, sort_no = ?, status = ?, publish_time = case when ? = 1 then coalesce(publish_time, now()) else publish_time end where deleted = 0 and id = ?",
                request.title().trim(), blankToNull(request.subtitle()), blankToNull(request.coverUrl()), blankToNull(request.author()), operatorId, blankToNull(request.source()), sanitizeNewsContent(request.content()), Boolean.TRUE.equals(request.isTop()) ? 1 : 0, Boolean.TRUE.equals(request.isTop()) ? 1 : 0, Boolean.TRUE.equals(request.isTop()) ? 1 : 0, request.sortNo() == null ? 0 : request.sortNo(), status, status, id);
        ensureUpdated(updated);
        return policyDetail(id);
    }
@@ -348,6 +365,21 @@
                    where merchant_id = ? and deleted = 0 and enabled = 1
                    order by sort_no asc, id asc
                    """, id)));
            // 加油服务详情页顶部轮播图:后台在商家编辑页维护,保存时整表替换。
            result.put("heroImages", DatabaseRows.normalize(jdbcTemplate.queryForList("""
                    select id, image_url, sort_no, enabled
                    from transport_fuel_hero_image
                    where merchant_id = ? and deleted = 0
                    order by sort_no asc, id asc
                    """, id)));
        } else if (HERO_IMAGE_MERCHANT_TYPES.contains(String.valueOf(result.get("merchantTypeCode")))) {
            // 子女托管 / 行政审批的详情页轮播图同样按商家配置。
            result.put("heroImages", DatabaseRows.normalize(jdbcTemplate.queryForList("""
                    select id, image_url, sort_no, enabled
                    from merchant_hero_image
                    where merchant_id = ? and deleted = 0
                    order by sort_no asc, id asc
                    """, id)));
        }
        return result;
    }
@@ -410,6 +442,7 @@
        ensureParkingBenefit(id, merchantType.code());
        ensureFuelBenefit(id, merchantType.code(), request.serviceLabel());
        syncFuelStores(id, merchantType.code(), request.applicableStores());
        syncMerchantHeroImages(id, merchantType.code(), request.heroImages());
        syncPremiumTopicMerchant(id, merchantType, request.sortNo());
        Long storeId = createDefaultStore(id, request.merchantName(), request.address());
        syncMerchantBusinessHours(id, storeId, request.businessHours());
@@ -555,6 +588,7 @@
        ensureParkingBenefit(id, merchantType.code());
        ensureFuelBenefit(id, merchantType.code(), request.serviceLabel());
        syncFuelStores(id, merchantType.code(), request.applicableStores());
        syncMerchantHeroImages(id, merchantType.code(), request.heroImages());
        syncPremiumTopicMerchant(id, merchantType, request.sortNo());
        Long storeId = findOrCreateDefaultStore(id, request.merchantName(), request.address());
        syncMerchantBusinessHours(id, storeId, request.businessHours());
@@ -669,10 +703,80 @@
        }
    }
    /**
     * 子女托管 / 行政审批的详情页图:这两类服务的权益是全局一条,图片也全局共用。
     */
    @Transactional(readOnly = true)
    public Map<String, Object> serviceHeroImages() {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("children", firstRowOrEmpty("select id, hero_image from children_entrustment_benefit where deleted = 0 order by id asc limit 1"));
        result.put("approval", firstRowOrEmpty("select id, hero_image from admin_approval_benefit where deleted = 0 order by id asc limit 1"));
        return result;
    }
    @Transactional
    public Map<String, Object> saveServiceHeroImage(String type, String imageUrl) {
        String table = switch (type == null ? "" : type.trim()) {
            case "children" -> "children_entrustment_benefit";
            case "approval" -> "admin_approval_benefit";
            default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的服务类型");
        };
        String url = imageUrl == null || imageUrl.isBlank() ? null : imageUrl.trim();
        int updated = jdbcTemplate.update("update " + table + " set hero_image = ? where deleted = 0 order by id asc limit 1", url);
        if (updated == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "该服务的权益暂未配置");
        return serviceHeroImages();
    }
    private Map<String, Object> firstRowOrEmpty(String sql, Object... args) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbcTemplate.queryForList(sql, args));
        return rows.isEmpty() ? new LinkedHashMap<>() : rows.get(0);
    }
    private boolean enabledFlag(Object value) {
        if (value == null) return true;
        if (value instanceof Boolean bool) return bool;
        if (value instanceof Number number) return number.intValue() != 0;
        String text = String.valueOf(value).trim();
        return "true".equalsIgnoreCase(text) || "1".equals(text);
    }
    private Double numberValue(Object value) {
        if (value instanceof Number number) return number.doubleValue();
        try { return value == null ? null : Double.valueOf(String.valueOf(value)); }
        catch (NumberFormatException ignored) { return null; }
    }
    /** 详情页轮播图按商家配置的服务类型:其余类型顶部图仍用封面图或团单封面。 */
    private static final List<String> HERO_IMAGE_MERCHANT_TYPES = List.of("children", "approval");
    /**
     * 同步商家详情页轮播图。
     *
     * 后台提交整份列表,这里按商家整表替换;列表为 null 表示本次提交没有这个字段,保持原样。
     * 加油服务沿用原有的 transport_fuel_hero_image,子女托管/行政审批使用通用的 merchant_hero_image。
     */
    private void syncMerchantHeroImages(Long merchantId, String merchantType, List<Map<String, Object>> images) {
        if (images == null) return;
        boolean fuel = "fuel".equals(merchantType);
        if (!fuel && !HERO_IMAGE_MERCHANT_TYPES.contains(merchantType)) return;
        List<Map<String, Object>> normalized = new ArrayList<>();
        for (Map<String, Object> image : images) {
            if (image == null) continue;
            Object urlValue = image.containsKey("imageUrl") ? image.get("imageUrl") : image.get("image_url");
            String url = urlValue == null ? "" : String.valueOf(urlValue).trim();
            if (url.isBlank()) continue;
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("imageUrl", url);
            item.put("enabled", enabledFlag(image.get("enabled")) ? 1 : 0);
            normalized.add(item);
        }
        String table = fuel ? "transport_fuel_hero_image" : "merchant_hero_image";
        jdbcTemplate.update("delete from " + table + " where merchant_id = ?", merchantId);
        String insertSql = "insert into " + table + "(merchant_id, image_url, sort_no, enabled, deleted) values (?, ?, ?, ?, 0)";
        for (int index = 0; index < normalized.size(); index++) {
            Map<String, Object> image = normalized.get(index);
            jdbcTemplate.update(insertSql, merchantId, image.get("imageUrl"), (index + 1) * 10, image.get("enabled"));
        }
    }
    private void syncPremiumTopicMerchant(Long merchantId, MerchantType merchantType, Integer sortNo) {
@@ -734,6 +838,64 @@
        return one("select * from benefit where id = ?", id);
    }
    /**
     * 商家编辑页面的团单列表。
     *
     * 按商品维度展示(一条团单一行),修改团单只更新原记录的状态为待审核;
     * 审核记录(merchant_product_audit_record)仍只在团单审核页面展示。
     */
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> merchantProducts(
            Long merchantId,
            String keyword,
            Integer auditStatus,
            String createdStart,
            String createdEnd,
            int page,
            int size
    ) {
        StringBuilder sql = new StringBuilder("""
                select p.id, p.id as product_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, 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, p.updated_at
                from merchant_product p
                left join merchant m on m.id = p.merchant_id
                where p.deleted = 0 and p.merchant_id = ?
                """);
        StringBuilder countSql = new StringBuilder("""
                select count(*)
                from merchant_product p
                where p.deleted = 0 and p.merchant_id = ?
                """);
        List<Object> args = new ArrayList<>();
        args.add(merchantId);
        if (keyword != null && !keyword.isBlank()) {
            sql.append(" and p.product_name like ?");
            countSql.append(" and p.product_name like ?");
            args.add("%" + keyword.trim() + "%");
        }
        if (auditStatus != null) {
            sql.append(" and p.audit_status = ?");
            countSql.append(" and p.audit_status = ?");
            args.add(auditStatus);
        }
        if (createdStart != null && !createdStart.isBlank()) {
            sql.append(" and date(coalesce(p.submit_time, p.created_at)) >= ?");
            countSql.append(" and date(coalesce(p.submit_time, p.created_at)) >= ?");
            args.add(createdStart);
        }
        if (createdEnd != null && !createdEnd.isBlank()) {
            sql.append(" and date(coalesce(p.submit_time, p.created_at)) <= ?");
            countSql.append(" and date(coalesce(p.submit_time, p.created_at)) <= ?");
            args.add(createdEnd);
        }
        sql.append(" order by coalesce(p.updated_at, p.submit_time, p.created_at) desc, p.id desc");
        return page(sql.toString(), countSql.toString(), page, size, args.toArray());
    }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> products(
            Long merchantId,
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementService.java
@@ -20,7 +20,7 @@
    public AdminManagementService(JdbcTemplate jdbc) { this.jdbc = jdbc; }
    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(); }
    @Transactional public Map<String, Object> saveSettings(Map<String, String> body) { for (String key : List.of("bookstoreDiscount", "hotelSubsidy", "homeVideoUrl", "miniProgramUrl", "miniProgramAppId", "miniProgramPath")) 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 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()"); }
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantCreateRequest.java
@@ -35,6 +35,7 @@
        Double latitude,
        Byte groupBuyingEnabled,
        List<Map<String, Object>> applicableStores,
        List<Map<String, Object>> heroImages,
        String usageInstructions,
        String rightsText,
        List<Map<String, Object>> merchantRightsConfig,
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantUpdateRequest.java
@@ -30,6 +30,7 @@
        Double latitude,
        Byte groupBuyingEnabled,
        List<Map<String, Object>> applicableStores,
        List<Map<String, Object>> heroImages,
        String usageInstructions,
        String rightsText,
        List<Map<String, Object>> merchantRightsConfig,
talent-card-server/src/main/java/com/talentcard/server/business/admin/ServiceHeroImageRequest.java
New file
@@ -0,0 +1,15 @@
package com.talentcard.server.business.admin;
import jakarta.validation.constraints.NotBlank;
/**
 * 子女托管 / 行政审批详情页图的保存请求。
 *
 * type 取值 children(子女托管)或 approval(行政审批)。
 */
public record ServiceHeroImageRequest(
        @NotBlank(message = "请选择服务类型")
        String type,
        String imageUrl
) {
}
talent-card-server/src/main/java/com/talentcard/server/business/portal/AdminApprovalPortalService.java
@@ -34,7 +34,9 @@
        result.put("serviceId", serviceId);
        result.put("title", benefit.get("title"));
        result.put("freeLabel", benefit.get("freeLabel"));
        result.put("heroImage", text(merchant.get("coverUrl"), text(benefit.get("heroImage"))));
        // 详情页图优先用该商家自己配置的轮播图,没配时先回落商家封面图,再回落服务权益里的全局图。
        result.put("heroImage", merchantHeroImage(serviceId, text(merchant.get("coverUrl"), text(benefit.get("heroImage")))));
        result.put("heroImages", merchantHeroImages(serviceId));
        result.put("available", number(benefit.get("enabled")) == 1 && number(merchant.get("status")) == 1);
        result.put("unavailableReason", text(benefit.get("unavailableReason"), "该权益暂未开放"));
        List<Map<String, Object>> configuredOrganizations = configuredOrganizations(merchant);
@@ -44,6 +46,23 @@
        return result;
    }
    private List<String> merchantHeroImages(Long merchantId) {
        return query("""
                select image_url
                from merchant_hero_image
                where merchant_id = ? and deleted = 0 and enabled = 1
                order by sort_no asc, id asc
                """, merchantId).stream()
                .map(row -> text(row.get("imageUrl")))
                .filter(url -> !url.isBlank())
                .toList();
    }
    private String merchantHeroImage(Long merchantId, String fallback) {
        List<String> images = merchantHeroImages(merchantId);
        return images.isEmpty() ? fallback : images.get(0);
    }
    private List<Map<String, Object>> configuredOrganizations(Map<String, Object> merchant) {
        List<Map<String, Object>> items = parseApprovalItems(merchant.get("usageInstructions"));
        if (items.isEmpty()) {
talent-card-server/src/main/java/com/talentcard/server/business/portal/ChildrenEntrustmentPortalService.java
@@ -32,7 +32,10 @@
        result.put("serviceId", serviceId);
        result.put("title", benefit.get("title"));
        result.put("applicableCards", text(matchedRight.get("tag"), ""));
        result.put("heroImage", benefit.get("heroImage"));
        // 详情页图优先用该商家自己配置的轮播图,没配时回落商家封面图,最后才是服务权益里的全局图。
        result.put("heroImage", merchantHeroImage(serviceId,
                text(merchant.get("coverUrl"), text(benefit.get("heroImage"), ""))));
        result.put("heroImages", merchantHeroImages(serviceId));
        result.put("rightsText", text(matchedRight.get("detail"), text(benefit.get("rightsText"), "暂无使用说明")));
        result.put("available", available);
        result.put("unavailableReason", available ? "" : text(benefit.get("unavailableReason"), "该权益暂未开放"));
@@ -61,6 +64,23 @@
            school.put("detailAvailable", number(row.get("detailAvailable")) == 1);
            return school;
                }).toList();
    }
    private List<String> merchantHeroImages(Long merchantId) {
        return query("""
                select image_url
                from merchant_hero_image
                where merchant_id = ? and deleted = 0 and enabled = 1
                order by sort_no asc, id asc
                """, merchantId).stream()
                .map(row -> text(row.get("imageUrl"), ""))
                .filter(url -> !url.isBlank())
                .toList();
    }
    private String merchantHeroImage(Long merchantId, String fallback) {
        List<String> images = merchantHeroImages(merchantId);
        return images.isEmpty() ? fallback : images.get(0);
    }
    private List<Map<String, Object>> configuredSchools(Object value) {
@@ -150,7 +170,7 @@
    private Map<String, Object> approvalService(Long serviceId) {
        if (serviceId == null || serviceId <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "学生托付服务信息不能为空");
        List<Map<String, Object>> rows = query("""
                select m.id, m.merchant_rights_config, m.usage_instructions
                select m.id, m.cover_url, m.merchant_rights_config, m.usage_instructions
                from merchant m
                where m.id = ? and m.deleted = 0 and m.merchant_type_code = 'children'
                """, serviceId);
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -59,7 +59,7 @@
                select id, policy_no, title, subtitle, cover_url, is_top, publish_time
                from policy_article
                where deleted = 0 and status = 1
                order by is_top desc, sort_no desc, publish_time desc, id desc
                order by is_top desc, case when is_top = 1 then top_time end desc, sort_no desc, publish_time desc, id desc
                limit 5
                """));
        result.put("benefits", query("""
@@ -79,12 +79,15 @@
        Map<String, Object> homeSettings = new LinkedHashMap<>();
        for (Map<String, Object> row : query("""
                select setting_key, setting_value from admin_setting
                where setting_key in ('homeVideoUrl', 'miniProgramUrl')
                where setting_key in ('homeVideoUrl', 'miniProgramUrl', 'miniProgramAppId', 'miniProgramPath')
                """)) {
            homeSettings.put(String.valueOf(row.get("settingKey")), row.get("settingValue"));
        }
        result.put("homeVideoUrl", String.valueOf(homeSettings.getOrDefault("homeVideoUrl", "")));
        result.put("miniProgramUrl", String.valueOf(homeSettings.getOrDefault("miniProgramUrl", "")));
        // 首页入口指向其它小程序时必须给出目标 AppID,前端用 navigateToMiniProgram 直接跳转。
        result.put("miniProgramAppId", String.valueOf(homeSettings.getOrDefault("miniProgramAppId", "")));
        result.put("miniProgramPath", String.valueOf(homeSettings.getOrDefault("miniProgramPath", "")));
        if (talentId != null) {
            result.put("cards", query("""
                    select id, talent_id, card_no, card_type, card_level, valid_start, valid_end, status
@@ -132,7 +135,7 @@
                select id, policy_no, title, subtitle, category_name, cover_url, tags, author, source, content, view_count, is_top, publish_time
                from policy_article
                where deleted = 0 and status = 1
                order by is_top desc, sort_no desc, publish_time desc, id desc
                order by is_top desc, case when is_top = 1 then top_time end desc, sort_no desc, publish_time desc, id desc
                """, "select count(*) from policy_article where deleted = 0 and status = 1", page, size);
        List<Map<String, Object>> records = result.records().stream().map(row -> {
            Map<String, Object> item = new LinkedHashMap<>(row);
@@ -284,7 +287,7 @@
        String merchantWhere = " where deleted = 0 and merchant_type_code = 'food'";
        PageResult<Map<String, Object>> result = page("""
                select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                       status, sort_no, longitude, latitude, business_status,
                       status, sort_no, longitude, latitude, business_status, merchant_rights_config,
                       (select min(p.sale_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as sale_price,
                       (select min(p.original_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as original_price
                from merchant
@@ -295,9 +298,15 @@
            Long merchantId = item.get("id") instanceof Number id ? id.longValue() : null;
            boolean couponAvailable = merchantId != null && hasActiveMerchantCoupon(merchantId);
            String discountText = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId);
            Map<String, Object> right = matchingMerchantRight(
                    merchantRightsConfigs(row.get("merchantRightsConfig")),
                    activeProductCardLevel(talentId),
                    activeTalentSourceAreaType(talentId),
                    true);
            item.put("couponAvailable", couponAvailable);
            item.put("discountText", discountText);
            item.put("benefitTags", merchantBenefitTags("food", couponAvailable, discountText, null));
            item.put("benefitTags", merchantBenefitTags(merchantId, activeProductCardLevel(talentId), "food", couponAvailable, discountText, right));
            item.remove("merchantRightsConfig");
            return item;
        }).toList();
        return new PageResult<>(records, result.page(), result.size(), result.total());
@@ -330,7 +339,7 @@
        String merchantWhere = " where deleted = 0 and merchant_type_code = 'scenic'";
        PageResult<Map<String, Object>> result = page("""
                select id, merchant_name, merchant_type_code as merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
                       status, sort_no, longitude, latitude, business_status,
                       status, sort_no, longitude, latitude, business_status, merchant_rights_config,
                       (select min(p.sale_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as sale_price,
                       (select min(p.original_price) from merchant_product p where p.merchant_id = merchant.id and p.deleted = 0 and p.status = 1 and p.audit_status = 2) as original_price
                from merchant
@@ -342,9 +351,15 @@
            Long merchantId = item.get("id") instanceof Number id ? id.longValue() : null;
            boolean couponAvailable = merchantId != null && hasActiveMerchantCoupon(merchantId);
            String discountText = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId);
            Map<String, Object> right = matchingMerchantRight(
                    merchantRightsConfigs(row.get("merchantRightsConfig")),
                    activeProductCardLevel(talentId),
                    activeTalentSourceAreaType(talentId),
                    true);
            item.put("couponAvailable", couponAvailable);
            item.put("discountText", discountText);
            item.put("benefitTags", merchantBenefitTags("scenic", couponAvailable, discountText, null));
            item.put("benefitTags", merchantBenefitTags(merchantId, activeProductCardLevel(talentId), "scenic", couponAvailable, discountText, right));
            item.remove("merchantRightsConfig");
            return item;
        }).toList();
        return new PageResult<>(records, result.page(), result.size(), result.total());
@@ -403,7 +418,7 @@
                    true);
            item.put("couponAvailable", couponAvailable);
            item.put("discountText", discountText);
            item.put("benefitTags", merchantBenefitTags(responseMerchantType, couponAvailable, discountText, right));
            item.put("benefitTags", merchantBenefitTags(merchantId, activeProductCardLevel(talentId), responseMerchantType, couponAvailable, discountText, right));
            item.remove("merchantRightsConfig");
            return item;
        }).toList();
@@ -419,16 +434,16 @@
        String sourceAreaType = activeTalentSourceAreaType(talentId);
        List<Map<String, Object>> rights = merchantRightsConfigs(row.get("merchantRightsConfig"));
        Map<String, Object> right = matchingMerchantRight(rights, cardLevel, sourceAreaType, true);
        Long merchantId = item.get("id") instanceof Number number ? number.longValue() : null;
        if ("parking".equals(merchantType)) {
            Long merchantId = item.get("id") instanceof Number number ? number.longValue() : null;
            return parkingBenefitTags(merchantId, rights, cardLevel, sourceAreaType);
        }
        if ("carWash".equals(merchantType)) {
            String tag = carWashListDiscountText(carWashExternalLinkType(rights), right);
            String tag = carWashDiscountTag(merchantId, talentId, rights, right);
            return tag.isBlank() ? List.of() : List.of(tag);
        }
        if (usesMerchantDetailHeaderTags(merchantType) || "cinema".equals(merchantType)) {
            return merchantBenefitTags(merchantType, couponAvailable, discountText, right);
            return merchantBenefitTags(merchantId, cardLevel, merchantType, couponAvailable, discountText, right);
        }
        boolean shoppingType = MerchantTypeCatalog.find(merchantType)
@@ -437,10 +452,11 @@
        if (shoppingType) {
            List<String> tags = new ArrayList<>();
            if (couponAvailable) tags.add("券");
            if (right != null) {
                String configuredDiscount = merchantRightDiscountText(right);
                if (!configuredDiscount.isBlank()) tags.add(configuredDiscount);
            }
            // 购物类同样取「团单折扣」与「商家权益折扣」中更低的一档。
            String configuredDiscount = right == null ? "" : merchantRightDiscountText(right);
            String productDiscount = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId);
            String discountTag = lowerDiscountTag(productDiscount, configuredDiscount);
            if (!discountTag.isBlank()) tags.add(discountTag);
            return tags;
        }
@@ -455,6 +471,8 @@
    }
    private List<String> merchantBenefitTags(
            Long merchantId,
            String cardLevel,
            String merchantType,
            boolean couponAvailable,
            String productDiscountText,
@@ -467,22 +485,30 @@
        }
        List<String> tags = new ArrayList<>();
        if (couponAvailable) tags.add("券");
        String discount = textOrDefault(productDiscountText, "").trim();
        if ("人才专享".equals(discount)) discount = "";
        boolean hotelLike = "hotel".equals(type) || "apartment".equals(type);
        // 免费次数:酒店/公寓用商家权益里的免费券次数,其它类型用团单的免单次数。
        String freeTag = "";
        if (hotelLike && right != null && booleanValue(right.get("freeVoucherEnabled"))) {
            String times = textOrDefault(right.get("freeVoucherTimes"), "").trim();
            discount = times.isBlank() ? "免费" : "免费" + times + "次";
            freeTag = times.isBlank() ? "免费" : "免费" + times + "次";
        } else if (!hotelLike) {
            freeTag = productFreeTimesTag(merchantId, cardLevel);
        }
        if (!discount.isBlank() && !tags.contains(discount)) tags.add(discount);
        if (!freeTag.isBlank() && !tags.contains(freeTag)) tags.add(freeTag);
        // 折扣标签统一取「团单折扣」与「商家权益到店消费折扣」中更低的那档(数值越小折扣越大)。
        String discount = textOrDefault(productDiscountText, "").trim();
        if ("人才专享".equals(discount)) discount = "";
        String configuredDiscount = hotelLike ? consumeDiscountText(right) : (right == null ? "" : merchantRightDiscountText(right));
        String discountTag = lowerDiscountTag(discount, configuredDiscount);
        if (!discountTag.isBlank() && !tags.contains(discountTag)) tags.add(discountTag);
        // 酒店/公寓的到店消费如果配成"优惠"(减 X 元),它不是折扣,单独展示。
        if (hotelLike && right != null && booleanValue(right.get("storeConsume")) && booleanValue(right.get("consumeEnabled"))) {
            String consumeType = textOrDefault(right.get("consumeType"), "");
            String consumeDiscount = textOrDefault(right.get("consumeDiscount"), "").trim();
            String consumeCoupon = textOrDefault(right.get("consumeCoupon"), "").trim();
            String consumeTag = "";
            if ("折扣".equals(consumeType) && !consumeDiscount.isBlank()) consumeTag = consumeDiscount + "折";
            else if ("优惠".equals(consumeType) && !consumeCoupon.isBlank()) consumeTag = "减" + consumeCoupon;
            if (!consumeTag.isBlank() && !tags.contains(consumeTag)) tags.add(consumeTag);
            if ("优惠".equals(consumeType) && !consumeCoupon.isBlank()) {
                String consumeTag = "减" + consumeCoupon;
                if (!tags.contains(consumeTag)) tags.add(consumeTag);
            }
        }
        return tags;
    }
@@ -532,7 +558,7 @@
            boolean couponAvailable = booleanValue(item.get("couponAvailable"));
            item.put("couponAvailable", couponAvailable);
            item.put("discountText", couponAvailable ? "券" : "");
            item.put("benefitTags", merchantBenefitTags("cinema", couponAvailable, "", null));
            item.put("benefitTags", merchantBenefitTags(null, null, "cinema", couponAvailable, "", null));
            return item;
        }).toList();
        return new PageResult<>(records, result.page(), result.size(), result.total());
@@ -582,7 +608,8 @@
                List<Map<String, Object>> rights = merchantRightsConfigs(item.get("merchantRightsConfig"));
                String externalLinkType = carWashExternalLinkType(rights);
                Map<String, Object> right = matchingMerchantRight(rights, cardLevel, sourceAreaType, true);
                String discountText = carWashListDiscountText(externalLinkType, right);
                Long merchantId = item.get("id") instanceof Number number ? number.longValue() : null;
                String discountText = carWashDiscountTag(merchantId, talentId, rights, right);
                item.put("externalLinkType", externalLinkType);
                item.put("discountText", discountText);
                item.put("benefitTagVisible", !discountText.isBlank());
@@ -708,6 +735,61 @@
        } catch (NumberFormatException ignored) {
            return "";
        }
    }
    /** 酒店、公寓的到店消费折扣:配在 consumeType=折扣 的 consumeDiscount 上。 */
    private String consumeDiscountText(Map<String, Object> right) {
        if (right == null) return "";
        if (!booleanValue(right.get("storeConsume")) || !booleanValue(right.get("consumeEnabled"))) return "";
        if (!"折扣".equals(textOrDefault(right.get("consumeType"), ""))) return "";
        String discount = textOrDefault(right.get("consumeDiscount"), "").trim();
        if (discount.isBlank()) return "";
        BigDecimal value = discountValue(discount + "折");
        return value == null ? "" : formatDiscount(value);
    }
    /**
     * 自助洗车折扣标签。
     *
     * 小程序跳转模式不打折扣标签(由外部小程序自己展示权益);普通模式取「团单折扣」与
     * 「商家权益折扣」中更低的那档。
     */
    private String carWashDiscountTag(Long merchantId, Long talentId, List<Map<String, Object>> rights, Map<String, Object> right) {
        String configured = carWashListDiscountText(carWashExternalLinkType(rights), right);
        if (configured.isBlank()) return "";
        String productDiscount = merchantId == null ? "" : merchantLowestTalentDiscount(merchantId, talentId);
        return lowerDiscountTag(productDiscount, configured);
    }
    /**
     * 取两个折扣标签里更低的那档(数值越小折扣越大),统一格式化成 "X.Y折"。
     *
     * 团单折扣来自商品配置(如"超出免单次数后 7 折"),商家权益折扣来自后台"折扣"字段;
     * 两者都配了时按更低的一档展示,只有一个时用那一个,都不是数字则返回空。
     */
    private String lowerDiscountTag(String first, String second) {
        BigDecimal firstValue = discountValue(first);
        BigDecimal secondValue = discountValue(second);
        if (firstValue == null && secondValue == null) return "";
        if (firstValue == null) return formatDiscount(secondValue);
        if (secondValue == null) return formatDiscount(firstValue);
        return formatDiscount(firstValue.min(secondValue));
    }
    private BigDecimal discountValue(String tag) {
        if (tag == null) return null;
        String text = tag.trim().replace("折", "").trim();
        if (text.isBlank()) return null;
        try {
            BigDecimal value = new BigDecimal(text);
            return value.compareTo(BigDecimal.ZERO) > 0 && value.compareTo(BigDecimal.TEN) <= 0 ? value : null;
        } catch (NumberFormatException ignored) {
            return null;
        }
    }
    private String formatDiscount(BigDecimal value) {
        return value.setScale(1, RoundingMode.HALF_UP).toPlainString() + "折";
    }
    private List<String> parkingBenefitTags(
@@ -970,7 +1052,7 @@
        merchant.put("consumeEnabled", consumeEnabled);
        merchant.put("benefitTags", merchantListBenefitTags(merchant, merchant, talentId));
        merchant.put("usageInstructions", merchantUsageInstructions(
                id, merchant.get("merchantRightsConfig"), cardLevel, merchant.get("usageInstructions")));
                id, merchant.get("merchantRightsConfig"), cardLevel, sourceAreaType, merchant.get("usageInstructions")));
        List<String> heroImages = new ArrayList<>();
        String coverUrl = textOrDefault(merchant.get("coverUrl"), "");
        if (!coverUrl.isBlank()) heroImages.add(coverUrl);
@@ -992,14 +1074,35 @@
    private Map<String, Object> matchingMerchantRight(List<Map<String, Object>> rights, String cardLevel, String sourceAreaType, boolean requireEnabled) {
        if (cardLevel == null) return null;
        List<String> candidates = merchantRightCandidates(cardLevel, sourceAreaType);
        // 第一轮:完整等级名匹配。商家可以按“卓越人才卡(本地)/(外地)”分开配置,必须命中对应地区的那一条。
        for (String candidate : candidates) {
            for (Map<String, Object> right : rights) {
                if (requireEnabled && !booleanValue(right.get("enabled"))) continue;
                if (candidate.equals(textOrDefault(right.get("level"), ""))) return right;
                if (sameCardLevel(right.get("level"), candidate)) return right;
            }
        }
        // 第二轮:退化成只比等级。人才地区已知时,不再使用地区对不上的配置
        // (否则本地人才会命中“(外地)”配置,显示外地权益内容)。
        String area = normalizedSourceAreaType(sourceAreaType);
        for (String candidate : candidates) {
            for (Map<String, Object> right : rights) {
                if (requireEnabled && !booleanValue(right.get("enabled"))) continue;
                if (!sameCardLevel(right.get("level"), candidate)) continue;
                if (!areaMatchesConfiguredLevel(right.get("level"), area)) continue;
                return right;
            }
        }
        return null;
    }
    /** 人才地区已知时,带“(本地)/(外地)”后缀的商家配置必须与人才地区一致;没带后缀的配置不受限制。 */
    private boolean areaMatchesConfiguredLevel(Object configuredLevel, String area) {
        if (area == null || area.isBlank()) return true;
        String level = textOrDefault(configuredLevel, "").toUpperCase(Locale.ROOT);
        boolean configuredOutside = level.contains("外地") || level.contains("OUTSIDE");
        boolean configuredLocal = level.contains("本地") || level.contains("LOCAL");
        if (!configuredOutside && !configuredLocal) return true;
        return "OUTSIDE".equals(area) ? configuredOutside : ("LOCAL".equals(area) && configuredLocal);
    }
    private List<String> merchantRightCandidates(String cardLevel, String sourceAreaType) {
@@ -1024,11 +1127,14 @@
        };
    }
    private String merchantUsageInstructions(Long merchantId, Object rightsConfig, String cardLevel, Object fallback) {
    private String merchantUsageInstructions(Long merchantId, Object rightsConfig, String cardLevel, String sourceAreaType, Object fallback) {
        if (cardLevel != null) {
            String area = normalizedSourceAreaType(sourceAreaType);
            for (Map<String, Object> right : merchantRightsConfigs(rightsConfig)) {
                if (!booleanValue(right.get("enabled")) || !booleanValue(right.get("consumeEnabled"))
                        || !booleanValue(right.get("storeConsume")) || !sameCardLevel(right.get("level"), cardLevel)) continue;
                // “(本地)/(外地)”分开配置的商家,说明文案也要跟着人才地区走。
                if (!areaMatchesConfiguredLevel(right.get("level"), area)) continue;
                String description = textOrDefault(right.get("consumeDesc"), "");
                if (!description.isBlank()) return description;
            }
@@ -1111,6 +1217,31 @@
        return count != null && count > 0;
    }
    /**
     * 团单按人才等级配置了免单时的“免费/免费N次”标签。
     *
     * 多个团单免费次数不一致时取最大值;999 表示不限次数,只展示“免费”。
     */
    private String productFreeTimesTag(Long merchantId, String cardLevel) {
        if (merchantId == null || cardLevel == null || cardLevel.isBlank()) return "";
        int maxTimes = 0;
        boolean freeAvailable = false;
        for (Map<String, Object> product : query("""
                select id, original_price, sale_price, rights_config
                from merchant_product
                where merchant_id = ? and deleted = 0 and status = 1 and audit_status = 2
                """, merchantId)) {
            ProductPriceCalculator.ProductRight right =
                    ProductPriceCalculator.productRight(product.get("rightsConfig"), cardLevel);
            if (right == null || !right.free()) continue;
            freeAvailable = true;
            if (right.limitCount() > maxTimes) maxTimes = right.limitCount();
        }
        if (!freeAvailable) return "";
        if (maxTimes <= 0 || maxTimes >= 999) return "免费";
        return "免费" + maxTimes + "次";
    }
    private String merchantLowestTalentDiscount(Long merchantId, Long talentId) {
        String cardLevel = activeProductCardLevel(talentId);
        if (cardLevel == null) return "";
talent-card-server/src/main/java/com/talentcard/server/service/BenefitQuotaService.java
@@ -381,9 +381,9 @@
        List<String> candidates = cardLevelCandidates(cardLevel, primary.sourceAreaType());
        try {
            List<Map<String, Object>> rights = JSON_MAPPER.readValue(config, RIGHTS_TYPE);
            matched = matchingMerchantRight(rights, candidates, true);
            matched = matchingMerchantRight(rights, candidates, true, primary.sourceAreaType());
            if (matched == null) {
                disabledMatched = matchingMerchantRight(rights, candidates, false);
                disabledMatched = matchingMerchantRight(rights, candidates, false, primary.sourceAreaType());
            }
        } catch (Exception exception) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "酒店商户权益配置格式不正确");
@@ -439,24 +439,46 @@
        return cardLevel == null || cardLevel.isBlank() ? List.of() : List.of(cardLevel);
    }
    private Map<String, Object> matchingMerchantRight(List<Map<String, Object>> rights, List<String> candidates, boolean requireEnabled) {
    private Map<String, Object> matchingMerchantRight(List<Map<String, Object>> rights, List<String> candidates, boolean requireEnabled, String sourceAreaType) {
        if (rights == null || rights.isEmpty() || candidates == null || candidates.isEmpty()) {
            return null;
        }
        // 第一轮:完整等级名匹配,按“(本地)/(外地)”分开配置时命中对应地区的那一条。
        for (String candidate : candidates) {
            for (Map<String, Object> right : rights) {
                if (requireEnabled && !booleanValue(right.get("enabled"))) {
                    continue;
                }
                String configured = text(right.get("level"));
                if (candidate.equals(configured) || sameCardLevel(configured, candidate)) {
                    return right;
                if (candidate.equals(configured)) return right;
            }
        }
        // 第二轮:退化成只比等级,但人才地区已知时不能用地区对不上的配置。
        String area = normalizedSourceAreaType(sourceAreaType);
        for (String candidate : candidates) {
            for (Map<String, Object> right : rights) {
                if (requireEnabled && !booleanValue(right.get("enabled"))) {
                    continue;
                }
                String configured = text(right.get("level"));
                if (!sameCardLevel(configured, candidate)) continue;
                if (!areaMatchesConfiguredLevel(configured, area)) continue;
                return right;
            }
        }
        return null;
    }
    /** 人才地区已知时,带“(本地)/(外地)”后缀的商家配置必须与人才地区一致;没带后缀的配置不受限制。 */
    private boolean areaMatchesConfiguredLevel(String configuredLevel, String area) {
        if (area == null || area.isBlank()) return true;
        String level = text(configuredLevel).toUpperCase(Locale.ROOT);
        boolean configuredOutside = level.contains("外地") || level.contains("OUTSIDE");
        boolean configuredLocal = level.contains("本地") || level.contains("LOCAL");
        if (!configuredOutside && !configuredLocal) return true;
        return "OUTSIDE".equals(area) ? configuredOutside : ("LOCAL".equals(area) && configuredLocal);
    }
    private Object firstValue(Map<String, Object> values, String... keys) {
        for (String key : keys) {
            Object value = values.get(key);
talent-card-server/src/main/resources/application.yml
@@ -50,7 +50,7 @@
  cors:
    # 后台管理端(vite dev,默认 5273)与 HBuilderX Web 调试来源;部署时用 CORS_ALLOWED_ORIGINS 覆盖为真实域名。
    # localhost 与局域网地址都要列:换网段后局域网 IP 会变,localhost 是最稳的调试入口。
    allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5273,http://127.0.0.1:5273,http://localhost:7893,http://localhost:7894,http://127.0.0.1:7893,http://127.0.0.1:7894,http://192.168.110.26:5273,http://192.168.110.26:,http://192.168.110.15:5273,http://192.168.110.15:5173}
    allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5273,http://127.0.0.1:5273,http://localhost:7893,http://localhost:7894,http://127.0.0.1:7893,http://127.0.0.1:7894,http://192.168.110.26:5273,http://192.168.110.26:,http://192.168.110.15:5273,http://192.168.110.15:5173,http://192.168.110.13:5273,http://192.168.110.13:5173}
    # 本机调试来源的端口是随机的:微信开发者工具每次启动都换一个端口(见过 42665),
    # HBuilderX 内置浏览器同理。写死端口必然漏,所以按 origin 通配匹配任意端口。
    # 只放行 localhost / 127.0.0.1,不对公网来源开口子;真实域名请加进上面的精确白名单。
talent-card-server/src/main/resources/db/migration/V148__policy_article_top_time.sql
New file
@@ -0,0 +1,5 @@
-- 政策列表需要「置顶优先,置顶组内按置顶时间倒序」,原来只有 is_top 标记,没有置顶时间。
alter table policy_article add column top_time datetime default null comment '置顶时间:勾选置顶时写入,用于置顶组内排序' after is_top;
-- 历史置顶数据回填:没有记录过置顶时间,先用最后更新时间兜底。
update policy_article set top_time = coalesce(updated_at, created_at) where is_top = 1 and top_time is null;
talent-card-server/src/main/resources/db/migration/V149__clear_service_hero_placeholders.sql
New file
@@ -0,0 +1,4 @@
-- 子女托管 / 行政审批的详情页图改为按商家配置后,服务级权益里种下的占位图
-- (/static/stay-assets/hotel-vienna.jpg)不再作为默认展示,清空后回落商家封面图。
update children_entrustment_benefit set hero_image = null where hero_image = '/static/stay-assets/hotel-vienna.jpg';
update admin_approval_benefit set hero_image = null where hero_image = '/static/stay-assets/hotel-vienna.jpg';
talent-card-server/src/main/resources/db/migration/V150__merchant_hero_image.sql
New file
@@ -0,0 +1,14 @@
-- 子女托管 / 行政审批等服务的详情页轮播图改为按商家配置。
-- 未单独配置的商家仍回落到服务权益里原有的全局图片,行为不变。
create table if not exists merchant_hero_image (
    id bigint unsigned not null auto_increment,
    merchant_id bigint unsigned not null,
    image_url varchar(500) not null,
    sort_no int not null default 0,
    enabled tinyint not null default 1,
    created_at datetime not null default current_timestamp,
    updated_at datetime not null default current_timestamp on update current_timestamp,
    deleted tinyint not null default 0,
    primary key (id),
    key idx_merchant_hero_image_merchant (merchant_id, deleted, sort_no)
) engine=InnoDB default charset=utf8mb4 comment='商家详情页轮播图';
talent-card-server/src/test/java/com/talentcard/server/business/portal/PortalServiceTests.java
@@ -110,11 +110,11 @@
    @SuppressWarnings("unchecked")
    void merchantDetailHeaderTagsDriveListTags() throws Exception {
        Method method = PortalService.class.getDeclaredMethod(
                "merchantBenefitTags", String.class, boolean.class, String.class, Map.class);
                "merchantBenefitTags", Long.class, String.class, String.class, boolean.class, String.class, Map.class);
        method.setAccessible(true);
        List<String> foodTags = (List<String>) method.invoke(service, "food", true, "8.8折", null);
        List<String> hotelTags = (List<String>) method.invoke(service, "hotel", true, "7折", Map.of(
        List<String> foodTags = (List<String>) method.invoke(service, null, null, "food", true, "8.8折", null);
        List<String> hotelTags = (List<String>) method.invoke(service, null, null, "hotel", true, "7折", Map.of(
                "freeVoucherEnabled", true,
                "freeVoucherTimes", "2",
                "storeConsume", true,
@@ -123,7 +123,22 @@
                "consumeDiscount", "8.5"));
        assertThat(foodTags).containsExactly("券", "8.8折");
        assertThat(hotelTags).containsExactly("券", "免费2次", "8.5折");
        // 酒店折扣同样取「团单折扣 7 折」与「商家权益到店消费折扣 8.5 折」中更低的一档。
        assertThat(hotelTags).containsExactly("券", "免费2次", "7.0折");
    }
    @Test
    void productFreeRightsProduceFreeTimesTag() throws Exception {
        when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of(
                Map.of("rightsConfig", "[{\"level\": \"卓越人才卡\", \"enabled\": true, \"free\": true,"
                        + " \"limitCount\": 10, \"exceedDiscount\": 7}]"),
                Map.of("rightsConfig", "[{\"level\": \"卓越人才卡\", \"enabled\": true, \"free\": true,"
                        + " \"limitCount\": 1, \"exceedDiscount\": 6}]")));
        Method method = PortalService.class.getDeclaredMethod("productFreeTimesTag", Long.class, String.class);
        method.setAccessible(true);
        assertThat((String) method.invoke(service, 83L, "卓越人才卡")).isEqualTo("免费10次");
        assertThat((String) method.invoke(service, 83L, "杰出人才卡")).isEmpty();
    }
    @Test
@@ -144,4 +159,36 @@
        assertThat(freeTags).containsExactly("免费");
        assertThat(paidTags).containsExactly("8.5折");
    }
    @Test
    @SuppressWarnings("unchecked")
    void merchantRightMatchingKeepsLocalAndOutsideRightsApart() throws Exception {
        Method method = PortalService.class.getDeclaredMethod(
                "matchingMerchantRight", List.class, String.class, String.class, boolean.class);
        method.setAccessible(true);
        List<Map<String, Object>> rights = List.of(
                Map.of("level", "卓越人才卡(外地)", "tag", "卓越人才卡(外地)", "detail", "外地权益", "enabled", true),
                Map.of("level", "卓越人才卡(本地)", "tag", "卓越人才卡(本地)", "detail", "本地权益", "enabled", true));
        Map<String, Object> local = (Map<String, Object>) method.invoke(service, rights, "卓越人才卡", "LOCAL", true);
        Map<String, Object> outside = (Map<String, Object>) method.invoke(service, rights, "卓越人才卡", "OUTSIDE", true);
        assertThat(local.get("detail")).isEqualTo("本地权益");
        assertThat(outside.get("detail")).isEqualTo("外地权益");
    }
    @Test
    void merchantRightMatchingNeverFallsBackToAnotherAreaScope() throws Exception {
        Method method = PortalService.class.getDeclaredMethod(
                "matchingMerchantRight", List.class, String.class, String.class, boolean.class);
        method.setAccessible(true);
        // 只配了“(外地)”时,本地人才拿不到这份权益;不带地区后缀的配置两边都能用。
        List<Map<String, Object>> outsideOnly = List.of(
                Map.of("level", "卓越人才卡(外地)", "detail", "外地权益", "enabled", true));
        List<Map<String, Object>> plain = List.of(Map.of("level", "卓越人才卡", "detail", "通用权益", "enabled", true));
        assertThat(method.invoke(service, outsideOnly, "卓越人才卡", "LOCAL", true)).isNull();
        assertThat(method.invoke(service, plain, "卓越人才卡", "LOCAL", true)).isNotNull();
        assertThat(method.invoke(service, plain, "卓越人才卡", "OUTSIDE", true)).isNotNull();
    }
}