wenminjie
2026-08-05 f130d019f4315af0225497d8d7f4e1b87e1e5bbc
二期后端小程序对接:亲属卡
2个文件已修改
484 ■■■■■ 已修改文件
rencaika/pages/stay-benefits/stay-benefits.uvue 468 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/stay-benefits/stay-benefits.uvue
@@ -1,9 +1,74 @@
<template>
    <view class="page">
        <view class="custom-nav" :class="isSolidNav ? 'is-solid' : ''"><view class="nav-bg"></view><view class="safe-space" :style="'height:' + navTop + 'px'"></view><view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'"><view class="back-button" @tap="goBack"><image src="/static/food-assets/arrow-left-white.svg"></image></view><text class="nav-title">住--舒心安居</text><view class="capsule-space" :style="'width:' + navWidth + 'px'"></view></view></view>
        <scroll-view class="page-scroll" scroll-y lower-threshold="80" @scroll="handleScroll" @scrolltolower="loadMore"><view class="hero"><image class="hero-image" :src="currentScene.image" mode="aspectFill" @error="replaceHeroImage"></image><view class="hero-shade"></view><text v-if="currentScene.name != ''" class="scene-name">{{currentScene.name}}</text><text v-if="scenes.length == 0" class="no-gallery">暂无图册内容</text><scroll-view v-else class="scene-scroll" scroll-x scroll-with-animation :scroll-left="sceneScrollLeft" @scrollend="handleSceneScrollEnd"><view class="scene-row" :style="'width:' + sceneTrackWidth + 'rpx'"><view class="scene-pad"></view><view class="scene-item" v-for="(item,index) in scenes" :key="item.id" :class="index == activeIndex ? 'active' : ''" @tap="selectScene(index)"><image :src="item.image" mode="aspectFill" @error="replaceSceneImage(index)"></image></view><view class="scene-pad"></view></view></scroll-view></view>
            <view v-if="topicDescription != ''" class="topic-summary"><view class="intro-card"><text>{{topicDescription}}</text></view></view>
            <view class="service-section"><view class="service-tabs"><view :class="activeTab == 'hotel' ? 'active' : ''" @tap="switchTab('hotel')"><text>酒店服务</text><view></view></view><view :class="activeTab == 'apartment' ? 'active' : ''" @tap="switchTab('apartment')"><text>公寓租赁服务</text><view></view></view></view><view v-if="initialLoading" class="list-state"><view class="loading-spinner"></view><text>正在加载住宿服务</text></view><view v-else-if="initialError" class="list-state error-state"><text>住宿服务加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view><view v-else-if="hasLoaded && visibleServices.length == 0" class="list-state"><text>{{activeTab == 'hotel' ? '暂无相关酒店服务' : '暂无相关公寓租赁服务'}}</text></view><view v-else-if="hasLoaded"><view class="service-item" :class="item.available ? '' : 'is-unavailable'" v-for="item in visibleServices" :key="item.id" @tap="openService(item)"><image class="service-image" :src="item.image" mode="aspectFill" @error="replaceServiceImage(item)"></image><view class="service-info"><text class="service-name">{{item.name}}</text><text class="discount-tag">{{item.available ? item.discount : '已下架'}}</text><view class="address"><image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text></view></view></view></view><view v-if="loadingMore" class="list-state compact"><view class="loading-spinner"></view><text>正在加载</text></view><view v-else-if="loadMoreError" class="list-state compact load-error" @tap="retryLoadMore"><text>加载失败,点击重试</text></view><view v-else-if="hasLoaded && !hasMore && visibleServices.length > 0" class="no-more"><text>已经到底了</text></view></view><view class="bottom-space"></view>
        <view class="custom-nav" :class="isSolidNav ? 'is-solid' : ''">
            <view class="nav-bg"></view>
            <view class="safe-space" :style="'height:' + navTop + 'px'"></view>
            <view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'">
                <view class="back-button" hover-class="back-button-active" @tap="goBack">
                    <image src="/static/food-assets/arrow-left-white.svg" mode="aspectFit"></image>
                </view><text class="nav-title">住—舒心安居</text>
                <view class="capsule-space" :style="'width:' + navWidth + 'px'"></view>
            </view>
        </view>
        <scroll-view class="page-scroll" scroll-y lower-threshold="80" @scroll="handleScroll" @scrolltolower="loadMore">
            <view class="hero">
                <image class="hero-image" :src="currentScene.image" mode="aspectFill" @error="replaceHeroImage"></image>
                <view class="hero-shade"></view><text v-if="currentScene.name != ''"
                    class="scene-name">{{currentScene.name}}</text><text v-if="scenes.length == 0"
                    class="no-gallery">暂无图册内容</text><scroll-view v-else class="scene-scroll" scroll-x :show-scrollbar="false"
                    scroll-with-animation :scroll-left="sceneScrollLeft" @scrollend="handleSceneScrollEnd">
                    <view class="scene-row" :style="'width:' + sceneTrackWidth + 'rpx'">
                        <view class="scene-pad"></view>
                        <view class="scene-item" v-for="(item,index) in scenes" :key="item.id"
                            :class="index == activeIndex ? 'active' : ''" @tap="selectScene(index)">
                            <image :src="item.image" mode="aspectFill" @error="replaceSceneImage(index)"></image>
                        </view>
                        <view class="scene-pad"></view>
                    </view>
                </scroll-view>
            </view>
            <view v-if="topicDescription != ''" class="topic-summary">
                <view class="intro-card"><text>{{topicDescription}}</text></view>
            </view>
            <view class="service-section">
                <view class="service-tabs">
                    <view :class="activeTab == 'hotel' ? 'active' : ''" @tap="switchTab('hotel')"><text>酒店服务</text>
                        <view></view>
                    </view>
                    <view :class="activeTab == 'apartment' ? 'active' : ''" @tap="switchTab('apartment')">
                        <text>公寓租赁服务</text>
                        <view></view>
                    </view>
                </view>
                <view v-if="initialLoading" class="list-state">
                    <view class="loading-spinner"></view><text>正在加载住宿服务</text>
                </view>
                <view v-else-if="initialError" class="list-state error-state"><text>住宿服务加载失败</text><text
                        class="retry-text" @tap="retryInitialLoad">重新加载</text></view>
                <view v-else-if="hasLoaded && visibleServices.length == 0" class="list-state">
                    <text>{{activeTab == 'hotel' ? '暂无相关酒店服务' : '暂无相关公寓租赁服务'}}</text></view>
                <view v-else-if="hasLoaded">
                    <view class="service-item" :class="item.available ? '' : 'is-unavailable'"
                        v-for="item in visibleServices" :key="item.id" @tap="openService(item)">
                        <image class="service-image" :src="item.image" mode="aspectFill"
                            @error="replaceServiceImage(item)"></image>
                        <view class="service-info"><text class="service-name">{{item.name}}</text><text
                                class="discount-tag">{{item.available ? item.discount : '已下架'}}</text>
                            <view class="address">
                                <image src="/static/benefits-assets/map-pin.svg"></image><text>{{item.address}}</text>
                            </view>
                        </view>
                    </view>
                </view>
                <view v-if="loadingMore" class="list-state compact">
                    <view class="loading-spinner"></view><text>正在加载</text>
                </view>
                <view v-else-if="loadMoreError" class="list-state compact load-error" @tap="retryLoadMore">
                    <text>加载失败,点击重试</text></view>
                <view v-else-if="hasLoaded && !hasMore && visibleServices.length > 0" class="no-more"><text>已经到底了</text>
                </view>
            </view>
            <view class="bottom-space"></view>
        </scroll-view>
    </view>
