From f130d019f4315af0225497d8d7f4e1b87e1e5bbc Mon Sep 17 00:00:00 2001
From: wenminjie <1036699586@qq.com>
Date: 星期三, 05 八月 2026 18:05:04 +0800
Subject: [PATCH] 二期后端小程序对接:亲属卡

---
 talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java |   16 +
 rencaika/pages/stay-benefits/stay-benefits.uvue                                           |  486 +++++++++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 480 insertions(+), 22 deletions(-)

diff --git a/rencaika/pages/stay-benefits/stay-benefits.uvue b/rencaika/pages/stay-benefits/stay-benefits.uvue
index a700bae..1c5b9c5 100644
--- a/rencaika/pages/stay-benefits/stay-benefits.uvue
+++ b/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>
@@ -19,35 +84,418 @@
 	const activeIndex = ref(-1); const sceneScrollLeft = ref(0); const sceneTrackWidth = ref(644); const scenes = ref<any[]>([]); const currentScene = ref({ id: 'fallback', name: '', image: fallbackImage }); const topicDescription = ref('')
 	const activeTab = ref('hotel'); const visibleServices = ref<any[]>([]); const initialLoading = ref(true); const initialError = ref(false); const loadingMore = ref(false); const loadMoreError = ref(false); const hasLoaded = ref(false); const hasMore = ref(false); const currentPage = ref(0); const listRequestVersion = ref(0); const failInitialOnce = ref(false); const failLoadMoreOnce = ref(false)
 
-	onLoad((options: any) => {
+	onLoad((options : any) => {
 		// #ifdef MP-WEIXIN
 		const capsule = uni.getMenuButtonBoundingClientRect(); const info = uni.getSystemInfoSync(); windowWidth.value = info.windowWidth; navTop.value = capsule.top; navHeight.value = capsule.height; navWidth.value = capsule.width; navRight.value = info.windowWidth - capsule.right
 		// #endif
 		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()
-	const selectScene = (index: number) => { if (index < 0 || index >= scenes.value.length) return; activeIndex.value = index; currentScene.value = scenes.value[index]; sceneScrollLeft.value = index * 137 * windowWidth.value / 750 }
-	const handleSceneScrollEnd = (event: any) => { let index = Math.round(event.detail.scrollLeft * 750 / windowWidth.value / 137); if (index < 0) index = 0; if (index >= scenes.value.length) index = scenes.value.length - 1; selectScene(index) }
-	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 }
+	const selectScene = (index : number) => { if (index < 0 || index >= scenes.value.length) return; activeIndex.value = index; currentScene.value = scenes.value[index]; sceneScrollLeft.value = index * 137 * windowWidth.value / 750 }
+	const handleSceneScrollEnd = (event : any) => { let index = Math.round(event.detail.scrollLeft * 750 / windowWidth.value / 137); if (index < 0) index = 0; if (index >= scenes.value.length) index = scenes.value.length - 1; selectScene(index) }
+	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
+		}
 		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 }
-	const goBack = () => safeBack('/pages/benefits/benefits'); const openService = (item: any) => { if (!item.available) { uni.showToast({ title: '该服务已下架', icon: 'none' }); return }; uni.navigateTo({ url: '/pages/merchant/merchant?merchantType=' + encodeURIComponent(item.merchantType) + '&merchantId=' + encodeURIComponent(item.id) + '&name=' + encodeURIComponent(item.name) + '&image=' + encodeURIComponent(item.image) + '&address=' + encodeURIComponent(item.address) + '&discount=' + encodeURIComponent(item.discount) }) }
+	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 }
+	const goBack = () => safeBack('/pages/benefits/benefits'); const openService = (item : any) => { if (!item.available) { uni.showToast({ title: '该服务已下架', icon: 'none' }); return }; uni.navigateTo({ url: '/pages/merchant/merchant?merchantType=' + encodeURIComponent(item.merchantType) + '&merchantId=' + encodeURIComponent(item.id) + '&name=' + encodeURIComponent(item.name) + '&image=' + encodeURIComponent(item.image) + '&address=' + encodeURIComponent(item.address) + '&discount=' + encodeURIComponent(item.discount) }) }
 </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))}
-</style>
+	@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>
\ No newline at end of file
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java b/talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
index 249aabd..b109211 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/portal/PortalService.java
+++ b/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());
 	}
 

--
Gitblit v1.7.1