</template>
@@ -26,9 +91,39 @@
        if (options != null && options['previewState'] != null) { const state = options['previewState'] as string; failInitialOnce.value = state == 'initialError'; failLoadMoreOnce.value = state == 'loadMoreError' }
        loadStayTopic(); loadInitialServices()
    })
    const readText = (primary : any, fallback : any) : string => {
        if (primary != null && primary != '') return primary as string
        if (fallback != null && fallback != '') return fallback as string
        return ''
    }
    const readAvailable = (item : any) : boolean => {
        if (item.available != null) return item.available as boolean
        if (item.status != null) return Number(item.status) == 1
        return true
    }
    const loadStayTopic = () => {
        if (!isApiConfigured()) return
        getStayTopic((data: any) => { topicDescription.value = data != null && data.description != null ? data.description as string : ''; const records: any[] = data != null && data.images instanceof Array ? data.images as any[] : []; const next: any[] = []; for (let index = 0; index < records.length; index++) { const item = records[index]; if (item.imageUrl != null && item.imageUrl != '') next.push({ id: '' + item.id, name: item.name != null ? item.name as string : '', image: resolveApiUrl(item.imageUrl as string) }) }; scenes.value = next; sceneTrackWidth.value = 644 + next.length * 137; if (next.length > 0) selectScene(Math.min(2, next.length - 1)) }, () => { scenes.value = []; topicDescription.value = '' })
        getStayTopic((data : any) => {
            topicDescription.value = data != null ? readText(data.description, data.intro) : ''
            const records : any[] = data != null && data.images instanceof Array ? data.images as any[] : []
            const next : any[] = []
            for (let index = 0; index < records.length; index++) {
                const item = records[index]
                const imageUrl = readText(item.imageUrl, item['image_url'])
                if (imageUrl == '') continue
                next.push({
                    id: '' + item.id,
                    name: readText(item.name, item['item_name']),
                    image: resolveApiUrl(imageUrl)
                })
            }
            scenes.value = next
            sceneTrackWidth.value = 644 + next.length * 137
            if (next.length > 0) selectScene(Math.min(2, next.length - 1))
        }, () => {
            scenes.value = []
            topicDescription.value = ''
        })
    }
    const loadInitialServices = () => { listRequestVersion.value++; currentPage.value = 0; visibleServices.value = []; hasMore.value = false; initialLoading.value = true; initialError.value = false; loadMoreError.value = false; hasLoaded.value = false; loadStayServices(false) }
    const retryInitialLoad = () => loadInitialServices(); const retryLoadMore = () => loadMore()
@@ -37,11 +132,59 @@
    const handleScroll = (event: any) => { isSolidNav.value = event.detail.scrollTop > 64 }
    const switchTab = (tab: string) => { if (activeTab.value == tab && hasLoaded.value) return; activeTab.value = tab; loadInitialServices() }
    const loadStayServices = (append: boolean) => {
        if (!isApiConfigured()) { initialLoading.value = false; loadingMore.value = false; initialError.value = true; return }
        if (!isApiConfigured()) {
            initialLoading.value = false
            loadingMore.value = false
            initialError.value = true
            return
        }
        const requestVersion = listRequestVersion.value
        if (!append && failInitialOnce.value) { failInitialOnce.value = false; initialLoading.value = false; initialError.value = true; return }
        if (append && failLoadMoreOnce.value) { failLoadMoreOnce.value = false; loadingMore.value = false; loadMoreError.value = true; return }
        getStayTopicMerchants(activeTab.value, currentPage.value + 1, 4, (data: any) => { if (requestVersion != listRequestVersion.value) return; const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []; const next = append ? visibleServices.value.slice() : []; for (let index = 0; index < records.length; index++) { const item = records[index]; next.push({ id: '' + item.id, merchantType: item.merchantType != null ? item.merchantType as string : activeTab.value, name: item.merchantName != null ? item.merchantName as string : '住宿服务', discount: item.discountText != null ? item.discountText as string : '人才专享', address: item.address != null ? item.address as string : '', image: item.coverUrl != null && item.coverUrl != '' ? resolveApiUrl(item.coverUrl as string) : fallbackImage, available: item.available !== false }) }; visibleServices.value = next; currentPage.value++; hasMore.value = data != null && data.total != null ? next.length < data.total : false; initialLoading.value = false; loadingMore.value = false; hasLoaded.value = true }, () => { if (requestVersion != listRequestVersion.value) return; initialLoading.value = false; loadingMore.value = false; if (append) loadMoreError.value = true; else initialError.value = true })
        if (!append && failInitialOnce.value) {
            failInitialOnce.value = false
            initialLoading.value = false
            initialError.value = true
            return
        }
        if (append && failLoadMoreOnce.value) {
            failLoadMoreOnce.value = false
            loadingMore.value = false
            loadMoreError.value = true
            return
        }
        getStayTopicMerchants(activeTab.value, currentPage.value + 1, 4, (data : any) => {
            if (requestVersion != listRequestVersion.value) return
            const records : any[] = data != null && data.records instanceof Array ? data.records as any[] : []
            const next = append ? visibleServices.value.slice() : []
            for (let index = 0; index < records.length; index++) {
                const item = records[index]
                let coverUrl = readText(item.coverUrl, item['cover_url'])
                if (coverUrl == '') coverUrl = readText(item.logoUrl, item['logo_url'])
                const merchantType = readText(item.merchantType, item['merchant_type'])
                const merchantName = readText(item.merchantName, item['merchant_name'])
                const discountText = readText(item.discountText, item['discount_text'])
                next.push({
                    id: '' + item.id,
                    merchantType: merchantType != '' ? merchantType : activeTab.value,
                    name: merchantName != '' ? merchantName : '\u4f4f\u5bbf\u670d\u52a1',
                    discount: discountText != '' ? discountText : '\u4eba\u624d\u4e13\u4eab',
                    address: readText(item.address, item['merchant_address']),
                    image: coverUrl != '' ? resolveApiUrl(coverUrl) : fallbackImage,
                    available: readAvailable(item)
                })
            }
            visibleServices.value = next
            currentPage.value++
            hasMore.value = data != null && data.total != null ? next.length < data.total : false
            initialLoading.value = false
            loadingMore.value = false
            hasLoaded.value = true
        }, () => {
            if (requestVersion != listRequestVersion.value) return
            initialLoading.value = false
            loadingMore.value = false
            if (append) loadMoreError.value = true
            else initialError.value = true
        })
    }
    const loadMore = () => { if (initialLoading.value || loadingMore.value || !hasMore.value) return; loadMoreError.value = false; loadingMore.value = true; loadStayServices(true) }
    const replaceHeroImage = () => { currentScene.value.image = fallbackImage }; const replaceSceneImage = (index: number) => { if (index >= 0 && index < scenes.value.length) scenes.value[index].image = fallbackImage }; const replaceServiceImage = (item: any) => { item.image = fallbackImage }
@@ -49,5 +192,310 @@
</script>
<style>
    @font-face { font-family:'FoodCalligraphy'; src:url('/static/fonts/food-calligraphy-subset.ttf') format('truetype'); }.page,.page-scroll{width:100%;height:100vh;background:#FFF}.custom-nav{position:fixed;z-index:20;left:0;right:0;top:0;padding-bottom:20rpx}.nav-bg{position:absolute;inset:0;opacity:0;background:#F26A38}.custom-nav.is-solid .nav-bg{opacity:1}.safe-space,.nav-row{position:relative;z-index:1}.safe-space{height:28px}.nav-row{display:flex;height:32px;box-sizing:border-box;padding-left:24rpx;padding-right:10px;align-items:center;justify-content:space-between}.back-button{display:flex;width:54rpx;height:54rpx;border-radius:50%;background:rgba(45,45,45,.52);align-items:center;justify-content:center}.custom-nav.is-solid .back-button{background:transparent}.back-button image{width:38rpx;height:38rpx}.nav-title{position:absolute;left:0;right:0;color:#FFF;font-size:31rpx;text-align:center;text-shadow:0 2rpx 8rpx rgba(0,0,0,.35)}.capsule-space{width:88px;height:100%;flex-shrink:0}.hero{height:730rpx;position:relative;overflow:visible;background:#1D315D}.hero-image{position:absolute;inset:0;width:100%;height:730rpx}.hero-shade{position:absolute;left:0;right:0;top:0;height:360rpx;background:linear-gradient(180deg,rgba(0,0,0,.58),transparent)}.scene-name{position:absolute;z-index:2;left:38rpx;top:300rpx;color:#FFF;font-family:'FoodCalligraphy';font-size:48rpx;text-shadow:0 3rpx 10rpx rgba(0,0,0,.55)}.no-gallery{position:absolute;z-index:2;top:480rpx;left:0;right:0;color:#FFF;text-align:center}.scene-scroll{position:absolute;z-index:4;left:0;right:0;top:469rpx;height:150rpx;white-space:nowrap}.scene-row{display:flex;height:150rpx;align-items:center}.scene-pad{width:322rpx;flex-shrink:0}.scene-item{width:106rpx;height:106rpx;margin-right:31rpx;overflow:hidden;border:3rpx solid rgba(255,255,255,.9);border-radius:8rpx;flex-shrink:0}.scene-item image{width:100%;height:100%}.scene-item.active{border:4rpx solid #FFD329;transform:scale(1.28)}.topic-summary{margin-top:-66rpx;position:relative;z-index:4}.intro-card{margin:0 31rpx;padding:25rpx 31rpx;border-radius:26rpx;background:linear-gradient(90deg,#FF5320,#FF8A22)}.intro-card text{color:#FFF;font-size:23rpx;line-height:34rpx}.service-section{padding:48rpx 31rpx 0}.service-tabs{display:flex;height:72rpx}.service-tabs>view{display:flex;margin-right:42rpx;flex-direction:column;align-items:center}.service-tabs text{color:#8A8D92;font-size:31rpx;font-weight:600}.service-tabs .active text{color:#363636}.service-tabs view view{width:0;height:7rpx;margin-top:12rpx;border-radius:4rpx;background:linear-gradient(90deg,#FF4D36,#FFBF3A)}.service-tabs .active view{width:61rpx}.service-item{display:flex;min-height:205rpx;margin-bottom:20rpx}.is-unavailable{opacity:.56}.service-image{width:184rpx;height:184rpx;border-radius:17rpx;flex-shrink:0;background:#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}.discount-tag{display:block;width:fit-content;padding:6rpx 9rpx;border-radius:6rpx;background:#FFF1EC;color:#FF653D;font-size:21rpx}.address{display:flex;min-width:0;padding:7rpx 10rpx;border-radius:7rpx;background:#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;align-items:center;justify-content: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{to{transform:rotate(360deg)}}.bottom-space{height:calc(30rpx + env(safe-area-inset-bottom))}
    @font-face {
        font-family: 'StayCalligraphy';
        src: url('/static/fonts/stay-calligraphy-subset.ttf') format('truetype');
        font-weight: normal;
        font-style: normal;
    }
    .page,
    .page-scroll {
        width: 100%;
        height: 100vh;
        background-color: #FFFFFF;
        font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
    }
    .custom-nav {
        position: fixed;
        z-index: 20;
        left: 0;
        right: 0;
        top: 0;
        padding-bottom: 10rpx;
    }
    .nav-bg {
        position: absolute;
        inset: 0;
        opacity: 0;
        background-color: #F26A38;
        transition: opacity 160ms ease-out;
    }
    .custom-nav.is-solid .nav-bg { opacity: 1; }
    .safe-space,
    .nav-row { position: relative; z-index: 1; }
    .safe-space { height: 28px; }
    .nav-row {
        display: flex;
        height: 32px;
        box-sizing: border-box;
        padding-left: 20rpx;
        padding-right: 10px;
        flex-direction: row;
        align-items: center;
        justify-content: space-between;
    }
    .back-button {
        display: flex;
        width: 54rpx;
        height: 54rpx;
        position: relative;
        z-index: 3;
        border-radius: 50%;
        background-color: rgba(27, 38, 62, .34);
        align-items: center;
        justify-content: center;
    }
    .custom-nav.is-solid .back-button { background-color: transparent; }
    .back-button-active { opacity: .62; }
    .back-button image { width: 36rpx; height: 36rpx; pointer-events: none; }
    .nav-title {
        position: absolute;
        z-index: 1;
        left: 0;
        right: 0;
        color: #FFFFFF;
        font-size: 31rpx;
        font-weight: 500;
        line-height: 42rpx;
        text-align: center;
        text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, .32);
    }
    .capsule-space { width: 88px; height: 100%; flex-shrink: 0; }
    .hero {
        height: 590rpx;
        position: relative;
        overflow: visible;
        background-color: #1D315D;
    }
    .hero-image {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 590rpx;
    }
    .hero-shade {
        position: absolute;
        left: 0;
        right: 0;
        top: 0;
        height: 300rpx;
        background-image: linear-gradient(180deg, rgba(10, 22, 51, .55), rgba(10, 22, 51, 0));
    }
    .scene-name {
        position: absolute;
        z-index: 2;
        left: 38rpx;
        top: 245rpx;
        max-width: 650rpx;
        color: #FFFFFF;
        font-family: 'StayCalligraphy', 'STXingkai', 'KaiTi', cursive;
        font-size: 48rpx;
        font-weight: normal;
        font-style: normal;
        line-height: 66rpx;
        letter-spacing: 1rpx;
        text-shadow: 0 3rpx 10rpx rgba(0, 0, 0, .55);
    }
    .no-gallery {
        position: absolute;
        z-index: 2;
        top: 390rpx;
        left: 0;
        right: 0;
        color: rgba(255,255,255,.9);
        font-size: 23rpx;
        text-align: center;
    }
    .scene-scroll {
        position: absolute;
        z-index: 4;
        left: 0;
        right: 0;
        top: 385rpx;
        height: 132rpx;
        white-space: nowrap;
    }
    .scene-row {
        display: flex;
        height: 132rpx;
        flex-direction: row;
        align-items: center;
    }
    .scene-pad { width: 322rpx; height: 1rpx; flex-shrink: 0; }
    .scene-item {
        display: flex;
        width: 106rpx;
        height: 106rpx;
        margin-right: 31rpx;
        box-sizing: border-box;
        overflow: hidden;
        position: relative;
        z-index: 1;
        border: 3rpx solid rgba(255,255,255,.92);
        border-radius: 8rpx;
        flex-shrink: 0;
        transition: transform 160ms ease-out;
    }
    .scene-item image { width: 100%; height: 100%; }
    .scene-item.active {
        z-index: 3;
        border-width: 4rpx;
        border-color: #FFD329;
        transform: scale(1.13);
        box-shadow: 0 8rpx 18rpx rgba(0,0,0,.28);
    }
    .topic-summary {
        margin-top: -65rpx;
        position: relative;
        z-index: 5;
    }
    .intro-card {
        display: flex;
        min-height: 110rpx;
        margin: 0 32rpx;
        box-sizing: border-box;
        padding: 18rpx 24rpx;
        border-radius: 16rpx;
        background-image: linear-gradient(90deg, #FF5425 0%, #FF861D 100%);
        box-shadow: 0 9rpx 22rpx rgba(255, 104, 28, .26);
        align-items: center;
    }
    .intro-card text {
        color: #FFFFFF;
        font-size: 23rpx;
        line-height: 34rpx;
    }
    .service-section { padding: 20rpx 32rpx 0; }
    .service-tabs {
        display: flex;
        height: 70rpx;
        flex-direction: row;
        align-items: flex-start;
    }
    .service-tabs > view {
        display: flex;
        margin-right: 44rpx;
        flex-direction: column;
        align-items: center;
    }
    .service-tabs text {
        color: #8A8D92;
        font-size: 31rpx;
        font-weight: 600;
        line-height: 42rpx;
    }
    .service-tabs .active text { color: #303238; font-weight: 700; }
    .service-tabs view view {
        width: 0;
        height: 5rpx;
        margin-top: 7rpx;
        border-radius: 4rpx;
        background-image: linear-gradient(90deg, #FF4D36, #FFBF3A);
        transition: width 160ms ease-out;
    }
    .service-tabs .active view { width: 48rpx; }
    .service-item {
        display: flex;
        min-height: 156rpx;
        margin-bottom: 18rpx;
        flex-direction: row;
        align-items: flex-start;
    }
    .is-unavailable { opacity: .56; }
    .service-image {
        width: 136rpx;
        height: 136rpx;
        border-radius: 14rpx;
        flex-shrink: 0;
        background-color: #EEF0F2;
    }
    .service-info {
        display: flex;
        min-width: 0;
        min-height: 136rpx;
        box-sizing: border-box;
        padding: 0 0 0 20rpx;
        flex: 1;
        flex-direction: column;
        align-items: flex-start;
        justify-content: space-between;
    }
    .service-name {
        display: block;
        width: 100%;
        color: #292B30;
        font-size: 29rpx;
        font-weight: 700;
        line-height: 40rpx;
    }
    .discount-tag {
        display: block;
        width: fit-content;
        box-sizing: border-box;
        padding: 3rpx 8rpx;
        border-radius: 5rpx;
        background-color: #FFF0EB;
        color: #FF6239;
        font-size: 21rpx;
        line-height: 29rpx;
    }
    .address {
        display: flex;
        max-width: 100%;
        box-sizing: border-box;
        padding: 3rpx 7rpx;
        border-radius: 5rpx;
        background-color: #F3F4F5;
        flex-direction: row;
        align-items: center;
    }
    .address image { width: 21rpx; height: 21rpx; margin-right: 5rpx; flex-shrink: 0; }
    .address text {
        min-width: 0;
        color: #7D838E;
        font-size: 21rpx;
        line-height: 31rpx;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }
    .list-state,
    .no-more {
        display: flex;
        height: 132rpx;
        color: #989DA5;
        font-size: 23rpx;
        align-items: center;
        justify-content: center;
    }
    .list-state.compact { height: 82rpx; }
    .error-state { flex-direction: column; }
    .retry-text,
    .load-error text { margin-top: 16rpx; 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 { to { transform: rotate(360deg); } }
    .bottom-space { height: calc(30rpx + env(safe-area-inset-bottom)); }
</style>
talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
@@ -291,7 +291,13 @@
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> stayMerchants(String type, int page, int size) {
        if (!List.of("hotel", "apartment").contains(type)) throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的住宿服务类型");
        String requestedType = type == null ? "" : type.trim();
        String merchantType = switch (requestedType) {
            case "hotel", "酒店服务" -> "酒店服务";
            case "apartment", "公寓租赁服务" -> "公寓租赁服务";
            default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的住宿服务类型");
        };
        String responseMerchantType = merchantType.equals("公寓租赁服务") ? "apartment" : "hotel";
        String merchantWhere = " where deleted = 0 and merchant_type = ?";
        PageResult<Map<String, Object>> result = page("""
                select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url, address,
@@ -300,8 +306,12 @@
                       (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
                """ + merchantWhere + " order by sort_no asc, id asc",
                "select count(*) from merchant" + merchantWhere, page, size, type);
        List<Map<String, Object>> records = result.records().stream().map(this::enrichMerchantSearchRow).toList();
                "select count(*) from merchant" + merchantWhere, page, size, merchantType);
        List<Map<String, Object>> records = result.records().stream().map(row -> {
            Map<String, Object> normalized = new LinkedHashMap<>(row);
            normalized.put("merchantType", responseMerchantType);
            return enrichMerchantSearchRow(normalized);
        }).toList();
        return new PageResult<>(records, result.page(), result.size(), result.total());
    }